@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/cli/index.js
CHANGED
|
@@ -2573,7 +2573,8 @@ var chalkStderr = createChalk({ level: stderrColor ? stderrColor.level : 0 });
|
|
|
2573
2573
|
var source_default = chalk;
|
|
2574
2574
|
|
|
2575
2575
|
// src/cli/index.ts
|
|
2576
|
-
import {
|
|
2576
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
2577
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs";
|
|
2577
2578
|
|
|
2578
2579
|
// src/checks.ts
|
|
2579
2580
|
import net from "net";
|
|
@@ -2583,7 +2584,11 @@ async function runMonitorCheck(monitor, options = {}) {
|
|
|
2583
2584
|
}
|
|
2584
2585
|
if (monitor.kind === "http")
|
|
2585
2586
|
return runHttpCheck(monitor, options.fetch ?? fetch);
|
|
2586
|
-
|
|
2587
|
+
if (monitor.kind === "browser_page")
|
|
2588
|
+
return runBrowserPageCheck(monitor, { fetch: options.fetch, runner: options.browserPage });
|
|
2589
|
+
if (monitor.kind === "tcp")
|
|
2590
|
+
return runTcpCheck(monitor);
|
|
2591
|
+
return { status: "down", latencyMs: null, error: `unsupported monitor kind: ${monitor.kind ?? "unknown"}` };
|
|
2587
2592
|
}
|
|
2588
2593
|
async function runHttpCheck(monitor, fetchImpl = fetch) {
|
|
2589
2594
|
if (!monitor.url)
|
|
@@ -2641,14 +2646,744 @@ async function runTcpCheck(monitor) {
|
|
|
2641
2646
|
});
|
|
2642
2647
|
});
|
|
2643
2648
|
}
|
|
2649
|
+
async function runBrowserPageCheck(monitor, options = {}) {
|
|
2650
|
+
if (!monitor.url)
|
|
2651
|
+
return { status: "down", latencyMs: null, error: "missing url" };
|
|
2652
|
+
validateBrowserPageUrl(monitor.url);
|
|
2653
|
+
if (!options.runner) {
|
|
2654
|
+
const evidence = normalizeBrowserEvidence(monitor.url, {
|
|
2655
|
+
finalUrl: monitor.url,
|
|
2656
|
+
navigationStatus: null,
|
|
2657
|
+
pageErrors: ["browser_page checks require a configured browser runner"]
|
|
2658
|
+
});
|
|
2659
|
+
return {
|
|
2660
|
+
status: "down",
|
|
2661
|
+
latencyMs: null,
|
|
2662
|
+
statusCode: null,
|
|
2663
|
+
error: "browser_page checks require a configured browser runner",
|
|
2664
|
+
evidence
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
const started = performance.now();
|
|
2668
|
+
try {
|
|
2669
|
+
const raw = await options.runner(monitor);
|
|
2670
|
+
const latencyMs = raw.latencyMs ?? Math.round((performance.now() - started) * 100) / 100;
|
|
2671
|
+
const evidence = normalizeBrowserEvidence(monitor.url, raw);
|
|
2672
|
+
const statusCode = raw.navigationStatus ?? evidence.navigationStatus;
|
|
2673
|
+
const statusOk = statusCode == null ? false : monitor.expectedStatus == null ? statusCode >= 200 && statusCode < 400 : statusCode === monitor.expectedStatus;
|
|
2674
|
+
const browserFailures = evidence.consoleErrors.length + evidence.pageErrors.length + evidence.failedRequests.length;
|
|
2675
|
+
return {
|
|
2676
|
+
status: statusOk && browserFailures === 0 ? "up" : "down",
|
|
2677
|
+
latencyMs,
|
|
2678
|
+
statusCode,
|
|
2679
|
+
error: statusOk ? browserFailures === 0 ? null : `browser page captured ${browserFailures} error signal${browserFailures === 1 ? "" : "s"}` : `unexpected navigation status ${statusCode ?? "unknown"}`,
|
|
2680
|
+
evidence
|
|
2681
|
+
};
|
|
2682
|
+
} catch (error) {
|
|
2683
|
+
const safeError = redactText(error instanceof Error ? error.message : String(error));
|
|
2684
|
+
const evidence = normalizeBrowserEvidence(monitor.url, {
|
|
2685
|
+
finalUrl: monitor.url,
|
|
2686
|
+
navigationStatus: null,
|
|
2687
|
+
pageErrors: [safeError]
|
|
2688
|
+
});
|
|
2689
|
+
return {
|
|
2690
|
+
status: "down",
|
|
2691
|
+
latencyMs: Math.round((performance.now() - started) * 100) / 100,
|
|
2692
|
+
statusCode: null,
|
|
2693
|
+
error: safeError,
|
|
2694
|
+
evidence
|
|
2695
|
+
};
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
function normalizeBrowserEvidence(sourceUrl, raw) {
|
|
2699
|
+
return {
|
|
2700
|
+
kind: "browser_page",
|
|
2701
|
+
finalUrl: raw.finalUrl ? redactUrl(raw.finalUrl) : redactUrl(sourceUrl),
|
|
2702
|
+
navigationStatus: raw.navigationStatus ?? null,
|
|
2703
|
+
consoleErrors: sanitizeStrings(raw.consoleErrors ?? []),
|
|
2704
|
+
pageErrors: sanitizeStrings(raw.pageErrors ?? []),
|
|
2705
|
+
failedRequests: (raw.failedRequests ?? []).slice(0, 50).map((request) => ({
|
|
2706
|
+
url: redactUrl(request.url),
|
|
2707
|
+
statusCode: request.statusCode ?? null,
|
|
2708
|
+
error: request.error ? redactText(request.error) : null
|
|
2709
|
+
})),
|
|
2710
|
+
screenshot: raw.screenshot ? sanitizeArtifact(raw.screenshot) : null,
|
|
2711
|
+
artifacts: (raw.artifacts ?? []).slice(0, 20).map(sanitizeArtifact),
|
|
2712
|
+
redacted: true,
|
|
2713
|
+
redactionStatus: "redacted",
|
|
2714
|
+
retentionClass: "short"
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
function validateBrowserPageUrl(value) {
|
|
2718
|
+
const parsed = new URL(value);
|
|
2719
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
2720
|
+
throw new Error("browser_page monitors require an http or https URL");
|
|
2721
|
+
}
|
|
2722
|
+
if (parsed.username || parsed.password) {
|
|
2723
|
+
throw new Error("browser_page URLs must not contain userinfo");
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
function sanitizeStrings(values) {
|
|
2727
|
+
return values.slice(0, 50).map(redactText).filter(Boolean);
|
|
2728
|
+
}
|
|
2729
|
+
function sanitizeArtifact(artifact) {
|
|
2730
|
+
const ref = artifact.ref.trim();
|
|
2731
|
+
if (artifact.path || ref.startsWith("/") || ref.toLowerCase().startsWith("file:")) {
|
|
2732
|
+
throw new Error("browser evidence artifacts must use redacted artifact refs, not local paths");
|
|
2733
|
+
}
|
|
2734
|
+
if (!artifact.sha256 || !/^[a-f0-9]{64}$/i.test(artifact.sha256)) {
|
|
2735
|
+
throw new Error("browser evidence artifacts require a sha256 checksum");
|
|
2736
|
+
}
|
|
2737
|
+
const bytes = artifact.bytes;
|
|
2738
|
+
if (!Number.isInteger(bytes) || bytes == null || bytes < 0) {
|
|
2739
|
+
throw new Error("browser evidence artifacts require a byte size");
|
|
2740
|
+
}
|
|
2741
|
+
return {
|
|
2742
|
+
ref: redactText(ref),
|
|
2743
|
+
sha256: artifact.sha256,
|
|
2744
|
+
bytes,
|
|
2745
|
+
contentType: redactText(artifact.contentType ?? "application/octet-stream") || "application/octet-stream",
|
|
2746
|
+
retentionClass: "short"
|
|
2747
|
+
};
|
|
2748
|
+
}
|
|
2749
|
+
function redactUrl(value) {
|
|
2750
|
+
try {
|
|
2751
|
+
const parsed = new URL(value);
|
|
2752
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
2753
|
+
return "[blocked-url]";
|
|
2754
|
+
}
|
|
2755
|
+
parsed.username = "";
|
|
2756
|
+
parsed.password = "";
|
|
2757
|
+
parsed.hash = "";
|
|
2758
|
+
for (const key of parsed.searchParams.keys()) {
|
|
2759
|
+
if (isSecretKey(key))
|
|
2760
|
+
parsed.searchParams.set(key, "[redacted]");
|
|
2761
|
+
}
|
|
2762
|
+
return parsed.toString();
|
|
2763
|
+
} catch {
|
|
2764
|
+
return redactText(value);
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
function redactText(value) {
|
|
2768
|
+
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]");
|
|
2769
|
+
}
|
|
2770
|
+
function isSecretKey(value) {
|
|
2771
|
+
return /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i.test(value);
|
|
2772
|
+
}
|
|
2644
2773
|
|
|
2645
2774
|
// src/service.ts
|
|
2646
|
-
import { randomUUID as
|
|
2775
|
+
import { createPublicKey, randomUUID as randomUUID3 } from "crypto";
|
|
2647
2776
|
|
|
2648
|
-
// src/
|
|
2649
|
-
import { mkdirSync as mkdirSync2 } from "fs";
|
|
2650
|
-
import { dirname } from "path";
|
|
2777
|
+
// src/imports.ts
|
|
2651
2778
|
import { randomUUID } from "crypto";
|
|
2779
|
+
|
|
2780
|
+
// src/limits.ts
|
|
2781
|
+
var MIN_INTERVAL_SECONDS = 1;
|
|
2782
|
+
var MAX_INTERVAL_SECONDS = 86400;
|
|
2783
|
+
var MIN_TIMEOUT_MS = 1;
|
|
2784
|
+
var MAX_TIMEOUT_MS = 60000;
|
|
2785
|
+
var MIN_RETRY_COUNT = 0;
|
|
2786
|
+
var MAX_RETRY_COUNT = 10;
|
|
2787
|
+
var MAX_RESULT_LIMIT = 1000;
|
|
2788
|
+
|
|
2789
|
+
// src/target-policy.ts
|
|
2790
|
+
import net2 from "net";
|
|
2791
|
+
var SECRET_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
|
|
2792
|
+
function assertHostedTargetAllowed(target) {
|
|
2793
|
+
if (target.kind === "http" || target.kind === "browser_page") {
|
|
2794
|
+
if (!target.url)
|
|
2795
|
+
throw new Error("HTTP monitors require url");
|
|
2796
|
+
assertHostedHttpUrlAllowed(target.url);
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2799
|
+
if (target.kind === "tcp") {
|
|
2800
|
+
if (!target.host)
|
|
2801
|
+
throw new Error("TCP monitors require host");
|
|
2802
|
+
assertHostedHostAllowed(target.host, "TCP host");
|
|
2803
|
+
if (!Number.isInteger(target.port) || target.port <= 0 || target.port > 65535) {
|
|
2804
|
+
throw new Error("TCP monitors require a port from 1 to 65535");
|
|
2805
|
+
}
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
throw new Error("Monitor kind must be http, tcp, or browser_page");
|
|
2809
|
+
}
|
|
2810
|
+
function assertHostedHttpUrlAllowed(value) {
|
|
2811
|
+
const parsed = new URL(value);
|
|
2812
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
2813
|
+
throw new Error("HTTP monitor url must use http or https");
|
|
2814
|
+
}
|
|
2815
|
+
if (parsed.username || parsed.password) {
|
|
2816
|
+
throw new Error("hosted target URLs must not contain userinfo");
|
|
2817
|
+
}
|
|
2818
|
+
for (const key of parsed.searchParams.keys()) {
|
|
2819
|
+
if (SECRET_PARAM_PATTERN.test(key)) {
|
|
2820
|
+
throw new Error(`hosted target URL query parameter is not allowed: ${key}`);
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
if (parsed.hash && SECRET_PARAM_PATTERN.test(parsed.hash)) {
|
|
2824
|
+
throw new Error("hosted target URL fragment contains secret-like data");
|
|
2825
|
+
}
|
|
2826
|
+
assertHostedHostAllowed(parsed.hostname, "HTTP host");
|
|
2827
|
+
}
|
|
2828
|
+
function assertHostedHostAllowed(hostname, label = "host") {
|
|
2829
|
+
const host = normalizeHost(hostname);
|
|
2830
|
+
if (!host)
|
|
2831
|
+
throw new Error(`${label} is required`);
|
|
2832
|
+
if (host === "localhost" || host.endsWith(".localhost")) {
|
|
2833
|
+
throw new Error(`${label} is not allowed in hosted mode: localhost`);
|
|
2834
|
+
}
|
|
2835
|
+
if (host.endsWith(".local") || host.endsWith(".internal")) {
|
|
2836
|
+
throw new Error(`${label} is not allowed in hosted mode: private DNS name`);
|
|
2837
|
+
}
|
|
2838
|
+
const ipVersion = net2.isIP(host);
|
|
2839
|
+
if (ipVersion === 4 && isDeniedIpv4(host)) {
|
|
2840
|
+
throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv4`);
|
|
2841
|
+
}
|
|
2842
|
+
if (ipVersion === 6 && isDeniedIpv6(host)) {
|
|
2843
|
+
throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv6`);
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
function normalizeHost(hostname) {
|
|
2847
|
+
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
2848
|
+
}
|
|
2849
|
+
function isDeniedIpv4(ip) {
|
|
2850
|
+
const parts = ip.split(".").map((part) => Number(part));
|
|
2851
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
2852
|
+
return true;
|
|
2853
|
+
}
|
|
2854
|
+
const [a, b] = parts;
|
|
2855
|
+
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;
|
|
2856
|
+
}
|
|
2857
|
+
function isDeniedIpv6(ip) {
|
|
2858
|
+
const normalized = ip.toLowerCase();
|
|
2859
|
+
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.");
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
// src/imports.ts
|
|
2863
|
+
function previewImport(store, request) {
|
|
2864
|
+
const source = normalizeSource(request.source);
|
|
2865
|
+
const items = dedupePreviewItems(request.records.map((record) => previewRecord(store, source, record, request.defaults ?? {})));
|
|
2866
|
+
return {
|
|
2867
|
+
source,
|
|
2868
|
+
generatedAt: new Date().toISOString(),
|
|
2869
|
+
dryRun: true,
|
|
2870
|
+
items,
|
|
2871
|
+
totals: countActions(items)
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
function dedupePreviewItems(items) {
|
|
2875
|
+
const seenSources = new Set;
|
|
2876
|
+
const seenNames = new Set;
|
|
2877
|
+
return items.map((item) => {
|
|
2878
|
+
if (item.action === "blocked")
|
|
2879
|
+
return item;
|
|
2880
|
+
const sourceKey = `${item.candidate.source}:${item.candidate.sourceId}`;
|
|
2881
|
+
const nameKey = item.candidate.name.toLowerCase();
|
|
2882
|
+
if (seenSources.has(sourceKey) || seenNames.has(nameKey)) {
|
|
2883
|
+
return {
|
|
2884
|
+
...item,
|
|
2885
|
+
action: "conflict",
|
|
2886
|
+
monitor: item.monitor,
|
|
2887
|
+
warnings: [...item.warnings, "duplicate import candidate in request"],
|
|
2888
|
+
reason: "duplicate import candidate in request"
|
|
2889
|
+
};
|
|
2890
|
+
}
|
|
2891
|
+
seenSources.add(sourceKey);
|
|
2892
|
+
seenNames.add(nameKey);
|
|
2893
|
+
return item;
|
|
2894
|
+
});
|
|
2895
|
+
}
|
|
2896
|
+
function applyImport(store, request) {
|
|
2897
|
+
if (store.mode === "hosted") {
|
|
2898
|
+
throw new Error("hosted import apply requires cloud import_batches and audit");
|
|
2899
|
+
}
|
|
2900
|
+
const execute = () => {
|
|
2901
|
+
const preview = previewImport(store, request);
|
|
2902
|
+
const appliedAt = new Date().toISOString();
|
|
2903
|
+
const items = preview.items.map((item) => applyPreviewItem(store, item));
|
|
2904
|
+
const batchId = `imp_${randomUUID().replace(/-/g, "").slice(0, 18)}`;
|
|
2905
|
+
store.saveImportBatch({
|
|
2906
|
+
id: batchId,
|
|
2907
|
+
source: preview.source,
|
|
2908
|
+
records: items.map((item) => ({
|
|
2909
|
+
action: item.action,
|
|
2910
|
+
sourceId: item.candidate.sourceId,
|
|
2911
|
+
monitorId: item.after?.id ?? item.monitor?.id ?? item.before?.id ?? null,
|
|
2912
|
+
before: item.before,
|
|
2913
|
+
after: item.after,
|
|
2914
|
+
candidate: item.candidate
|
|
2915
|
+
}))
|
|
2916
|
+
});
|
|
2917
|
+
return { batchId, source: preview.source, appliedAt, items, totals: countActions(items) };
|
|
2918
|
+
};
|
|
2919
|
+
return store.runInTransaction ? store.runInTransaction(execute) : execute();
|
|
2920
|
+
}
|
|
2921
|
+
function rollbackImport(store, batchId) {
|
|
2922
|
+
if (store.mode === "hosted") {
|
|
2923
|
+
throw new Error("hosted import rollback requires cloud import_batches and audit");
|
|
2924
|
+
}
|
|
2925
|
+
const batch = store.getImportBatch(batchId);
|
|
2926
|
+
if (!batch)
|
|
2927
|
+
throw new Error(`Import batch not found: ${batchId}`);
|
|
2928
|
+
if (batch.status === "rolled_back")
|
|
2929
|
+
throw new Error(`Import batch already rolled back: ${batchId}`);
|
|
2930
|
+
const items = [...batch.records].reverse().map((record) => rollbackRecord(store, record));
|
|
2931
|
+
const rolledBack = store.markImportBatchRolledBack(batchId);
|
|
2932
|
+
return {
|
|
2933
|
+
batchId,
|
|
2934
|
+
source: rolledBack.source,
|
|
2935
|
+
rolledBackAt: rolledBack.rolledBackAt ?? new Date().toISOString(),
|
|
2936
|
+
items
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
function previewRecord(store, source, record, defaults) {
|
|
2940
|
+
const warnings = [];
|
|
2941
|
+
let candidate;
|
|
2942
|
+
try {
|
|
2943
|
+
if (store.mode === "hosted")
|
|
2944
|
+
assertHostedTargetAllowed(rawTargetForHostedPolicy(source, record, defaults));
|
|
2945
|
+
candidate = normalizeCandidate(source, record, defaults);
|
|
2946
|
+
validateCandidate(candidate);
|
|
2947
|
+
if (store.mode === "hosted")
|
|
2948
|
+
assertHostedTargetAllowed(candidate);
|
|
2949
|
+
} catch (error) {
|
|
2950
|
+
return {
|
|
2951
|
+
candidate: fallbackCandidate(source, record),
|
|
2952
|
+
action: "blocked",
|
|
2953
|
+
monitor: null,
|
|
2954
|
+
provenance: null,
|
|
2955
|
+
warnings,
|
|
2956
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
2957
|
+
};
|
|
2958
|
+
}
|
|
2959
|
+
const provenance = store.getProvenance(candidate.source, candidate.sourceId);
|
|
2960
|
+
const monitor = provenance ? store.getMonitor(provenance.monitorId) : store.getMonitor(candidate.name);
|
|
2961
|
+
if (provenance && !monitor) {
|
|
2962
|
+
return { candidate, action: "create", monitor: null, provenance, warnings: ["source provenance points to a missing monitor"], reason: null };
|
|
2963
|
+
}
|
|
2964
|
+
if (provenance && monitor) {
|
|
2965
|
+
const nameOwner = store.getMonitor(candidate.name);
|
|
2966
|
+
if (nameOwner && nameOwner.id !== monitor.id) {
|
|
2967
|
+
return {
|
|
2968
|
+
candidate,
|
|
2969
|
+
action: "conflict",
|
|
2970
|
+
monitor,
|
|
2971
|
+
provenance,
|
|
2972
|
+
warnings,
|
|
2973
|
+
reason: "monitor name already exists on another monitor"
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
return {
|
|
2977
|
+
candidate,
|
|
2978
|
+
action: sameTarget(monitor, candidate) ? "unchanged" : "update",
|
|
2979
|
+
monitor,
|
|
2980
|
+
provenance,
|
|
2981
|
+
warnings,
|
|
2982
|
+
reason: null
|
|
2983
|
+
};
|
|
2984
|
+
}
|
|
2985
|
+
if (monitor) {
|
|
2986
|
+
return {
|
|
2987
|
+
candidate,
|
|
2988
|
+
action: "conflict",
|
|
2989
|
+
monitor,
|
|
2990
|
+
provenance: null,
|
|
2991
|
+
warnings,
|
|
2992
|
+
reason: "monitor name already exists without matching source provenance"
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
return { candidate, action: "create", monitor: null, provenance: null, warnings, reason: null };
|
|
2996
|
+
}
|
|
2997
|
+
function applyPreviewItem(store, item) {
|
|
2998
|
+
if (item.action === "blocked" || item.action === "conflict") {
|
|
2999
|
+
return { ...item, before: item.monitor, after: item.monitor };
|
|
3000
|
+
}
|
|
3001
|
+
const input = candidateToMonitorInput(item.candidate);
|
|
3002
|
+
const before = item.monitor;
|
|
3003
|
+
const after = item.action === "create" ? store.createMonitor(input, { allowBrowserPage: true }) : item.action === "update" ? store.updateMonitor(item.monitor.id, input, { allowBrowserPage: true }) : item.monitor;
|
|
3004
|
+
if (after) {
|
|
3005
|
+
store.upsertMonitorProvenance({
|
|
3006
|
+
monitorId: after.id,
|
|
3007
|
+
source: item.candidate.source,
|
|
3008
|
+
sourceId: item.candidate.sourceId,
|
|
3009
|
+
sourceLabel: item.candidate.sourceLabel,
|
|
3010
|
+
snapshot: item.candidate.snapshot
|
|
3011
|
+
});
|
|
3012
|
+
}
|
|
3013
|
+
return { ...item, before, after };
|
|
3014
|
+
}
|
|
3015
|
+
function rollbackRecord(store, record) {
|
|
3016
|
+
const value = asRecord(record);
|
|
3017
|
+
const action = stringValue(value.action);
|
|
3018
|
+
const monitorId = stringValue(value.monitorId);
|
|
3019
|
+
const before = isMonitor(value.before) ? value.before : null;
|
|
3020
|
+
const after = isMonitor(value.after) ? value.after : null;
|
|
3021
|
+
const targetId = after?.id ?? before?.id ?? monitorId;
|
|
3022
|
+
if (!targetId)
|
|
3023
|
+
return { monitorId: null, action: "skipped", reason: "batch record has no monitor id" };
|
|
3024
|
+
if (action === "create") {
|
|
3025
|
+
const hasHistory = store.listResults({ monitorId: targetId, limit: 1 }).length > 0;
|
|
3026
|
+
if (hasHistory) {
|
|
3027
|
+
store.updateMonitor(targetId, { enabled: false }, { allowBrowserPage: true });
|
|
3028
|
+
return { monitorId: targetId, action: "disabled", reason: "created monitor has check history, so rollback preserved history and disabled it" };
|
|
3029
|
+
}
|
|
3030
|
+
return { monitorId: targetId, action: store.deleteMonitor(targetId) ? "deleted" : "skipped", reason: null };
|
|
3031
|
+
}
|
|
3032
|
+
if (action === "update" && before) {
|
|
3033
|
+
store.updateMonitor(targetId, monitorToUpdateInput(before), { allowBrowserPage: true });
|
|
3034
|
+
return { monitorId: targetId, action: "restored", reason: null };
|
|
3035
|
+
}
|
|
3036
|
+
return { monitorId: targetId, action: "skipped", reason: `no rollback needed for ${action || "unknown"} action` };
|
|
3037
|
+
}
|
|
3038
|
+
function normalizeCandidate(source, record, defaults) {
|
|
3039
|
+
const value = asRecord(record);
|
|
3040
|
+
const monitor = asRecord(value.monitor);
|
|
3041
|
+
const sourceId = sanitizeIdentity(stringValue(value.sourceId) ?? stringValue(value.id) ?? stringValue(value.slug) ?? stringValue(value.name));
|
|
3042
|
+
let url = stringValue(monitor.url) ?? stringValue(value.url) ?? stringValue(value.healthUrl) ?? stringValue(value.homepageUrl) ?? stringValue(value.environmentUrl);
|
|
3043
|
+
if (source === "domains" && !url && stringValue(value.domain)) {
|
|
3044
|
+
url = `https://${stringValue(value.domain)}`;
|
|
3045
|
+
}
|
|
3046
|
+
const rawHost = stringValue(monitor.host) ?? stringValue(value.host) ?? stringValue(value.hostname);
|
|
3047
|
+
const rawKind = stringValue(monitor.kind) ?? stringValue(value.kind) ?? (url ? "http" : "tcp");
|
|
3048
|
+
const kind = normalizeKind(rawKind);
|
|
3049
|
+
const normalizedUrl = normalizeCandidateUrl(url ?? defaults.url);
|
|
3050
|
+
const normalizedHost = kind === "tcp" ? rawHost ?? defaults.host : undefined;
|
|
3051
|
+
const port = numberValue(monitor.port) ?? numberValue(value.port) ?? defaults.port;
|
|
3052
|
+
const normalizedTargetKey = sanitizeGeneratedTargetKey(kind, normalizedUrl, normalizedHost, port);
|
|
3053
|
+
const normalizedSourceId = sourceId ?? `${source}:${normalizedTargetKey}`;
|
|
3054
|
+
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}`;
|
|
3055
|
+
const expectedStatus = firstDefined(nullableNumberValue(monitor.expectedStatus), nullableNumberValue(value.expectedStatus), defaults.expectedStatus);
|
|
3056
|
+
const candidate = {
|
|
3057
|
+
source,
|
|
3058
|
+
sourceId: normalizedSourceId,
|
|
3059
|
+
sourceLabel: sanitizeIdentity(stringValue(value.label) ?? stringValue(value.name) ?? stringValue(value.slug)) ?? null,
|
|
3060
|
+
name: sanitizeIdentity(name) ?? name,
|
|
3061
|
+
kind,
|
|
3062
|
+
url: normalizedUrl,
|
|
3063
|
+
host: normalizedHost,
|
|
3064
|
+
port,
|
|
3065
|
+
method: normalizeCandidateMethod(stringValue(monitor.method) ?? stringValue(value.method) ?? defaults.method),
|
|
3066
|
+
expectedStatus,
|
|
3067
|
+
intervalSeconds: numberValue(monitor.intervalSeconds) ?? numberValue(value.intervalSeconds) ?? defaults.intervalSeconds,
|
|
3068
|
+
timeoutMs: numberValue(monitor.timeoutMs) ?? numberValue(value.timeoutMs) ?? defaults.timeoutMs,
|
|
3069
|
+
retryCount: numberValue(monitor.retryCount) ?? numberValue(value.retryCount) ?? defaults.retryCount,
|
|
3070
|
+
enabled: booleanValue(monitor.enabled) ?? booleanValue(value.enabled) ?? defaults.enabled,
|
|
3071
|
+
snapshot: sanitizeSnapshot(record)
|
|
3072
|
+
};
|
|
3073
|
+
return candidate;
|
|
3074
|
+
}
|
|
3075
|
+
function rawTargetForHostedPolicy(source, record, defaults) {
|
|
3076
|
+
const value = asRecord(record);
|
|
3077
|
+
const monitor = asRecord(value.monitor);
|
|
3078
|
+
let url = stringValue(monitor.url) ?? stringValue(value.url) ?? stringValue(value.healthUrl) ?? stringValue(value.homepageUrl) ?? stringValue(value.environmentUrl);
|
|
3079
|
+
if (source === "domains" && !url && stringValue(value.domain)) {
|
|
3080
|
+
url = `https://${stringValue(value.domain)}`;
|
|
3081
|
+
}
|
|
3082
|
+
const host = stringValue(monitor.host) ?? stringValue(value.host) ?? stringValue(value.hostname) ?? defaults.host;
|
|
3083
|
+
const rawKind = stringValue(monitor.kind) ?? stringValue(value.kind) ?? (url ? "http" : "tcp");
|
|
3084
|
+
const kind = normalizeKind(rawKind);
|
|
3085
|
+
return {
|
|
3086
|
+
kind,
|
|
3087
|
+
url: url ?? defaults.url,
|
|
3088
|
+
host: kind === "tcp" ? host : undefined,
|
|
3089
|
+
port: numberValue(monitor.port) ?? numberValue(value.port) ?? defaults.port
|
|
3090
|
+
};
|
|
3091
|
+
}
|
|
3092
|
+
function validateCandidate(candidate) {
|
|
3093
|
+
if (!candidate.name.trim())
|
|
3094
|
+
throw new Error("import candidate requires name");
|
|
3095
|
+
rejectControlCharacters(candidate.name.trim(), "Monitor name");
|
|
3096
|
+
if (candidate.method !== undefined && !/^[A-Z]+$/.test(candidate.method)) {
|
|
3097
|
+
throw new Error("HTTP method must contain only letters");
|
|
3098
|
+
}
|
|
3099
|
+
if (candidate.expectedStatus !== undefined && candidate.expectedStatus !== null) {
|
|
3100
|
+
if (!Number.isInteger(candidate.expectedStatus) || candidate.expectedStatus < 100 || candidate.expectedStatus > 599) {
|
|
3101
|
+
throw new Error("expectedStatus must be an HTTP status from 100 to 599");
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
if (candidate.intervalSeconds !== undefined) {
|
|
3105
|
+
boundedInteger(candidate.intervalSeconds, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS);
|
|
3106
|
+
}
|
|
3107
|
+
if (candidate.timeoutMs !== undefined) {
|
|
3108
|
+
boundedInteger(candidate.timeoutMs, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
3109
|
+
}
|
|
3110
|
+
if (candidate.retryCount !== undefined) {
|
|
3111
|
+
boundedInteger(candidate.retryCount, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT);
|
|
3112
|
+
}
|
|
3113
|
+
if (candidate.kind === "http" || candidate.kind === "browser_page") {
|
|
3114
|
+
if (!candidate.url)
|
|
3115
|
+
throw new Error(`${candidate.kind} import candidate requires url`);
|
|
3116
|
+
const parsed = new URL(candidate.url);
|
|
3117
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
3118
|
+
throw new Error(`${candidate.kind} import candidate URL must use http or https`);
|
|
3119
|
+
}
|
|
3120
|
+
if (parsed.username || parsed.password)
|
|
3121
|
+
throw new Error(`${candidate.kind} import candidate URL must not contain userinfo`);
|
|
3122
|
+
return;
|
|
3123
|
+
}
|
|
3124
|
+
if (candidate.kind === "tcp") {
|
|
3125
|
+
if (!candidate.host)
|
|
3126
|
+
throw new Error("tcp import candidate requires host");
|
|
3127
|
+
rejectControlCharacters(candidate.host, "TCP host");
|
|
3128
|
+
if (!Number.isInteger(candidate.port) || candidate.port <= 0 || candidate.port > 65535) {
|
|
3129
|
+
throw new Error("tcp import candidate requires a port from 1 to 65535");
|
|
3130
|
+
}
|
|
3131
|
+
return;
|
|
3132
|
+
}
|
|
3133
|
+
throw new Error(`unsupported import candidate kind: ${candidate.kind}`);
|
|
3134
|
+
}
|
|
3135
|
+
function candidateToMonitorInput(candidate) {
|
|
3136
|
+
return {
|
|
3137
|
+
name: candidate.name,
|
|
3138
|
+
kind: candidate.kind,
|
|
3139
|
+
url: candidate.url,
|
|
3140
|
+
host: candidate.host,
|
|
3141
|
+
port: candidate.port,
|
|
3142
|
+
method: candidate.method,
|
|
3143
|
+
expectedStatus: candidate.expectedStatus,
|
|
3144
|
+
intervalSeconds: candidate.intervalSeconds,
|
|
3145
|
+
timeoutMs: candidate.timeoutMs,
|
|
3146
|
+
retryCount: candidate.retryCount,
|
|
3147
|
+
enabled: candidate.enabled
|
|
3148
|
+
};
|
|
3149
|
+
}
|
|
3150
|
+
function monitorToUpdateInput(monitor) {
|
|
3151
|
+
return {
|
|
3152
|
+
name: monitor.name,
|
|
3153
|
+
kind: monitor.kind,
|
|
3154
|
+
url: monitor.url ?? undefined,
|
|
3155
|
+
host: monitor.host ?? undefined,
|
|
3156
|
+
port: monitor.port ?? undefined,
|
|
3157
|
+
method: monitor.method,
|
|
3158
|
+
expectedStatus: monitor.expectedStatus,
|
|
3159
|
+
intervalSeconds: monitor.intervalSeconds,
|
|
3160
|
+
timeoutMs: monitor.timeoutMs,
|
|
3161
|
+
retryCount: monitor.retryCount,
|
|
3162
|
+
enabled: monitor.enabled
|
|
3163
|
+
};
|
|
3164
|
+
}
|
|
3165
|
+
function sameTarget(monitor, candidate) {
|
|
3166
|
+
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);
|
|
3167
|
+
}
|
|
3168
|
+
function countActions(items) {
|
|
3169
|
+
return {
|
|
3170
|
+
create: items.filter((item) => item.action === "create").length,
|
|
3171
|
+
update: items.filter((item) => item.action === "update").length,
|
|
3172
|
+
unchanged: items.filter((item) => item.action === "unchanged").length,
|
|
3173
|
+
blocked: items.filter((item) => item.action === "blocked").length,
|
|
3174
|
+
conflict: items.filter((item) => item.action === "conflict").length
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
function normalizeSource(source) {
|
|
3178
|
+
if (["manual", "projects", "servers", "domains", "deployment"].includes(source))
|
|
3179
|
+
return source;
|
|
3180
|
+
throw new Error(`unsupported import source: ${source}`);
|
|
3181
|
+
}
|
|
3182
|
+
function normalizeKind(value) {
|
|
3183
|
+
if (value === "http" || value === "tcp" || value === "browser_page")
|
|
3184
|
+
return value;
|
|
3185
|
+
return value === "browser" || value === "page" ? "browser_page" : "http";
|
|
3186
|
+
}
|
|
3187
|
+
function targetKey(kind, url, host, port) {
|
|
3188
|
+
return kind === "tcp" ? `${host ?? "host"}:${port ?? "port"}` : url ?? "url";
|
|
3189
|
+
}
|
|
3190
|
+
function sanitizeGeneratedTargetKey(kind, url, host, port) {
|
|
3191
|
+
const key = targetKey(kind, url, host, port);
|
|
3192
|
+
return kind === "tcp" ? key : sanitizeIdentity(key) ?? key;
|
|
3193
|
+
}
|
|
3194
|
+
function normalizeCandidateUrl(value) {
|
|
3195
|
+
if (!value)
|
|
3196
|
+
return;
|
|
3197
|
+
try {
|
|
3198
|
+
const parsed = new URL(value);
|
|
3199
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
3200
|
+
if (isSecretKey2(key))
|
|
3201
|
+
parsed.searchParams.set(key, "[redacted]");
|
|
3202
|
+
}
|
|
3203
|
+
parsed.hash = "";
|
|
3204
|
+
return parsed.toString();
|
|
3205
|
+
} catch {
|
|
3206
|
+
return value;
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
function normalizeCandidateMethod(value) {
|
|
3210
|
+
return value?.trim().toUpperCase();
|
|
3211
|
+
}
|
|
3212
|
+
function fallbackCandidate(source, record) {
|
|
3213
|
+
const value = asRecord(record);
|
|
3214
|
+
const monitor = asRecord(value.monitor);
|
|
3215
|
+
const name = stringValue(monitor.name) ?? stringValue(value.name) ?? stringValue(value.domain) ?? "invalid import candidate";
|
|
3216
|
+
const rawUrl = stringValue(monitor.url) ?? stringValue(value.url) ?? stringValue(value.domain);
|
|
3217
|
+
const kind = normalizeKind(stringValue(monitor.kind) ?? stringValue(value.kind) ?? "http");
|
|
3218
|
+
return {
|
|
3219
|
+
source,
|
|
3220
|
+
sourceId: sanitizeIdentity(stringValue(value.sourceId) ?? stringValue(value.id)) ?? `${source}:invalid`,
|
|
3221
|
+
sourceLabel: sanitizeIdentity(stringValue(value.label)) ?? null,
|
|
3222
|
+
name: sanitizeIdentity(name) ?? name,
|
|
3223
|
+
kind,
|
|
3224
|
+
url: redactUrlForDisplay(rawUrl),
|
|
3225
|
+
host: kind === "tcp" ? sanitizeHost(stringValue(monitor.host) ?? stringValue(value.host) ?? stringValue(value.hostname)) : undefined,
|
|
3226
|
+
port: numberValue(monitor.port) ?? numberValue(value.port),
|
|
3227
|
+
snapshot: sanitizeSnapshot(record)
|
|
3228
|
+
};
|
|
3229
|
+
}
|
|
3230
|
+
function asRecord(value) {
|
|
3231
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
3232
|
+
}
|
|
3233
|
+
function stringValue(value) {
|
|
3234
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
3235
|
+
}
|
|
3236
|
+
function numberValue(value) {
|
|
3237
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
3238
|
+
}
|
|
3239
|
+
function nullableNumberValue(value) {
|
|
3240
|
+
if (value === null)
|
|
3241
|
+
return null;
|
|
3242
|
+
return numberValue(value);
|
|
3243
|
+
}
|
|
3244
|
+
function booleanValue(value) {
|
|
3245
|
+
return typeof value === "boolean" ? value : undefined;
|
|
3246
|
+
}
|
|
3247
|
+
function firstDefined(...values) {
|
|
3248
|
+
return values.find((value) => value !== undefined);
|
|
3249
|
+
}
|
|
3250
|
+
function isMonitor(value) {
|
|
3251
|
+
const row = asRecord(value);
|
|
3252
|
+
return Boolean(stringValue(row.id) && stringValue(row.name) && stringValue(row.kind));
|
|
3253
|
+
}
|
|
3254
|
+
function sanitizeSnapshot(value) {
|
|
3255
|
+
if (Array.isArray(value))
|
|
3256
|
+
return value.map(sanitizeSnapshot);
|
|
3257
|
+
if (!value || typeof value !== "object")
|
|
3258
|
+
return sanitizeScalar(value);
|
|
3259
|
+
const output = {};
|
|
3260
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
3261
|
+
if (isSecretKey2(key))
|
|
3262
|
+
output[key] = "[redacted]";
|
|
3263
|
+
else
|
|
3264
|
+
output[key] = sanitizeSnapshot(entry);
|
|
3265
|
+
}
|
|
3266
|
+
return output;
|
|
3267
|
+
}
|
|
3268
|
+
function sanitizeScalar(value) {
|
|
3269
|
+
if (typeof value !== "string")
|
|
3270
|
+
return value;
|
|
3271
|
+
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]");
|
|
3272
|
+
}
|
|
3273
|
+
function isSecretKey2(value) {
|
|
3274
|
+
return /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i.test(value);
|
|
3275
|
+
}
|
|
3276
|
+
function rejectControlCharacters(value, label) {
|
|
3277
|
+
if (/[\x00-\x1f\x7f-\x9f]/.test(value)) {
|
|
3278
|
+
throw new Error(`${label} must not contain control characters`);
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
function boundedInteger(value, label, min, max) {
|
|
3282
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
3283
|
+
throw new Error(`${label} must be an integer from ${min} to ${max}`);
|
|
3284
|
+
}
|
|
3285
|
+
return value;
|
|
3286
|
+
}
|
|
3287
|
+
function redactUrlForDisplay(value) {
|
|
3288
|
+
if (!value)
|
|
3289
|
+
return;
|
|
3290
|
+
try {
|
|
3291
|
+
const parsed = new URL(value);
|
|
3292
|
+
parsed.username = parsed.username ? "[redacted]" : "";
|
|
3293
|
+
parsed.password = "";
|
|
3294
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
3295
|
+
if (isSecretKey2(key))
|
|
3296
|
+
parsed.searchParams.set(key, "[redacted]");
|
|
3297
|
+
}
|
|
3298
|
+
if (parsed.hash && isSecretKey2(parsed.hash))
|
|
3299
|
+
parsed.hash = "#[redacted]";
|
|
3300
|
+
return parsed.toString();
|
|
3301
|
+
} catch {
|
|
3302
|
+
return sanitizeScalar(value);
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
function sanitizeIdentity(value) {
|
|
3306
|
+
if (!value)
|
|
3307
|
+
return;
|
|
3308
|
+
try {
|
|
3309
|
+
const parsed = new URL(value);
|
|
3310
|
+
parsed.username = parsed.username ? "[redacted]" : "";
|
|
3311
|
+
parsed.password = "";
|
|
3312
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
3313
|
+
if (isSecretKey2(key))
|
|
3314
|
+
parsed.searchParams.set(key, "[redacted]");
|
|
3315
|
+
}
|
|
3316
|
+
parsed.hash = "";
|
|
3317
|
+
return parsed.toString();
|
|
3318
|
+
} catch {
|
|
3319
|
+
return sanitizeScalar(value);
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
function sanitizeHost(value) {
|
|
3323
|
+
if (!value)
|
|
3324
|
+
return;
|
|
3325
|
+
return sanitizeScalar(value);
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
// src/probes.ts
|
|
3329
|
+
import { createHash, generateKeyPairSync, sign, verify } from "crypto";
|
|
3330
|
+
function generateProbeKeyPair() {
|
|
3331
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
3332
|
+
const publicKeyPem = publicKey.export({ format: "pem", type: "spki" }).toString();
|
|
3333
|
+
const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" }).toString();
|
|
3334
|
+
return {
|
|
3335
|
+
publicKeyPem,
|
|
3336
|
+
privateKeyPem,
|
|
3337
|
+
publicKeyFingerprint: probePublicKeyFingerprint(publicKeyPem)
|
|
3338
|
+
};
|
|
3339
|
+
}
|
|
3340
|
+
function probePublicKeyFingerprint(publicKeyPem) {
|
|
3341
|
+
return createHash("sha256").update(publicKeyPem.trim()).digest("hex");
|
|
3342
|
+
}
|
|
3343
|
+
function signProbeResult(input, privateKeyPem) {
|
|
3344
|
+
return sign(null, Buffer.from(probeResultSigningPayload(input)), privateKeyPem).toString("base64url");
|
|
3345
|
+
}
|
|
3346
|
+
function verifyProbeResultSignature(input, publicKeyPem) {
|
|
3347
|
+
try {
|
|
3348
|
+
return verify(null, Buffer.from(probeResultSigningPayload(input)), publicKeyPem, Buffer.from(input.signature, "base64url"));
|
|
3349
|
+
} catch {
|
|
3350
|
+
return false;
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
function probeResultSigningPayload(input) {
|
|
3354
|
+
return stableJson({
|
|
3355
|
+
version: "open-uptime.probe-result.v1",
|
|
3356
|
+
probeId: input.probeId,
|
|
3357
|
+
jobId: input.jobId,
|
|
3358
|
+
scheduleSlot: input.scheduleSlot,
|
|
3359
|
+
fencingToken: input.fencingToken,
|
|
3360
|
+
monitorId: input.monitorId,
|
|
3361
|
+
nonce: input.nonce,
|
|
3362
|
+
checkedAt: input.checkedAt,
|
|
3363
|
+
status: input.status,
|
|
3364
|
+
latencyMs: input.latencyMs,
|
|
3365
|
+
statusCode: input.statusCode ?? null,
|
|
3366
|
+
error: input.error ?? null,
|
|
3367
|
+
attemptCount: input.attemptCount ?? 1,
|
|
3368
|
+
monitorRevision: input.monitorRevision,
|
|
3369
|
+
evidenceSha256: createHash("sha256").update(stableJson(input.evidence ?? null)).digest("hex")
|
|
3370
|
+
});
|
|
3371
|
+
}
|
|
3372
|
+
function stableJson(value) {
|
|
3373
|
+
if (value === undefined)
|
|
3374
|
+
return "null";
|
|
3375
|
+
if (value === null || typeof value !== "object")
|
|
3376
|
+
return JSON.stringify(value);
|
|
3377
|
+
if (Array.isArray(value))
|
|
3378
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
3379
|
+
const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== undefined).sort(([left], [right]) => left.localeCompare(right));
|
|
3380
|
+
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJson(entryValue)}`).join(",")}}`;
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
// src/store.ts
|
|
3384
|
+
import { copyFileSync, existsSync, mkdirSync as mkdirSync2, statSync } from "fs";
|
|
3385
|
+
import { dirname, join as join2 } from "path";
|
|
3386
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2652
3387
|
import { Database } from "bun:sqlite";
|
|
2653
3388
|
|
|
2654
3389
|
// src/paths.ts
|
|
@@ -2661,22 +3396,36 @@ function uptimeHome() {
|
|
|
2661
3396
|
function uptimeDbPath() {
|
|
2662
3397
|
return process.env.HASNA_UPTIME_DB || join(uptimeHome(), "uptime.db");
|
|
2663
3398
|
}
|
|
3399
|
+
function uptimeHostedFallbackDbPath() {
|
|
3400
|
+
return process.env.HASNA_UPTIME_HOSTED_FALLBACK_DB || join(uptimeHome(), "hosted-fallback", "uptime.db");
|
|
3401
|
+
}
|
|
2664
3402
|
function ensureUptimeHome() {
|
|
2665
3403
|
const home = uptimeHome();
|
|
2666
3404
|
mkdirSync(home, { recursive: true });
|
|
2667
3405
|
return home;
|
|
2668
3406
|
}
|
|
2669
3407
|
|
|
2670
|
-
// src/limits.ts
|
|
2671
|
-
var MIN_INTERVAL_SECONDS = 1;
|
|
2672
|
-
var MAX_INTERVAL_SECONDS = 86400;
|
|
2673
|
-
var MIN_TIMEOUT_MS = 1;
|
|
2674
|
-
var MAX_TIMEOUT_MS = 60000;
|
|
2675
|
-
var MIN_RETRY_COUNT = 0;
|
|
2676
|
-
var MAX_RETRY_COUNT = 10;
|
|
2677
|
-
var MAX_RESULT_LIMIT = 1000;
|
|
2678
|
-
|
|
2679
3408
|
// src/store.ts
|
|
3409
|
+
var SECRET_URL_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
|
|
3410
|
+
var REQUIRED_TABLES = [
|
|
3411
|
+
"schema_migrations",
|
|
3412
|
+
"monitors",
|
|
3413
|
+
"check_results",
|
|
3414
|
+
"incidents",
|
|
3415
|
+
"check_leases",
|
|
3416
|
+
"monitor_provenance",
|
|
3417
|
+
"import_batches",
|
|
3418
|
+
"probe_identities",
|
|
3419
|
+
"probe_check_jobs",
|
|
3420
|
+
"probe_submissions",
|
|
3421
|
+
"report_schedules",
|
|
3422
|
+
"report_runs",
|
|
3423
|
+
"audit_events"
|
|
3424
|
+
];
|
|
3425
|
+
var PROBE_TABLES = new Set(["probe_identities", "probe_check_jobs", "probe_submissions"]);
|
|
3426
|
+
var REPORT_AUDIT_TABLES = new Set(["report_schedules", "report_runs", "audit_events"]);
|
|
3427
|
+
var CURRENT_SCHEMA_VERSION = "3";
|
|
3428
|
+
|
|
2680
3429
|
class StaleCheckResultError extends Error {
|
|
2681
3430
|
constructor(message) {
|
|
2682
3431
|
super(message);
|
|
@@ -2686,9 +3435,20 @@ class StaleCheckResultError extends Error {
|
|
|
2686
3435
|
|
|
2687
3436
|
class UptimeStore {
|
|
2688
3437
|
dbPath;
|
|
3438
|
+
mode;
|
|
3439
|
+
dataMode;
|
|
2689
3440
|
db;
|
|
2690
3441
|
constructor(options = {}) {
|
|
2691
|
-
this.
|
|
3442
|
+
this.mode = resolveRuntimeMode(options.mode ?? "local");
|
|
3443
|
+
const cloudDatabaseUrl = options.cloudDatabaseUrl ?? process.env.HASNA_UPTIME_DATABASE_URL;
|
|
3444
|
+
if (this.mode === "hosted" && cloudDatabaseUrl) {
|
|
3445
|
+
throw new Error("hosted cloud database adapter is not implemented yet");
|
|
3446
|
+
}
|
|
3447
|
+
if (this.mode === "hosted" && !allowHostedLocalStore(options.allowHostedLocalStore)) {
|
|
3448
|
+
throw new Error("hosted mode requires a cloud data layer; set HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE=1 only for explicit local fallback testing");
|
|
3449
|
+
}
|
|
3450
|
+
this.dataMode = this.mode === "hosted" ? "hosted-local-sqlite" : "local-sqlite";
|
|
3451
|
+
this.dbPath = options.dbPath ?? (this.mode === "hosted" ? uptimeHostedFallbackDbPath() : uptimeDbPath());
|
|
2692
3452
|
if (this.dbPath !== ":memory:") {
|
|
2693
3453
|
mkdirSync2(dirname(this.dbPath), { recursive: true });
|
|
2694
3454
|
}
|
|
@@ -2705,7 +3465,7 @@ class UptimeStore {
|
|
|
2705
3465
|
CREATE TABLE IF NOT EXISTS monitors (
|
|
2706
3466
|
id TEXT PRIMARY KEY,
|
|
2707
3467
|
name TEXT NOT NULL UNIQUE,
|
|
2708
|
-
kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp')),
|
|
3468
|
+
kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
|
|
2709
3469
|
url TEXT,
|
|
2710
3470
|
host TEXT,
|
|
2711
3471
|
port INTEGER,
|
|
@@ -2723,6 +3483,7 @@ class UptimeStore {
|
|
|
2723
3483
|
)
|
|
2724
3484
|
`);
|
|
2725
3485
|
this.ensureColumn("monitors", "revision", "INTEGER NOT NULL DEFAULT 1");
|
|
3486
|
+
this.ensureMonitorKindAllowsBrowserPage();
|
|
2726
3487
|
this.db.run(`
|
|
2727
3488
|
CREATE TABLE IF NOT EXISTS check_results (
|
|
2728
3489
|
id TEXT PRIMARY KEY,
|
|
@@ -2732,9 +3493,11 @@ class UptimeStore {
|
|
|
2732
3493
|
latency_ms REAL,
|
|
2733
3494
|
status_code INTEGER,
|
|
2734
3495
|
error TEXT,
|
|
2735
|
-
attempt_count INTEGER NOT NULL DEFAULT 1
|
|
3496
|
+
attempt_count INTEGER NOT NULL DEFAULT 1,
|
|
3497
|
+
evidence_json TEXT
|
|
2736
3498
|
)
|
|
2737
3499
|
`);
|
|
3500
|
+
this.ensureColumn("check_results", "evidence_json", "TEXT");
|
|
2738
3501
|
this.db.run(`
|
|
2739
3502
|
CREATE TABLE IF NOT EXISTS incidents (
|
|
2740
3503
|
id TEXT PRIMARY KEY,
|
|
@@ -2748,6 +3511,71 @@ class UptimeStore {
|
|
|
2748
3511
|
reason TEXT
|
|
2749
3512
|
)
|
|
2750
3513
|
`);
|
|
3514
|
+
this.db.run(`
|
|
3515
|
+
CREATE TABLE IF NOT EXISTS monitor_provenance (
|
|
3516
|
+
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
3517
|
+
source TEXT NOT NULL,
|
|
3518
|
+
source_id TEXT NOT NULL,
|
|
3519
|
+
source_label TEXT,
|
|
3520
|
+
imported_at TEXT NOT NULL,
|
|
3521
|
+
snapshot_json TEXT NOT NULL,
|
|
3522
|
+
PRIMARY KEY (source, source_id)
|
|
3523
|
+
)
|
|
3524
|
+
`);
|
|
3525
|
+
this.db.run(`
|
|
3526
|
+
CREATE TABLE IF NOT EXISTS import_batches (
|
|
3527
|
+
id TEXT PRIMARY KEY,
|
|
3528
|
+
source TEXT NOT NULL,
|
|
3529
|
+
status TEXT NOT NULL CHECK (status IN ('applied', 'rolled_back')),
|
|
3530
|
+
created_at TEXT NOT NULL,
|
|
3531
|
+
rolled_back_at TEXT,
|
|
3532
|
+
records_json TEXT NOT NULL
|
|
3533
|
+
)
|
|
3534
|
+
`);
|
|
3535
|
+
this.db.run(`
|
|
3536
|
+
CREATE TABLE IF NOT EXISTS probe_identities (
|
|
3537
|
+
id TEXT PRIMARY KEY,
|
|
3538
|
+
name TEXT NOT NULL UNIQUE,
|
|
3539
|
+
public_key_pem TEXT NOT NULL,
|
|
3540
|
+
public_key_fingerprint TEXT NOT NULL UNIQUE,
|
|
3541
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
3542
|
+
created_at TEXT NOT NULL,
|
|
3543
|
+
last_seen_at TEXT
|
|
3544
|
+
)
|
|
3545
|
+
`);
|
|
3546
|
+
this.db.run(`
|
|
3547
|
+
CREATE TABLE IF NOT EXISTS probe_submissions (
|
|
3548
|
+
id TEXT PRIMARY KEY,
|
|
3549
|
+
probe_id TEXT NOT NULL REFERENCES probe_identities(id) ON DELETE CASCADE,
|
|
3550
|
+
job_id TEXT NOT NULL,
|
|
3551
|
+
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
3552
|
+
check_result_id TEXT NOT NULL REFERENCES check_results(id) ON DELETE CASCADE,
|
|
3553
|
+
nonce TEXT NOT NULL,
|
|
3554
|
+
checked_at TEXT NOT NULL,
|
|
3555
|
+
submitted_at TEXT NOT NULL,
|
|
3556
|
+
UNIQUE (probe_id, nonce)
|
|
3557
|
+
)
|
|
3558
|
+
`);
|
|
3559
|
+
this.ensureColumn("probe_submissions", "job_id", "TEXT");
|
|
3560
|
+
this.db.run(`
|
|
3561
|
+
CREATE TABLE IF NOT EXISTS probe_check_jobs (
|
|
3562
|
+
id TEXT PRIMARY KEY,
|
|
3563
|
+
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
3564
|
+
monitor_revision INTEGER NOT NULL DEFAULT 1,
|
|
3565
|
+
schedule_slot TEXT NOT NULL,
|
|
3566
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'claimed', 'submitted', 'expired', 'cancelled')),
|
|
3567
|
+
claimed_by_probe_id TEXT REFERENCES probe_identities(id) ON DELETE SET NULL,
|
|
3568
|
+
fencing_token TEXT,
|
|
3569
|
+
due_at TEXT NOT NULL,
|
|
3570
|
+
claimed_at TEXT,
|
|
3571
|
+
lease_expires_at TEXT,
|
|
3572
|
+
submitted_result_id TEXT REFERENCES check_results(id) ON DELETE SET NULL,
|
|
3573
|
+
created_at TEXT NOT NULL,
|
|
3574
|
+
updated_at TEXT NOT NULL,
|
|
3575
|
+
UNIQUE (monitor_id, schedule_slot)
|
|
3576
|
+
)
|
|
3577
|
+
`);
|
|
3578
|
+
this.ensureColumn("probe_check_jobs", "monitor_revision", "INTEGER NOT NULL DEFAULT 1");
|
|
2751
3579
|
this.db.run(`
|
|
2752
3580
|
CREATE TABLE IF NOT EXISTS check_leases (
|
|
2753
3581
|
monitor_id TEXT PRIMARY KEY REFERENCES monitors(id) ON DELETE CASCADE,
|
|
@@ -2756,12 +3584,113 @@ class UptimeStore {
|
|
|
2756
3584
|
acquired_at TEXT NOT NULL
|
|
2757
3585
|
)
|
|
2758
3586
|
`);
|
|
3587
|
+
this.db.run(`
|
|
3588
|
+
CREATE TABLE IF NOT EXISTS report_schedules (
|
|
3589
|
+
id TEXT PRIMARY KEY,
|
|
3590
|
+
name TEXT NOT NULL UNIQUE,
|
|
3591
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
3592
|
+
interval_seconds INTEGER NOT NULL,
|
|
3593
|
+
next_run_at TEXT NOT NULL,
|
|
3594
|
+
last_run_at TEXT,
|
|
3595
|
+
subject TEXT,
|
|
3596
|
+
channels_json TEXT NOT NULL,
|
|
3597
|
+
created_at TEXT NOT NULL,
|
|
3598
|
+
updated_at TEXT NOT NULL
|
|
3599
|
+
)
|
|
3600
|
+
`);
|
|
3601
|
+
this.db.run(`
|
|
3602
|
+
CREATE TABLE IF NOT EXISTS report_runs (
|
|
3603
|
+
id TEXT PRIMARY KEY,
|
|
3604
|
+
schedule_id TEXT REFERENCES report_schedules(id) ON DELETE SET NULL,
|
|
3605
|
+
status TEXT NOT NULL CHECK (status IN ('success', 'failed')),
|
|
3606
|
+
started_at TEXT NOT NULL,
|
|
3607
|
+
finished_at TEXT NOT NULL,
|
|
3608
|
+
deliveries_json TEXT NOT NULL,
|
|
3609
|
+
error TEXT,
|
|
3610
|
+
report_json TEXT
|
|
3611
|
+
)
|
|
3612
|
+
`);
|
|
3613
|
+
this.db.run(`
|
|
3614
|
+
CREATE TABLE IF NOT EXISTS audit_events (
|
|
3615
|
+
id TEXT PRIMARY KEY,
|
|
3616
|
+
action TEXT NOT NULL,
|
|
3617
|
+
resource_type TEXT,
|
|
3618
|
+
resource_id TEXT,
|
|
3619
|
+
message TEXT,
|
|
3620
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
3621
|
+
actor TEXT,
|
|
3622
|
+
created_at TEXT NOT NULL
|
|
3623
|
+
)
|
|
3624
|
+
`);
|
|
3625
|
+
this.db.run(`
|
|
3626
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
3627
|
+
key TEXT PRIMARY KEY,
|
|
3628
|
+
value TEXT NOT NULL,
|
|
3629
|
+
updated_at TEXT NOT NULL
|
|
3630
|
+
)
|
|
3631
|
+
`);
|
|
3632
|
+
this.db.query("INSERT OR REPLACE INTO schema_migrations (key, value, updated_at) VALUES ('schema_version', ?, ?)").run(CURRENT_SCHEMA_VERSION, new Date().toISOString());
|
|
2759
3633
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_results_monitor_time ON check_results(monitor_id, checked_at DESC)");
|
|
2760
3634
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents(monitor_id, status)");
|
|
2761
3635
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_check_leases_until ON check_leases(leased_until)");
|
|
3636
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_monitor_provenance_monitor ON monitor_provenance(monitor_id)");
|
|
3637
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_status_due ON probe_check_jobs(status, due_at)");
|
|
3638
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_probe_status ON probe_check_jobs(claimed_by_probe_id, status)");
|
|
3639
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_probe_time ON probe_submissions(probe_id, submitted_at DESC)");
|
|
3640
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_monitor_time ON probe_submissions(monitor_id, checked_at DESC)");
|
|
3641
|
+
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 != ''");
|
|
3642
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_report_schedules_due ON report_schedules(enabled, next_run_at)");
|
|
3643
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_report_runs_schedule_time ON report_runs(schedule_id, started_at DESC)");
|
|
3644
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_audit_events_resource_time ON audit_events(resource_type, resource_id, created_at DESC)");
|
|
3645
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_audit_events_time ON audit_events(created_at DESC)");
|
|
2762
3646
|
}
|
|
2763
|
-
|
|
2764
|
-
|
|
3647
|
+
backup(destinationPath) {
|
|
3648
|
+
if (this.dbPath === ":memory:" && !destinationPath) {
|
|
3649
|
+
throw new Error("backup path is required for in-memory stores");
|
|
3650
|
+
}
|
|
3651
|
+
const createdAt = new Date().toISOString();
|
|
3652
|
+
const backupPath = destinationPath ?? join2(dirname(this.dbPath), "backups", `uptime-${createdAt.replace(/[:.]/g, "-")}.db`);
|
|
3653
|
+
mkdirSync2(dirname(backupPath), { recursive: true });
|
|
3654
|
+
if (this.dbPath === ":memory:") {
|
|
3655
|
+
this.vacuumInto(backupPath);
|
|
3656
|
+
} else {
|
|
3657
|
+
this.db.run("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
3658
|
+
copyFileSync(this.dbPath, backupPath);
|
|
3659
|
+
}
|
|
3660
|
+
const bytes = statSync(backupPath).size;
|
|
3661
|
+
return { sourcePath: this.dbPath, backupPath, bytes, createdAt };
|
|
3662
|
+
}
|
|
3663
|
+
verifyBackup(backupPath) {
|
|
3664
|
+
return verifyBackupFile(backupPath);
|
|
3665
|
+
}
|
|
3666
|
+
static verifyBackup(backupPath) {
|
|
3667
|
+
return verifyBackupFile(backupPath);
|
|
3668
|
+
}
|
|
3669
|
+
static restoreBackup(backupPath, destinationPath = uptimeDbPath()) {
|
|
3670
|
+
const check = verifyBackupFile(backupPath);
|
|
3671
|
+
if (!check.ok)
|
|
3672
|
+
throw new Error(`backup integrity check failed: ${check.integrity}`);
|
|
3673
|
+
if (destinationPath === ":memory:")
|
|
3674
|
+
throw new Error("cannot restore a backup to an in-memory store");
|
|
3675
|
+
if (existsSync(destinationPath) || existsSync(`${destinationPath}-wal`) || existsSync(`${destinationPath}-shm`)) {
|
|
3676
|
+
throw new Error("restore destination already exists or has SQLite sidecar files");
|
|
3677
|
+
}
|
|
3678
|
+
mkdirSync2(dirname(destinationPath), { recursive: true });
|
|
3679
|
+
copyFileSync(backupPath, destinationPath);
|
|
3680
|
+
const bytes = statSync(destinationPath).size;
|
|
3681
|
+
return {
|
|
3682
|
+
sourcePath: backupPath,
|
|
3683
|
+
backupPath: destinationPath,
|
|
3684
|
+
bytes,
|
|
3685
|
+
createdAt: new Date().toISOString()
|
|
3686
|
+
};
|
|
3687
|
+
}
|
|
3688
|
+
createMonitor(input, options = {}) {
|
|
3689
|
+
if (this.mode === "hosted")
|
|
3690
|
+
assertHostedTargetAllowed(input);
|
|
3691
|
+
const normalized = normalizeCreateMonitor(input, options.allowBrowserPage === true);
|
|
3692
|
+
if (this.mode === "hosted")
|
|
3693
|
+
assertHostedTargetAllowed(normalized);
|
|
2765
3694
|
const now = new Date().toISOString();
|
|
2766
3695
|
const monitor = {
|
|
2767
3696
|
id: newId("mon"),
|
|
@@ -2797,12 +3726,22 @@ class UptimeStore {
|
|
|
2797
3726
|
const row = this.db.query("SELECT * FROM monitors WHERE id = ? OR name = ?").get(idOrName, idOrName);
|
|
2798
3727
|
return row ? monitorFromRow(row) : null;
|
|
2799
3728
|
}
|
|
2800
|
-
updateMonitor(idOrName, input) {
|
|
3729
|
+
updateMonitor(idOrName, input, options = {}) {
|
|
2801
3730
|
const current = this.getMonitor(idOrName);
|
|
2802
3731
|
if (!current)
|
|
2803
3732
|
throw new Error(`Monitor not found: ${idOrName}`);
|
|
3733
|
+
if (this.mode === "hosted") {
|
|
3734
|
+
assertHostedTargetAllowed({
|
|
3735
|
+
kind: input.kind ?? current.kind,
|
|
3736
|
+
url: input.url ?? current.url ?? undefined,
|
|
3737
|
+
host: input.host ?? current.host ?? undefined,
|
|
3738
|
+
port: input.port ?? current.port ?? undefined
|
|
3739
|
+
});
|
|
3740
|
+
}
|
|
2804
3741
|
const updatedAt = new Date().toISOString();
|
|
2805
|
-
const next = normalizeUpdateMonitor(current, input, updatedAt);
|
|
3742
|
+
const next = normalizeUpdateMonitor(current, input, updatedAt, options.allowBrowserPage === true);
|
|
3743
|
+
if (this.mode === "hosted")
|
|
3744
|
+
assertHostedTargetAllowed(next);
|
|
2806
3745
|
this.db.query(`UPDATE monitors SET
|
|
2807
3746
|
name = ?, kind = ?, url = ?, host = ?, port = ?, method = ?,
|
|
2808
3747
|
expected_status = ?, interval_seconds = ?, timeout_ms = ?,
|
|
@@ -2821,6 +3760,315 @@ class UptimeStore {
|
|
|
2821
3760
|
this.db.query("DELETE FROM monitors WHERE id = ?").run(current.id);
|
|
2822
3761
|
return true;
|
|
2823
3762
|
}
|
|
3763
|
+
createProbeIdentity(input) {
|
|
3764
|
+
const name = input.name.trim();
|
|
3765
|
+
if (!name)
|
|
3766
|
+
throw new Error("Probe name is required");
|
|
3767
|
+
rejectControlCharacters2(name, "Probe name");
|
|
3768
|
+
const now = new Date().toISOString();
|
|
3769
|
+
const probe = {
|
|
3770
|
+
id: newId("prb"),
|
|
3771
|
+
name,
|
|
3772
|
+
publicKeyPem: input.publicKeyPem.trim(),
|
|
3773
|
+
publicKeyFingerprint: input.publicKeyFingerprint,
|
|
3774
|
+
enabled: input.enabled ?? true,
|
|
3775
|
+
createdAt: now,
|
|
3776
|
+
lastSeenAt: null
|
|
3777
|
+
};
|
|
3778
|
+
if (!probe.publicKeyPem)
|
|
3779
|
+
throw new Error("Probe public key is required");
|
|
3780
|
+
this.db.query(`INSERT INTO probe_identities (
|
|
3781
|
+
id, name, public_key_pem, public_key_fingerprint, enabled, created_at, last_seen_at
|
|
3782
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(probe.id, probe.name, probe.publicKeyPem, probe.publicKeyFingerprint, probe.enabled ? 1 : 0, probe.createdAt, probe.lastSeenAt);
|
|
3783
|
+
return probe;
|
|
3784
|
+
}
|
|
3785
|
+
listProbeIdentities(options = {}) {
|
|
3786
|
+
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();
|
|
3787
|
+
return rows.map(probeIdentityFromRow);
|
|
3788
|
+
}
|
|
3789
|
+
getProbeIdentity(idOrName) {
|
|
3790
|
+
const row = this.db.query("SELECT * FROM probe_identities WHERE id = ? OR name = ?").get(idOrName, idOrName);
|
|
3791
|
+
return row ? probeIdentityFromRow(row) : null;
|
|
3792
|
+
}
|
|
3793
|
+
updateProbeIdentity(idOrName, input) {
|
|
3794
|
+
const current = this.getProbeIdentity(idOrName);
|
|
3795
|
+
if (!current)
|
|
3796
|
+
throw new Error(`Probe not found: ${idOrName}`);
|
|
3797
|
+
const name = input.name === undefined ? current.name : input.name.trim();
|
|
3798
|
+
if (!name)
|
|
3799
|
+
throw new Error("Probe name is required");
|
|
3800
|
+
rejectControlCharacters2(name, "Probe name");
|
|
3801
|
+
const enabled = input.enabled ?? current.enabled;
|
|
3802
|
+
this.db.query("UPDATE probe_identities SET name = ?, enabled = ? WHERE id = ?").run(name, enabled ? 1 : 0, current.id);
|
|
3803
|
+
return this.getProbeIdentity(current.id);
|
|
3804
|
+
}
|
|
3805
|
+
touchProbeIdentity(idOrName, seenAt = new Date().toISOString()) {
|
|
3806
|
+
const probe = this.getProbeIdentity(idOrName);
|
|
3807
|
+
if (!probe)
|
|
3808
|
+
throw new Error(`Probe not found: ${idOrName}`);
|
|
3809
|
+
this.db.query("UPDATE probe_identities SET last_seen_at = ? WHERE id = ?").run(seenAt, probe.id);
|
|
3810
|
+
}
|
|
3811
|
+
createProbeCheckJob(input) {
|
|
3812
|
+
const monitor = this.getMonitor(input.monitorId);
|
|
3813
|
+
if (!monitor)
|
|
3814
|
+
throw new Error(`Monitor not found: ${input.monitorId}`);
|
|
3815
|
+
if (!monitor.enabled)
|
|
3816
|
+
throw new Error(`Monitor is disabled: ${monitor.name}`);
|
|
3817
|
+
const scheduleSlot = normalizeScheduleSlot(input.scheduleSlot);
|
|
3818
|
+
const dueAt = input.dueAt ?? new Date().toISOString();
|
|
3819
|
+
assertIsoTimestamp(dueAt, "Probe job dueAt");
|
|
3820
|
+
const now = new Date().toISOString();
|
|
3821
|
+
const existing = this.db.query("SELECT * FROM probe_check_jobs WHERE monitor_id = ? AND schedule_slot = ?").get(monitor.id, scheduleSlot);
|
|
3822
|
+
if (existing)
|
|
3823
|
+
return probeCheckJobFromRow(existing);
|
|
3824
|
+
const job = {
|
|
3825
|
+
id: newId("job"),
|
|
3826
|
+
monitorId: monitor.id,
|
|
3827
|
+
monitorRevision: monitor.revision,
|
|
3828
|
+
scheduleSlot,
|
|
3829
|
+
status: "pending",
|
|
3830
|
+
claimedByProbeId: null,
|
|
3831
|
+
fencingToken: null,
|
|
3832
|
+
dueAt,
|
|
3833
|
+
claimedAt: null,
|
|
3834
|
+
leaseExpiresAt: null,
|
|
3835
|
+
submittedResultId: null,
|
|
3836
|
+
createdAt: now,
|
|
3837
|
+
updatedAt: now
|
|
3838
|
+
};
|
|
3839
|
+
this.db.query(`INSERT INTO probe_check_jobs (
|
|
3840
|
+
id, monitor_id, monitor_revision, schedule_slot, status, claimed_by_probe_id, fencing_token,
|
|
3841
|
+
due_at, claimed_at, lease_expires_at, submitted_result_id, created_at, updated_at
|
|
3842
|
+
) 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);
|
|
3843
|
+
return job;
|
|
3844
|
+
}
|
|
3845
|
+
getProbeCheckJob(id) {
|
|
3846
|
+
const row = this.db.query("SELECT * FROM probe_check_jobs WHERE id = ?").get(id);
|
|
3847
|
+
return row ? probeCheckJobFromRow(row) : null;
|
|
3848
|
+
}
|
|
3849
|
+
claimProbeCheckJob(input) {
|
|
3850
|
+
const tx = this.db.transaction(() => {
|
|
3851
|
+
const probe = this.getProbeIdentity(input.probeId);
|
|
3852
|
+
if (!probe)
|
|
3853
|
+
throw new Error(`Probe not found: ${input.probeId}`);
|
|
3854
|
+
if (!probe.enabled)
|
|
3855
|
+
throw new Error(`Probe is disabled: ${probe.name}`);
|
|
3856
|
+
const current = this.getProbeCheckJob(input.jobId);
|
|
3857
|
+
if (!current)
|
|
3858
|
+
throw new Error(`Probe job not found: ${input.jobId}`);
|
|
3859
|
+
const now = new Date;
|
|
3860
|
+
const nowIso = now.toISOString();
|
|
3861
|
+
if (current.status === "submitted")
|
|
3862
|
+
throw new Error("Probe job already submitted");
|
|
3863
|
+
if (current.status === "cancelled")
|
|
3864
|
+
throw new Error("Probe job is cancelled");
|
|
3865
|
+
if (current.dueAt > nowIso)
|
|
3866
|
+
throw new Error("Probe job is not due yet");
|
|
3867
|
+
const leaseExpired = Boolean(current.leaseExpiresAt && current.leaseExpiresAt <= nowIso);
|
|
3868
|
+
if (current.status === "claimed" && !leaseExpired && current.claimedByProbeId !== probe.id) {
|
|
3869
|
+
throw new Error("Probe job already claimed by another probe");
|
|
3870
|
+
}
|
|
3871
|
+
if (current.status !== "pending" && current.status !== "claimed" && current.status !== "expired") {
|
|
3872
|
+
throw new Error(`Probe job is not claimable: ${current.status}`);
|
|
3873
|
+
}
|
|
3874
|
+
const leaseExpiresAt = new Date(now.getTime() + Math.max(1000, input.leaseTtlMs ?? 120000)).toISOString();
|
|
3875
|
+
const fencingToken = newId("fence");
|
|
3876
|
+
const update = this.db.query(`UPDATE probe_check_jobs
|
|
3877
|
+
SET status = 'claimed', claimed_by_probe_id = ?, fencing_token = ?, claimed_at = ?, lease_expires_at = ?, updated_at = ?
|
|
3878
|
+
WHERE id = ?
|
|
3879
|
+
AND submitted_result_id IS NULL
|
|
3880
|
+
AND (
|
|
3881
|
+
status IN ('pending', 'expired')
|
|
3882
|
+
OR (status = 'claimed' AND (claimed_by_probe_id = ? OR lease_expires_at <= ?))
|
|
3883
|
+
)`).run(probe.id, fencingToken, nowIso, leaseExpiresAt, nowIso, current.id, probe.id, nowIso);
|
|
3884
|
+
if (statementChanges(update) !== 1)
|
|
3885
|
+
throw new Error("Probe job claim raced; retry");
|
|
3886
|
+
this.touchProbeIdentity(probe.id, nowIso);
|
|
3887
|
+
return this.getProbeCheckJob(current.id);
|
|
3888
|
+
});
|
|
3889
|
+
return tx();
|
|
3890
|
+
}
|
|
3891
|
+
completeProbeCheckJob(input) {
|
|
3892
|
+
const job = this.getProbeCheckJob(input.jobId);
|
|
3893
|
+
if (!job)
|
|
3894
|
+
throw new Error(`Probe job not found: ${input.jobId}`);
|
|
3895
|
+
const submittedAt = input.submittedAt ?? new Date().toISOString();
|
|
3896
|
+
if (job.status !== "claimed")
|
|
3897
|
+
throw new Error(`Probe job is not claimable for submission: ${job.status}`);
|
|
3898
|
+
if (job.claimedByProbeId !== input.probeId)
|
|
3899
|
+
throw new Error("Probe job was claimed by another probe");
|
|
3900
|
+
if (job.fencingToken !== input.fencingToken)
|
|
3901
|
+
throw new Error("Probe job fencing token is invalid");
|
|
3902
|
+
if (!job.leaseExpiresAt || job.leaseExpiresAt <= submittedAt) {
|
|
3903
|
+
this.expireProbeCheckJob(job.id, submittedAt);
|
|
3904
|
+
throw new Error("Probe job lease expired");
|
|
3905
|
+
}
|
|
3906
|
+
const update = this.db.query(`UPDATE probe_check_jobs
|
|
3907
|
+
SET status = 'submitted', submitted_result_id = ?, updated_at = ?
|
|
3908
|
+
WHERE id = ?
|
|
3909
|
+
AND status = 'claimed'
|
|
3910
|
+
AND claimed_by_probe_id = ?
|
|
3911
|
+
AND fencing_token = ?
|
|
3912
|
+
AND lease_expires_at > ?
|
|
3913
|
+
AND submitted_result_id IS NULL`).run(input.checkResultId, submittedAt, job.id, input.probeId, input.fencingToken, submittedAt);
|
|
3914
|
+
if (statementChanges(update) !== 1)
|
|
3915
|
+
throw new Error("Probe job submission raced; retry");
|
|
3916
|
+
return this.getProbeCheckJob(job.id);
|
|
3917
|
+
}
|
|
3918
|
+
expireProbeCheckJob(jobId, updatedAt = new Date().toISOString()) {
|
|
3919
|
+
this.db.query("UPDATE probe_check_jobs SET status = 'expired', updated_at = ? WHERE id = ? AND status != 'submitted'").run(updatedAt, jobId);
|
|
3920
|
+
}
|
|
3921
|
+
getProbeSubmission(probeId, nonce) {
|
|
3922
|
+
const row = this.db.query("SELECT * FROM probe_submissions WHERE probe_id = ? AND nonce = ?").get(probeId, nonce);
|
|
3923
|
+
return row ? probeSubmissionFromRow(row) : null;
|
|
3924
|
+
}
|
|
3925
|
+
recordProbeSubmission(input) {
|
|
3926
|
+
const submittedAt = input.submittedAt ?? new Date().toISOString();
|
|
3927
|
+
const receipt = {
|
|
3928
|
+
id: newId("psb"),
|
|
3929
|
+
probeId: input.probeId,
|
|
3930
|
+
jobId: input.jobId,
|
|
3931
|
+
monitorId: input.monitorId,
|
|
3932
|
+
checkResultId: input.checkResultId,
|
|
3933
|
+
nonce: input.nonce,
|
|
3934
|
+
checkedAt: input.checkedAt,
|
|
3935
|
+
submittedAt
|
|
3936
|
+
};
|
|
3937
|
+
this.db.query(`INSERT INTO probe_submissions (
|
|
3938
|
+
id, probe_id, job_id, monitor_id, check_result_id, nonce, checked_at, submitted_at
|
|
3939
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(receipt.id, receipt.probeId, receipt.jobId, receipt.monitorId, receipt.checkResultId, receipt.nonce, receipt.checkedAt, receipt.submittedAt);
|
|
3940
|
+
return receipt;
|
|
3941
|
+
}
|
|
3942
|
+
createReportSchedule(input) {
|
|
3943
|
+
const normalized = normalizeReportScheduleInput(input);
|
|
3944
|
+
const now = new Date().toISOString();
|
|
3945
|
+
const schedule = {
|
|
3946
|
+
id: newId("rps"),
|
|
3947
|
+
name: normalized.name,
|
|
3948
|
+
enabled: normalized.enabled,
|
|
3949
|
+
intervalSeconds: normalized.intervalSeconds,
|
|
3950
|
+
nextRunAt: normalized.nextRunAt,
|
|
3951
|
+
lastRunAt: null,
|
|
3952
|
+
subject: normalized.subject,
|
|
3953
|
+
channels: normalized.channels,
|
|
3954
|
+
createdAt: now,
|
|
3955
|
+
updatedAt: now
|
|
3956
|
+
};
|
|
3957
|
+
this.db.query(`INSERT INTO report_schedules (
|
|
3958
|
+
id, name, enabled, interval_seconds, next_run_at, last_run_at,
|
|
3959
|
+
subject, channels_json, created_at, updated_at
|
|
3960
|
+
) 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);
|
|
3961
|
+
return schedule;
|
|
3962
|
+
}
|
|
3963
|
+
listReportSchedules(options = {}) {
|
|
3964
|
+
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();
|
|
3965
|
+
return rows.map(reportScheduleFromRow);
|
|
3966
|
+
}
|
|
3967
|
+
listDueReportSchedules(nowIso = new Date().toISOString()) {
|
|
3968
|
+
assertIsoTimestamp(nowIso, "Report schedule due timestamp");
|
|
3969
|
+
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);
|
|
3970
|
+
return rows.map(reportScheduleFromRow);
|
|
3971
|
+
}
|
|
3972
|
+
getReportSchedule(idOrName) {
|
|
3973
|
+
const row = this.db.query("SELECT * FROM report_schedules WHERE id = ? OR name = ?").get(idOrName, idOrName);
|
|
3974
|
+
return row ? reportScheduleFromRow(row) : null;
|
|
3975
|
+
}
|
|
3976
|
+
updateReportSchedule(idOrName, input) {
|
|
3977
|
+
const current = this.getReportSchedule(idOrName);
|
|
3978
|
+
if (!current)
|
|
3979
|
+
throw new Error(`Report schedule not found: ${idOrName}`);
|
|
3980
|
+
const normalized = normalizeReportScheduleInput({
|
|
3981
|
+
name: input.name ?? current.name,
|
|
3982
|
+
intervalSeconds: input.intervalSeconds ?? current.intervalSeconds,
|
|
3983
|
+
nextRunAt: input.nextRunAt ?? current.nextRunAt,
|
|
3984
|
+
enabled: input.enabled ?? current.enabled,
|
|
3985
|
+
subject: input.subject === undefined ? current.subject : input.subject,
|
|
3986
|
+
channels: input.channels ?? current.channels
|
|
3987
|
+
});
|
|
3988
|
+
const updatedAt = new Date().toISOString();
|
|
3989
|
+
this.db.query(`UPDATE report_schedules SET
|
|
3990
|
+
name = ?, enabled = ?, interval_seconds = ?, next_run_at = ?,
|
|
3991
|
+
subject = ?, channels_json = ?, updated_at = ?
|
|
3992
|
+
WHERE id = ?`).run(normalized.name, normalized.enabled ? 1 : 0, normalized.intervalSeconds, normalized.nextRunAt, normalized.subject, JSON.stringify(normalized.channels), updatedAt, current.id);
|
|
3993
|
+
return this.getReportSchedule(current.id);
|
|
3994
|
+
}
|
|
3995
|
+
deleteReportSchedule(idOrName) {
|
|
3996
|
+
const current = this.getReportSchedule(idOrName);
|
|
3997
|
+
if (!current)
|
|
3998
|
+
return false;
|
|
3999
|
+
this.db.query("DELETE FROM report_schedules WHERE id = ?").run(current.id);
|
|
4000
|
+
return true;
|
|
4001
|
+
}
|
|
4002
|
+
recordReportRun(input) {
|
|
4003
|
+
const startedAt = input.startedAt ?? new Date().toISOString();
|
|
4004
|
+
const finishedAt = input.finishedAt ?? new Date().toISOString();
|
|
4005
|
+
assertIsoTimestamp(startedAt, "Report run startedAt");
|
|
4006
|
+
assertIsoTimestamp(finishedAt, "Report run finishedAt");
|
|
4007
|
+
if (input.status !== "success" && input.status !== "failed") {
|
|
4008
|
+
throw new Error("Report run status must be success or failed");
|
|
4009
|
+
}
|
|
4010
|
+
if (input.scheduleId && !this.getReportSchedule(input.scheduleId)) {
|
|
4011
|
+
throw new Error(`Report schedule not found: ${input.scheduleId}`);
|
|
4012
|
+
}
|
|
4013
|
+
const run = {
|
|
4014
|
+
id: newId("rpr"),
|
|
4015
|
+
scheduleId: input.scheduleId ?? null,
|
|
4016
|
+
status: input.status,
|
|
4017
|
+
startedAt,
|
|
4018
|
+
finishedAt,
|
|
4019
|
+
deliveries: normalizeReportDeliveries(input.deliveries ?? []),
|
|
4020
|
+
error: normalizeNullableRedactedText(input.error, "Report run error", 1000),
|
|
4021
|
+
reportJson: input.reportJson ?? null
|
|
4022
|
+
};
|
|
4023
|
+
this.db.query(`INSERT INTO report_runs (
|
|
4024
|
+
id, schedule_id, status, started_at, finished_at, deliveries_json,
|
|
4025
|
+
error, report_json
|
|
4026
|
+
) 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);
|
|
4027
|
+
if (run.scheduleId) {
|
|
4028
|
+
this.advanceReportSchedule(run.scheduleId, run.finishedAt);
|
|
4029
|
+
}
|
|
4030
|
+
return run;
|
|
4031
|
+
}
|
|
4032
|
+
listReportRuns(options = {}) {
|
|
4033
|
+
const limit = clampLimit(options.limit ?? 50);
|
|
4034
|
+
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);
|
|
4035
|
+
return rows.map(reportRunFromRow);
|
|
4036
|
+
}
|
|
4037
|
+
recordAuditEvent(input) {
|
|
4038
|
+
const action = normalizeAuditText(input.action, "Audit action", 160);
|
|
4039
|
+
const createdAt = input.createdAt ?? new Date().toISOString();
|
|
4040
|
+
assertIsoTimestamp(createdAt, "Audit event createdAt");
|
|
4041
|
+
const event = {
|
|
4042
|
+
id: newId("aud"),
|
|
4043
|
+
action,
|
|
4044
|
+
resourceType: normalizeNullableAuditText(input.resourceType, "Audit resourceType", 80),
|
|
4045
|
+
resourceId: normalizeNullableAuditText(input.resourceId, "Audit resourceId", 160),
|
|
4046
|
+
message: normalizeNullableAuditText(input.message, "Audit message", 500),
|
|
4047
|
+
metadata: normalizeAuditMetadata(input.metadata ?? {}),
|
|
4048
|
+
actor: normalizeNullableAuditText(input.actor, "Audit actor", 160),
|
|
4049
|
+
createdAt
|
|
4050
|
+
};
|
|
4051
|
+
this.db.query(`INSERT INTO audit_events (
|
|
4052
|
+
id, action, resource_type, resource_id, message, metadata_json, actor, created_at
|
|
4053
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(event.id, event.action, event.resourceType, event.resourceId, event.message, JSON.stringify(event.metadata), event.actor, event.createdAt);
|
|
4054
|
+
return event;
|
|
4055
|
+
}
|
|
4056
|
+
listAuditEvents(options = {}) {
|
|
4057
|
+
const clauses = [];
|
|
4058
|
+
const args = [];
|
|
4059
|
+
if (options.resourceType) {
|
|
4060
|
+
clauses.push("resource_type = ?");
|
|
4061
|
+
args.push(options.resourceType);
|
|
4062
|
+
}
|
|
4063
|
+
if (options.resourceId) {
|
|
4064
|
+
clauses.push("resource_id = ?");
|
|
4065
|
+
args.push(options.resourceId);
|
|
4066
|
+
}
|
|
4067
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
4068
|
+
args.push(clampLimit(options.limit ?? 50));
|
|
4069
|
+
const rows = this.db.query(`SELECT * FROM audit_events ${where} ORDER BY created_at DESC, id DESC LIMIT ?`).all(...args);
|
|
4070
|
+
return rows.map(auditEventFromRow);
|
|
4071
|
+
}
|
|
2824
4072
|
acquireCheckLease(monitorId, owner, ttlMs) {
|
|
2825
4073
|
const now = new Date;
|
|
2826
4074
|
const nowIso = now.toISOString();
|
|
@@ -2855,7 +4103,8 @@ class UptimeStore {
|
|
|
2855
4103
|
latencyMs: input.latencyMs,
|
|
2856
4104
|
statusCode: input.statusCode,
|
|
2857
4105
|
error: input.error,
|
|
2858
|
-
attemptCount: Math.max(1, input.attemptCount)
|
|
4106
|
+
attemptCount: Math.max(1, input.attemptCount),
|
|
4107
|
+
evidence: input.evidence ?? null
|
|
2859
4108
|
};
|
|
2860
4109
|
const tx = this.db.transaction(() => {
|
|
2861
4110
|
const current = this.db.query("SELECT * FROM monitors WHERE id = ?").get(result.monitorId);
|
|
@@ -2868,19 +4117,59 @@ class UptimeStore {
|
|
|
2868
4117
|
throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${current.name}`);
|
|
2869
4118
|
}
|
|
2870
4119
|
this.db.query(`INSERT INTO check_results (
|
|
2871
|
-
id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count
|
|
2872
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(result.id, result.monitorId, result.checkedAt, result.status, result.latencyMs, result.statusCode, result.error, result.attemptCount);
|
|
4120
|
+
id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count, evidence_json
|
|
4121
|
+
) 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);
|
|
2873
4122
|
this.db.query("UPDATE monitors SET status = ?, last_checked_at = ?, updated_at = ? WHERE id = ?").run(result.status, result.checkedAt, result.checkedAt, result.monitorId);
|
|
2874
4123
|
this.reconcileIncidentInTransaction(result);
|
|
2875
4124
|
});
|
|
2876
4125
|
tx();
|
|
2877
4126
|
return result;
|
|
2878
4127
|
}
|
|
4128
|
+
getCheckResult(id) {
|
|
4129
|
+
const row = this.db.query("SELECT * FROM check_results WHERE id = ?").get(id);
|
|
4130
|
+
return row ? checkResultFromRow(row) : null;
|
|
4131
|
+
}
|
|
2879
4132
|
listResults(options = {}) {
|
|
2880
4133
|
const limit = clampLimit(options.limit ?? 50);
|
|
2881
4134
|
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);
|
|
2882
4135
|
return rows.map(checkResultFromRow);
|
|
2883
4136
|
}
|
|
4137
|
+
getProvenance(source, sourceId) {
|
|
4138
|
+
const row = this.db.query("SELECT * FROM monitor_provenance WHERE source = ? AND source_id = ?").get(source, sourceId);
|
|
4139
|
+
return row ? provenanceFromRow(row) : null;
|
|
4140
|
+
}
|
|
4141
|
+
upsertMonitorProvenance(input) {
|
|
4142
|
+
const importedAt = new Date().toISOString();
|
|
4143
|
+
this.db.query(`INSERT INTO monitor_provenance (
|
|
4144
|
+
monitor_id, source, source_id, source_label, imported_at, snapshot_json
|
|
4145
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
4146
|
+
ON CONFLICT(source, source_id) DO UPDATE SET
|
|
4147
|
+
monitor_id = excluded.monitor_id,
|
|
4148
|
+
source_label = excluded.source_label,
|
|
4149
|
+
imported_at = excluded.imported_at,
|
|
4150
|
+
snapshot_json = excluded.snapshot_json`).run(input.monitorId, input.source, input.sourceId, input.sourceLabel ?? null, importedAt, JSON.stringify(input.snapshot));
|
|
4151
|
+
return this.getProvenance(input.source, input.sourceId);
|
|
4152
|
+
}
|
|
4153
|
+
saveImportBatch(input) {
|
|
4154
|
+
const createdAt = new Date().toISOString();
|
|
4155
|
+
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));
|
|
4156
|
+
return this.getImportBatch(input.id);
|
|
4157
|
+
}
|
|
4158
|
+
getImportBatch(batchId) {
|
|
4159
|
+
const row = this.db.query("SELECT * FROM import_batches WHERE id = ?").get(batchId);
|
|
4160
|
+
return row ? importBatchFromRow(row) : null;
|
|
4161
|
+
}
|
|
4162
|
+
markImportBatchRolledBack(batchId) {
|
|
4163
|
+
const rolledBackAt = new Date().toISOString();
|
|
4164
|
+
this.db.query("UPDATE import_batches SET status = 'rolled_back', rolled_back_at = ? WHERE id = ?").run(rolledBackAt, batchId);
|
|
4165
|
+
const batch = this.getImportBatch(batchId);
|
|
4166
|
+
if (!batch)
|
|
4167
|
+
throw new Error(`Import batch not found: ${batchId}`);
|
|
4168
|
+
return batch;
|
|
4169
|
+
}
|
|
4170
|
+
runInTransaction(fn) {
|
|
4171
|
+
return this.db.transaction(fn)();
|
|
4172
|
+
}
|
|
2884
4173
|
listIncidents(options = {}) {
|
|
2885
4174
|
const clauses = [];
|
|
2886
4175
|
const args = [];
|
|
@@ -2962,22 +4251,134 @@ class UptimeStore {
|
|
|
2962
4251
|
closeOpenIncident(monitorId, closedAt) {
|
|
2963
4252
|
this.db.query("UPDATE incidents SET status = 'closed', closed_at = ? WHERE monitor_id = ? AND status = 'open'").run(closedAt, monitorId);
|
|
2964
4253
|
}
|
|
4254
|
+
advanceReportSchedule(scheduleId, finishedAt) {
|
|
4255
|
+
const schedule = this.getReportSchedule(scheduleId);
|
|
4256
|
+
if (!schedule)
|
|
4257
|
+
throw new Error(`Report schedule not found: ${scheduleId}`);
|
|
4258
|
+
const finishedMs = Date.parse(finishedAt);
|
|
4259
|
+
let nextMs = Math.max(Date.parse(schedule.nextRunAt), finishedMs);
|
|
4260
|
+
do {
|
|
4261
|
+
nextMs += schedule.intervalSeconds * 1000;
|
|
4262
|
+
} while (nextMs <= finishedMs);
|
|
4263
|
+
const nextRunAt = new Date(nextMs).toISOString();
|
|
4264
|
+
this.db.query("UPDATE report_schedules SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE id = ?").run(finishedAt, nextRunAt, finishedAt, schedule.id);
|
|
4265
|
+
}
|
|
2965
4266
|
ensureColumn(table, name, definition) {
|
|
2966
4267
|
const columns = this.db.query(`PRAGMA table_info(${table})`).all();
|
|
2967
4268
|
if (!columns.some((column) => column.name === name)) {
|
|
2968
4269
|
this.db.run(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
|
|
2969
4270
|
}
|
|
2970
4271
|
}
|
|
4272
|
+
ensureMonitorKindAllowsBrowserPage() {
|
|
4273
|
+
const row = this.db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'monitors'").get();
|
|
4274
|
+
if (!row?.sql || row.sql.includes("browser_page"))
|
|
4275
|
+
return;
|
|
4276
|
+
this.db.run("PRAGMA foreign_keys = OFF");
|
|
4277
|
+
this.db.run("PRAGMA legacy_alter_table = ON");
|
|
4278
|
+
try {
|
|
4279
|
+
const migrate = this.db.transaction(() => {
|
|
4280
|
+
this.db.run("ALTER TABLE monitors RENAME TO monitors_old_kind");
|
|
4281
|
+
this.db.run(`
|
|
4282
|
+
CREATE TABLE monitors (
|
|
4283
|
+
id TEXT PRIMARY KEY,
|
|
4284
|
+
name TEXT NOT NULL UNIQUE,
|
|
4285
|
+
kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
|
|
4286
|
+
url TEXT,
|
|
4287
|
+
host TEXT,
|
|
4288
|
+
port INTEGER,
|
|
4289
|
+
method TEXT NOT NULL DEFAULT 'GET',
|
|
4290
|
+
expected_status INTEGER,
|
|
4291
|
+
interval_seconds INTEGER NOT NULL DEFAULT 60,
|
|
4292
|
+
timeout_ms INTEGER NOT NULL DEFAULT 5000,
|
|
4293
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
4294
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
4295
|
+
status TEXT NOT NULL DEFAULT 'unknown',
|
|
4296
|
+
last_checked_at TEXT,
|
|
4297
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
4298
|
+
created_at TEXT NOT NULL,
|
|
4299
|
+
updated_at TEXT NOT NULL
|
|
4300
|
+
)
|
|
4301
|
+
`);
|
|
4302
|
+
this.db.run(`
|
|
4303
|
+
INSERT INTO monitors (
|
|
4304
|
+
id, name, kind, url, host, port, method, expected_status,
|
|
4305
|
+
interval_seconds, timeout_ms, retry_count, enabled, status,
|
|
4306
|
+
last_checked_at, revision, created_at, updated_at
|
|
4307
|
+
)
|
|
4308
|
+
SELECT
|
|
4309
|
+
id, name, kind, url, host, port, method, expected_status,
|
|
4310
|
+
interval_seconds, timeout_ms, retry_count, enabled, status,
|
|
4311
|
+
last_checked_at, revision, created_at, updated_at
|
|
4312
|
+
FROM monitors_old_kind
|
|
4313
|
+
`);
|
|
4314
|
+
this.db.run("DROP TABLE monitors_old_kind");
|
|
4315
|
+
});
|
|
4316
|
+
migrate();
|
|
4317
|
+
} finally {
|
|
4318
|
+
this.db.run("PRAGMA legacy_alter_table = OFF");
|
|
4319
|
+
this.db.run("PRAGMA foreign_keys = ON");
|
|
4320
|
+
}
|
|
4321
|
+
}
|
|
4322
|
+
vacuumInto(backupPath) {
|
|
4323
|
+
const quoted = backupPath.replace(/'/g, "''");
|
|
4324
|
+
this.db.run(`VACUUM INTO '${quoted}'`);
|
|
4325
|
+
}
|
|
4326
|
+
}
|
|
4327
|
+
function resolveRuntimeMode(mode) {
|
|
4328
|
+
const value = mode ?? process.env.HASNA_UPTIME_MODE ?? "local";
|
|
4329
|
+
if (value === "local" || value === "hosted")
|
|
4330
|
+
return value;
|
|
4331
|
+
throw new Error("HASNA_UPTIME_MODE must be local or hosted");
|
|
4332
|
+
}
|
|
4333
|
+
function allowHostedLocalStore(value) {
|
|
4334
|
+
return value === true || process.env.HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE === "1";
|
|
4335
|
+
}
|
|
4336
|
+
function verifyBackupFile(backupPath) {
|
|
4337
|
+
const db = new Database(backupPath, { readonly: true });
|
|
4338
|
+
try {
|
|
4339
|
+
const integrityRow = db.query("PRAGMA integrity_check").get();
|
|
4340
|
+
const integrity = String(integrityRow?.integrity_check ?? "unknown");
|
|
4341
|
+
const missingTables = REQUIRED_TABLES.filter((table) => !tableExists(db, table));
|
|
4342
|
+
const schemaVersion = missingTables.includes("schema_migrations") ? null : db.query("SELECT value FROM schema_migrations WHERE key = 'schema_version'").get()?.value ?? null;
|
|
4343
|
+
const currentOk = missingTables.length === 0 && schemaVersion === CURRENT_SCHEMA_VERSION;
|
|
4344
|
+
const restorableV1 = schemaVersion === "1" && missingTables.every((table) => PROBE_TABLES.has(table) || REPORT_AUDIT_TABLES.has(table));
|
|
4345
|
+
const restorableV2 = schemaVersion === "2" && missingTables.every((table) => REPORT_AUDIT_TABLES.has(table));
|
|
4346
|
+
return {
|
|
4347
|
+
ok: integrity === "ok" && (currentOk || restorableV1 || restorableV2),
|
|
4348
|
+
backupPath,
|
|
4349
|
+
integrity,
|
|
4350
|
+
schemaVersion,
|
|
4351
|
+
missingTables,
|
|
4352
|
+
monitors: tableCount(db, "monitors"),
|
|
4353
|
+
results: tableCount(db, "check_results"),
|
|
4354
|
+
incidents: tableCount(db, "incidents")
|
|
4355
|
+
};
|
|
4356
|
+
} finally {
|
|
4357
|
+
db.close();
|
|
4358
|
+
}
|
|
4359
|
+
}
|
|
4360
|
+
function tableCount(db, table) {
|
|
4361
|
+
if (!tableExists(db, table))
|
|
4362
|
+
return 0;
|
|
4363
|
+
const row = db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
|
|
4364
|
+
return Number(row?.count ?? 0);
|
|
4365
|
+
}
|
|
4366
|
+
function tableExists(db, table) {
|
|
4367
|
+
const row = db.query("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
4368
|
+
return Number(row?.count ?? 0) > 0;
|
|
2971
4369
|
}
|
|
2972
|
-
function normalizeCreateMonitor(input) {
|
|
4370
|
+
function normalizeCreateMonitor(input, allowBrowserPage = false) {
|
|
2973
4371
|
const name = input.name?.trim();
|
|
2974
4372
|
if (!name)
|
|
2975
4373
|
throw new Error("Monitor name is required");
|
|
2976
|
-
|
|
4374
|
+
rejectControlCharacters2(name, "Monitor name");
|
|
2977
4375
|
const method = normalizeMethod(input.method ?? "GET");
|
|
2978
4376
|
const expectedStatus = normalizeExpectedStatus(input.expectedStatus);
|
|
2979
4377
|
const enabled = normalizeEnabled(input.enabled);
|
|
2980
|
-
if (input.kind === "http") {
|
|
4378
|
+
if (input.kind === "http" || input.kind === "browser_page") {
|
|
4379
|
+
if (input.kind === "browser_page" && !allowBrowserPage) {
|
|
4380
|
+
throw new Error("browser_page monitors must be imported with explicit browser evidence support");
|
|
4381
|
+
}
|
|
2981
4382
|
const url = normalizeHttpUrl(input.url);
|
|
2982
4383
|
return {
|
|
2983
4384
|
name,
|
|
@@ -2985,16 +4386,16 @@ function normalizeCreateMonitor(input) {
|
|
|
2985
4386
|
url,
|
|
2986
4387
|
method,
|
|
2987
4388
|
expectedStatus,
|
|
2988
|
-
intervalSeconds:
|
|
2989
|
-
timeoutMs:
|
|
2990
|
-
retryCount:
|
|
4389
|
+
intervalSeconds: boundedInteger2(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
|
|
4390
|
+
timeoutMs: boundedInteger2(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
|
|
4391
|
+
retryCount: boundedInteger2(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
|
|
2991
4392
|
enabled
|
|
2992
4393
|
};
|
|
2993
4394
|
} else if (input.kind === "tcp") {
|
|
2994
4395
|
const host = input.host?.trim();
|
|
2995
4396
|
if (!host)
|
|
2996
4397
|
throw new Error("TCP monitors require host");
|
|
2997
|
-
|
|
4398
|
+
rejectControlCharacters2(host, "TCP host");
|
|
2998
4399
|
if (!Number.isInteger(input.port) || input.port <= 0 || input.port > 65535) {
|
|
2999
4400
|
throw new Error("TCP monitors require a port from 1 to 65535");
|
|
3000
4401
|
}
|
|
@@ -3005,19 +4406,19 @@ function normalizeCreateMonitor(input) {
|
|
|
3005
4406
|
port: input.port,
|
|
3006
4407
|
method,
|
|
3007
4408
|
expectedStatus: null,
|
|
3008
|
-
intervalSeconds:
|
|
3009
|
-
timeoutMs:
|
|
3010
|
-
retryCount:
|
|
4409
|
+
intervalSeconds: boundedInteger2(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
|
|
4410
|
+
timeoutMs: boundedInteger2(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
|
|
4411
|
+
retryCount: boundedInteger2(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
|
|
3011
4412
|
enabled
|
|
3012
4413
|
};
|
|
3013
4414
|
} else {
|
|
3014
|
-
throw new Error("Monitor kind must be http or
|
|
4415
|
+
throw new Error("Monitor kind must be http, tcp, or browser_page");
|
|
3015
4416
|
}
|
|
3016
4417
|
}
|
|
3017
4418
|
function definitionChanged(current, next) {
|
|
3018
4419
|
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;
|
|
3019
4420
|
}
|
|
3020
|
-
function normalizeUpdateMonitor(current, input, updatedAt) {
|
|
4421
|
+
function normalizeUpdateMonitor(current, input, updatedAt, allowBrowserPage = false) {
|
|
3021
4422
|
const merged = {
|
|
3022
4423
|
...current,
|
|
3023
4424
|
...input,
|
|
@@ -3036,7 +4437,7 @@ function normalizeUpdateMonitor(current, input, updatedAt) {
|
|
|
3036
4437
|
timeoutMs: merged.timeoutMs,
|
|
3037
4438
|
retryCount: merged.retryCount,
|
|
3038
4439
|
enabled: merged.enabled
|
|
3039
|
-
});
|
|
4440
|
+
}, allowBrowserPage || current.kind === "browser_page");
|
|
3040
4441
|
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;
|
|
3041
4442
|
const status = normalized.enabled ? checkDefinitionChanged || !current.enabled ? "unknown" : current.status : "paused";
|
|
3042
4443
|
return {
|
|
@@ -3065,6 +4466,11 @@ function normalizeHttpUrl(value) {
|
|
|
3065
4466
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
3066
4467
|
throw new Error("HTTP monitor url must use http or https");
|
|
3067
4468
|
}
|
|
4469
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
4470
|
+
if (SECRET_URL_PARAM_PATTERN.test(key))
|
|
4471
|
+
parsed.searchParams.set(key, "[redacted]");
|
|
4472
|
+
}
|
|
4473
|
+
parsed.hash = "";
|
|
3068
4474
|
return parsed.toString();
|
|
3069
4475
|
}
|
|
3070
4476
|
function normalizeMethod(value) {
|
|
@@ -3088,11 +4494,194 @@ function normalizeEnabled(value) {
|
|
|
3088
4494
|
throw new Error("enabled must be a boolean");
|
|
3089
4495
|
return value;
|
|
3090
4496
|
}
|
|
3091
|
-
function
|
|
4497
|
+
function rejectControlCharacters2(value, label) {
|
|
3092
4498
|
if (/[\x00-\x1f\x7f-\x9f]/.test(value)) {
|
|
3093
4499
|
throw new Error(`${label} must not contain control characters`);
|
|
3094
4500
|
}
|
|
3095
4501
|
}
|
|
4502
|
+
function normalizeScheduleSlot(value) {
|
|
4503
|
+
const slot = value.trim();
|
|
4504
|
+
if (!slot)
|
|
4505
|
+
throw new Error("Probe job scheduleSlot is required");
|
|
4506
|
+
if (slot.length > 128)
|
|
4507
|
+
throw new Error("Probe job scheduleSlot is too long");
|
|
4508
|
+
rejectControlCharacters2(slot, "Probe job scheduleSlot");
|
|
4509
|
+
return slot;
|
|
4510
|
+
}
|
|
4511
|
+
function normalizeReportScheduleInput(input) {
|
|
4512
|
+
const name = input.name?.trim();
|
|
4513
|
+
if (!name)
|
|
4514
|
+
throw new Error("Report schedule name is required");
|
|
4515
|
+
rejectControlCharacters2(name, "Report schedule name");
|
|
4516
|
+
const intervalSeconds = boundedInteger2(input.intervalSeconds, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS);
|
|
4517
|
+
const nextRunAt = input.nextRunAt ?? new Date().toISOString();
|
|
4518
|
+
assertIsoTimestamp(nextRunAt, "Report schedule nextRunAt");
|
|
4519
|
+
const enabled = normalizeEnabled(input.enabled);
|
|
4520
|
+
const subject = normalizeNullableBoundedText(input.subject, "Report schedule subject", 200);
|
|
4521
|
+
const channels = normalizeReportChannels(input.channels);
|
|
4522
|
+
return { name, intervalSeconds, nextRunAt, enabled, subject, channels };
|
|
4523
|
+
}
|
|
4524
|
+
function normalizeReportChannels(channels) {
|
|
4525
|
+
if (!channels || typeof channels !== "object")
|
|
4526
|
+
throw new Error("Report schedule channels are required");
|
|
4527
|
+
const normalized = {};
|
|
4528
|
+
if (channels.email !== undefined)
|
|
4529
|
+
normalized.email = normalizeChannelTarget(channels.email, "email", ["apiUrl", "from", "to", "subject", "providerId"]);
|
|
4530
|
+
if (channels.sms !== undefined)
|
|
4531
|
+
normalized.sms = normalizeChannelTarget(channels.sms, "sms", ["apiUrl", "from", "to"]);
|
|
4532
|
+
if (channels.logs !== undefined)
|
|
4533
|
+
normalized.logs = normalizeChannelTarget(channels.logs, "logs", ["apiUrl", "projectId", "environment", "service"]);
|
|
4534
|
+
if (!normalized.email && !normalized.sms && !normalized.logs) {
|
|
4535
|
+
throw new Error("Report schedule requires at least one channel");
|
|
4536
|
+
}
|
|
4537
|
+
return normalized;
|
|
4538
|
+
}
|
|
4539
|
+
function normalizeChannelTarget(value, channel, allowedKeys) {
|
|
4540
|
+
if (value === false || value == null)
|
|
4541
|
+
return false;
|
|
4542
|
+
if (value === true)
|
|
4543
|
+
return true;
|
|
4544
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
4545
|
+
throw new Error(`Report schedule ${channel} channel must be true or an object`);
|
|
4546
|
+
}
|
|
4547
|
+
const record = value;
|
|
4548
|
+
const normalized = {};
|
|
4549
|
+
for (const [key, rawValue] of Object.entries(record)) {
|
|
4550
|
+
if (!allowedKeys.includes(key)) {
|
|
4551
|
+
if (/key|token|secret|password|credential|auth/i.test(key)) {
|
|
4552
|
+
throw new Error("Report schedules must not persist API keys or tokens; use environment variables or cloud channel refs");
|
|
4553
|
+
}
|
|
4554
|
+
throw new Error(`Unsupported report schedule ${channel} channel field: ${key}`);
|
|
4555
|
+
}
|
|
4556
|
+
if (rawValue === undefined || rawValue === null || rawValue === "")
|
|
4557
|
+
continue;
|
|
4558
|
+
if (key === "apiUrl" && Array.isArray(rawValue)) {
|
|
4559
|
+
throw new Error(`Report schedule ${channel}.${key} must be a string`);
|
|
4560
|
+
}
|
|
4561
|
+
if (Array.isArray(rawValue)) {
|
|
4562
|
+
const items = rawValue.map((item) => normalizeBoundedText(String(item), `Report schedule ${channel}.${key}`, 300));
|
|
4563
|
+
if (items.length > 0)
|
|
4564
|
+
normalized[key] = items;
|
|
4565
|
+
} else if (typeof rawValue === "string" || typeof rawValue === "number") {
|
|
4566
|
+
normalized[key] = key === "apiUrl" ? normalizeHttpIntegrationUrl(String(rawValue)) : normalizeBoundedText(String(rawValue), `Report schedule ${channel}.${key}`, 500);
|
|
4567
|
+
} else {
|
|
4568
|
+
throw new Error(`Report schedule ${channel}.${key} must be a string or string array`);
|
|
4569
|
+
}
|
|
4570
|
+
}
|
|
4571
|
+
return Object.keys(normalized).length > 0 ? normalized : true;
|
|
4572
|
+
}
|
|
4573
|
+
function normalizeHttpIntegrationUrl(value) {
|
|
4574
|
+
const parsed = new URL(value.trim());
|
|
4575
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
4576
|
+
throw new Error("Report schedule integration API URL must use http or https");
|
|
4577
|
+
}
|
|
4578
|
+
if (parsed.username || parsed.password) {
|
|
4579
|
+
throw new Error("Report schedule integration API URL must not include credentials");
|
|
4580
|
+
}
|
|
4581
|
+
for (const key of parsed.searchParams.keys()) {
|
|
4582
|
+
if (SECRET_URL_PARAM_PATTERN.test(key)) {
|
|
4583
|
+
throw new Error("Report schedule integration API URL must not include secret query parameters");
|
|
4584
|
+
}
|
|
4585
|
+
}
|
|
4586
|
+
parsed.hash = "";
|
|
4587
|
+
return parsed.toString();
|
|
4588
|
+
}
|
|
4589
|
+
function normalizeReportDeliveries(deliveries) {
|
|
4590
|
+
return deliveries.map((delivery) => {
|
|
4591
|
+
if (delivery.channel !== "email" && delivery.channel !== "sms" && delivery.channel !== "logs") {
|
|
4592
|
+
throw new Error("Report delivery channel must be email, sms, or logs");
|
|
4593
|
+
}
|
|
4594
|
+
return {
|
|
4595
|
+
channel: delivery.channel,
|
|
4596
|
+
ok: Boolean(delivery.ok),
|
|
4597
|
+
status: delivery.status,
|
|
4598
|
+
id: delivery.id === undefined ? undefined : normalizeRedactedText(String(delivery.id), "Report delivery id", 300),
|
|
4599
|
+
error: delivery.error === undefined ? undefined : normalizeRedactedText(String(delivery.error), "Report delivery error", 1000)
|
|
4600
|
+
};
|
|
4601
|
+
});
|
|
4602
|
+
}
|
|
4603
|
+
function normalizeAuditText(value, label, maxLength) {
|
|
4604
|
+
return normalizeBoundedText(value ?? "", label, maxLength);
|
|
4605
|
+
}
|
|
4606
|
+
function normalizeNullableAuditText(value, label, maxLength) {
|
|
4607
|
+
return normalizeNullableBoundedText(value, label, maxLength);
|
|
4608
|
+
}
|
|
4609
|
+
function normalizeNullableBoundedText(value, label, maxLength) {
|
|
4610
|
+
if (value == null)
|
|
4611
|
+
return null;
|
|
4612
|
+
const normalized = normalizeRedactedText(value, label, maxLength);
|
|
4613
|
+
return normalized || null;
|
|
4614
|
+
}
|
|
4615
|
+
function normalizeBoundedText(value, label, maxLength) {
|
|
4616
|
+
const normalized = value.trim();
|
|
4617
|
+
rejectControlCharacters2(normalized, label);
|
|
4618
|
+
if (normalized.length > maxLength)
|
|
4619
|
+
throw new Error(`${label} is too long`);
|
|
4620
|
+
return normalized;
|
|
4621
|
+
}
|
|
4622
|
+
function normalizeNullableRedactedText(value, label, maxLength) {
|
|
4623
|
+
if (value == null)
|
|
4624
|
+
return null;
|
|
4625
|
+
const normalized = normalizeRedactedText(value, label, maxLength);
|
|
4626
|
+
return normalized || null;
|
|
4627
|
+
}
|
|
4628
|
+
function normalizeRedactedText(value, label, maxLength) {
|
|
4629
|
+
return normalizeBoundedText(redactSecretString(value), label, maxLength);
|
|
4630
|
+
}
|
|
4631
|
+
function normalizeAuditMetadata(value) {
|
|
4632
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
4633
|
+
throw new Error("Audit metadata must be an object");
|
|
4634
|
+
}
|
|
4635
|
+
return redactAuditSecrets(JSON.parse(JSON.stringify(value)));
|
|
4636
|
+
}
|
|
4637
|
+
function redactAuditSecrets(value) {
|
|
4638
|
+
if (Array.isArray(value))
|
|
4639
|
+
return value.map(redactAuditSecrets);
|
|
4640
|
+
if (typeof value === "string")
|
|
4641
|
+
return redactSecretString(value);
|
|
4642
|
+
if (!value || typeof value !== "object")
|
|
4643
|
+
return value;
|
|
4644
|
+
const output = {};
|
|
4645
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
4646
|
+
output[key] = /key|token|secret|password|credential|auth/i.test(key) ? "[REDACTED]" : redactAuditSecrets(nested);
|
|
4647
|
+
}
|
|
4648
|
+
return output;
|
|
4649
|
+
}
|
|
4650
|
+
function redactSecretString(value) {
|
|
4651
|
+
let output = value.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]");
|
|
4652
|
+
output = output.replace(/https?:\/\/[^\s"'<>]+/gi, (match) => redactUrlString(match));
|
|
4653
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(output))
|
|
4654
|
+
return output;
|
|
4655
|
+
return redactUrlString(output);
|
|
4656
|
+
}
|
|
4657
|
+
function redactUrlString(value) {
|
|
4658
|
+
let trailing = "";
|
|
4659
|
+
let candidate = value;
|
|
4660
|
+
while (/[),.;\]]$/.test(candidate)) {
|
|
4661
|
+
trailing = `${candidate.slice(-1)}${trailing}`;
|
|
4662
|
+
candidate = candidate.slice(0, -1);
|
|
4663
|
+
}
|
|
4664
|
+
try {
|
|
4665
|
+
const parsed = new URL(candidate);
|
|
4666
|
+
if (parsed.username)
|
|
4667
|
+
parsed.username = "[REDACTED]";
|
|
4668
|
+
if (parsed.password)
|
|
4669
|
+
parsed.password = "[REDACTED]";
|
|
4670
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
4671
|
+
if (SECRET_URL_PARAM_PATTERN.test(key))
|
|
4672
|
+
parsed.searchParams.set(key, "[REDACTED]");
|
|
4673
|
+
}
|
|
4674
|
+
parsed.hash = "";
|
|
4675
|
+
return `${parsed.toString()}${trailing}`;
|
|
4676
|
+
} catch {
|
|
4677
|
+
return value;
|
|
4678
|
+
}
|
|
4679
|
+
}
|
|
4680
|
+
function assertIsoTimestamp(value, label) {
|
|
4681
|
+
if (!Number.isFinite(Date.parse(value))) {
|
|
4682
|
+
throw new Error(`${label} must be an ISO timestamp`);
|
|
4683
|
+
}
|
|
4684
|
+
}
|
|
3096
4685
|
function monitorFromRow(row) {
|
|
3097
4686
|
return {
|
|
3098
4687
|
id: row.id,
|
|
@@ -3123,9 +4712,137 @@ function checkResultFromRow(row) {
|
|
|
3123
4712
|
latencyMs: row.latency_ms,
|
|
3124
4713
|
statusCode: row.status_code,
|
|
3125
4714
|
error: row.error,
|
|
3126
|
-
attemptCount: row.attempt_count
|
|
4715
|
+
attemptCount: row.attempt_count,
|
|
4716
|
+
evidence: parseEvidence(row.evidence_json)
|
|
4717
|
+
};
|
|
4718
|
+
}
|
|
4719
|
+
function provenanceFromRow(row) {
|
|
4720
|
+
return {
|
|
4721
|
+
monitorId: row.monitor_id,
|
|
4722
|
+
source: row.source,
|
|
4723
|
+
sourceId: row.source_id,
|
|
4724
|
+
sourceLabel: row.source_label,
|
|
4725
|
+
importedAt: row.imported_at,
|
|
4726
|
+
snapshot: parseJson(row.snapshot_json)
|
|
4727
|
+
};
|
|
4728
|
+
}
|
|
4729
|
+
function importBatchFromRow(row) {
|
|
4730
|
+
return {
|
|
4731
|
+
id: row.id,
|
|
4732
|
+
source: row.source,
|
|
4733
|
+
status: row.status,
|
|
4734
|
+
createdAt: row.created_at,
|
|
4735
|
+
rolledBackAt: row.rolled_back_at,
|
|
4736
|
+
records: Array.isArray(parseJson(row.records_json)) ? parseJson(row.records_json) : []
|
|
4737
|
+
};
|
|
4738
|
+
}
|
|
4739
|
+
function probeIdentityFromRow(row) {
|
|
4740
|
+
return {
|
|
4741
|
+
id: row.id,
|
|
4742
|
+
name: row.name,
|
|
4743
|
+
publicKeyPem: row.public_key_pem,
|
|
4744
|
+
publicKeyFingerprint: row.public_key_fingerprint,
|
|
4745
|
+
enabled: Boolean(row.enabled),
|
|
4746
|
+
createdAt: row.created_at,
|
|
4747
|
+
lastSeenAt: row.last_seen_at
|
|
4748
|
+
};
|
|
4749
|
+
}
|
|
4750
|
+
function probeSubmissionFromRow(row) {
|
|
4751
|
+
return {
|
|
4752
|
+
id: row.id,
|
|
4753
|
+
probeId: row.probe_id,
|
|
4754
|
+
jobId: row.job_id ?? "",
|
|
4755
|
+
monitorId: row.monitor_id,
|
|
4756
|
+
checkResultId: row.check_result_id,
|
|
4757
|
+
nonce: row.nonce,
|
|
4758
|
+
checkedAt: row.checked_at,
|
|
4759
|
+
submittedAt: row.submitted_at
|
|
4760
|
+
};
|
|
4761
|
+
}
|
|
4762
|
+
function probeCheckJobFromRow(row) {
|
|
4763
|
+
return {
|
|
4764
|
+
id: row.id,
|
|
4765
|
+
monitorId: row.monitor_id,
|
|
4766
|
+
monitorRevision: row.monitor_revision ?? 1,
|
|
4767
|
+
scheduleSlot: row.schedule_slot,
|
|
4768
|
+
status: row.status,
|
|
4769
|
+
claimedByProbeId: row.claimed_by_probe_id,
|
|
4770
|
+
fencingToken: row.fencing_token,
|
|
4771
|
+
dueAt: row.due_at,
|
|
4772
|
+
claimedAt: row.claimed_at,
|
|
4773
|
+
leaseExpiresAt: row.lease_expires_at,
|
|
4774
|
+
submittedResultId: row.submitted_result_id,
|
|
4775
|
+
createdAt: row.created_at,
|
|
4776
|
+
updatedAt: row.updated_at
|
|
4777
|
+
};
|
|
4778
|
+
}
|
|
4779
|
+
function reportScheduleFromRow(row) {
|
|
4780
|
+
return {
|
|
4781
|
+
id: row.id,
|
|
4782
|
+
name: row.name,
|
|
4783
|
+
enabled: Boolean(row.enabled),
|
|
4784
|
+
intervalSeconds: row.interval_seconds,
|
|
4785
|
+
nextRunAt: row.next_run_at,
|
|
4786
|
+
lastRunAt: row.last_run_at,
|
|
4787
|
+
subject: row.subject,
|
|
4788
|
+
channels: parseReportChannels(row.channels_json),
|
|
4789
|
+
createdAt: row.created_at,
|
|
4790
|
+
updatedAt: row.updated_at
|
|
4791
|
+
};
|
|
4792
|
+
}
|
|
4793
|
+
function reportRunFromRow(row) {
|
|
4794
|
+
return {
|
|
4795
|
+
id: row.id,
|
|
4796
|
+
scheduleId: row.schedule_id,
|
|
4797
|
+
status: row.status,
|
|
4798
|
+
startedAt: row.started_at,
|
|
4799
|
+
finishedAt: row.finished_at,
|
|
4800
|
+
deliveries: parseReportDeliveries(row.deliveries_json),
|
|
4801
|
+
error: row.error,
|
|
4802
|
+
reportJson: parseRecord(row.report_json)
|
|
3127
4803
|
};
|
|
3128
4804
|
}
|
|
4805
|
+
function auditEventFromRow(row) {
|
|
4806
|
+
return {
|
|
4807
|
+
id: row.id,
|
|
4808
|
+
action: row.action,
|
|
4809
|
+
resourceType: row.resource_type,
|
|
4810
|
+
resourceId: row.resource_id,
|
|
4811
|
+
message: row.message,
|
|
4812
|
+
metadata: parseRecord(row.metadata_json) ?? {},
|
|
4813
|
+
actor: row.actor,
|
|
4814
|
+
createdAt: row.created_at
|
|
4815
|
+
};
|
|
4816
|
+
}
|
|
4817
|
+
function parseEvidence(value) {
|
|
4818
|
+
if (!value)
|
|
4819
|
+
return null;
|
|
4820
|
+
const parsed = parseJson(value);
|
|
4821
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
4822
|
+
}
|
|
4823
|
+
function parseReportChannels(value) {
|
|
4824
|
+
const parsed = parseJson(value);
|
|
4825
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
4826
|
+
return {};
|
|
4827
|
+
return parsed;
|
|
4828
|
+
}
|
|
4829
|
+
function parseReportDeliveries(value) {
|
|
4830
|
+
const parsed = parseJson(value);
|
|
4831
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
4832
|
+
}
|
|
4833
|
+
function parseRecord(value) {
|
|
4834
|
+
if (!value)
|
|
4835
|
+
return null;
|
|
4836
|
+
const parsed = parseJson(value);
|
|
4837
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
4838
|
+
}
|
|
4839
|
+
function parseJson(value) {
|
|
4840
|
+
try {
|
|
4841
|
+
return JSON.parse(value);
|
|
4842
|
+
} catch {
|
|
4843
|
+
return null;
|
|
4844
|
+
}
|
|
4845
|
+
}
|
|
3129
4846
|
function incidentFromRow(row) {
|
|
3130
4847
|
return {
|
|
3131
4848
|
id: row.id,
|
|
@@ -3140,9 +4857,9 @@ function incidentFromRow(row) {
|
|
|
3140
4857
|
};
|
|
3141
4858
|
}
|
|
3142
4859
|
function newId(prefix) {
|
|
3143
|
-
return `${prefix}_${
|
|
4860
|
+
return `${prefix}_${randomUUID2().replace(/-/g, "").slice(0, 18)}`;
|
|
3144
4861
|
}
|
|
3145
|
-
function
|
|
4862
|
+
function boundedInteger2(value, label, min, max) {
|
|
3146
4863
|
if (!Number.isInteger(value) || value < min || value > max) {
|
|
3147
4864
|
throw new Error(`${label} must be an integer from ${min} to ${max}`);
|
|
3148
4865
|
}
|
|
@@ -3153,6 +4870,9 @@ function clampLimit(value) {
|
|
|
3153
4870
|
return 50;
|
|
3154
4871
|
return Math.max(1, Math.min(Math.floor(value), MAX_RESULT_LIMIT));
|
|
3155
4872
|
}
|
|
4873
|
+
function statementChanges(result) {
|
|
4874
|
+
return Number(result?.changes ?? 0);
|
|
4875
|
+
}
|
|
3156
4876
|
function round(value, places) {
|
|
3157
4877
|
const factor = 10 ** places;
|
|
3158
4878
|
return Math.round(value * factor) / factor;
|
|
@@ -3227,7 +4947,7 @@ function renderMonitorLine(item) {
|
|
|
3227
4947
|
return `- ${item.monitor.status.toUpperCase()} ${item.monitor.name} (${targetLabel(item)}): uptime ${uptime}, latency ${latency}${incident}`;
|
|
3228
4948
|
}
|
|
3229
4949
|
function targetLabel(item) {
|
|
3230
|
-
return item.monitor.kind === "
|
|
4950
|
+
return item.monitor.kind === "tcp" ? `${item.monitor.host}:${item.monitor.port}` : item.monitor.url ?? "";
|
|
3231
4951
|
}
|
|
3232
4952
|
function resolveEmailTarget(value) {
|
|
3233
4953
|
const target = typeof value === "boolean" ? {} : value;
|
|
@@ -3429,13 +5149,17 @@ function redactOptional(value, secrets) {
|
|
|
3429
5149
|
}
|
|
3430
5150
|
|
|
3431
5151
|
// src/service.ts
|
|
5152
|
+
var MAX_PROBE_RESULT_AGE_MS = 15 * 60000;
|
|
5153
|
+
var MAX_PROBE_RESULT_FUTURE_MS = 5 * 60000;
|
|
5154
|
+
|
|
3432
5155
|
class UptimeService {
|
|
3433
5156
|
store;
|
|
3434
5157
|
checkRunner;
|
|
3435
|
-
leaseOwner = `svc_${
|
|
5158
|
+
leaseOwner = `svc_${randomUUID3().replace(/-/g, "").slice(0, 18)}`;
|
|
3436
5159
|
inFlightChecks = new Set;
|
|
5160
|
+
inFlightReportSchedules = new Set;
|
|
3437
5161
|
constructor(options = {}) {
|
|
3438
|
-
this.store = options.store ?? new UptimeStore(options);
|
|
5162
|
+
this.store = options.store ?? new UptimeStore({ mode: "local", ...options });
|
|
3439
5163
|
this.checkRunner = options.checkRunner ?? runMonitorCheck;
|
|
3440
5164
|
}
|
|
3441
5165
|
close() {
|
|
@@ -3465,13 +5189,180 @@ class UptimeService {
|
|
|
3465
5189
|
summary() {
|
|
3466
5190
|
return this.store.summary();
|
|
3467
5191
|
}
|
|
5192
|
+
createProbe(input) {
|
|
5193
|
+
const store = this.probeStore();
|
|
5194
|
+
const publicKeyPem = input.publicKeyPem ? normalizeProbePublicKeyPem(input.publicKeyPem) : undefined;
|
|
5195
|
+
const keyPair = publicKeyPem ? {
|
|
5196
|
+
publicKeyPem,
|
|
5197
|
+
privateKeyPem: undefined,
|
|
5198
|
+
publicKeyFingerprint: probePublicKeyFingerprint(publicKeyPem)
|
|
5199
|
+
} : generateProbeKeyPair();
|
|
5200
|
+
const probe = store.createProbeIdentity({
|
|
5201
|
+
name: input.name,
|
|
5202
|
+
publicKeyPem: keyPair.publicKeyPem,
|
|
5203
|
+
publicKeyFingerprint: keyPair.publicKeyFingerprint,
|
|
5204
|
+
enabled: input.enabled
|
|
5205
|
+
});
|
|
5206
|
+
return { ...probe, privateKeyPem: keyPair.privateKeyPem };
|
|
5207
|
+
}
|
|
5208
|
+
listProbes(options = {}) {
|
|
5209
|
+
return this.probeStore().listProbeIdentities(options);
|
|
5210
|
+
}
|
|
5211
|
+
getProbe(idOrName) {
|
|
5212
|
+
return this.probeStore().getProbeIdentity(idOrName);
|
|
5213
|
+
}
|
|
5214
|
+
updateProbe(idOrName, input) {
|
|
5215
|
+
return this.probeStore().updateProbeIdentity(idOrName, input);
|
|
5216
|
+
}
|
|
5217
|
+
createProbeCheckJob(input) {
|
|
5218
|
+
return this.probeStore().createProbeCheckJob(input);
|
|
5219
|
+
}
|
|
5220
|
+
getProbeCheckJob(id) {
|
|
5221
|
+
return this.probeStore().getProbeCheckJob(id);
|
|
5222
|
+
}
|
|
5223
|
+
claimProbeCheckJob(input) {
|
|
5224
|
+
return this.probeStore().claimProbeCheckJob(input);
|
|
5225
|
+
}
|
|
5226
|
+
submitProbeResult(input) {
|
|
5227
|
+
const execute = () => this.submitProbeResultInTransaction(input);
|
|
5228
|
+
return this.store.runInTransaction ? this.store.runInTransaction(execute) : execute();
|
|
5229
|
+
}
|
|
5230
|
+
previewImport(request) {
|
|
5231
|
+
return previewImport(this.store, request);
|
|
5232
|
+
}
|
|
5233
|
+
applyImport(request) {
|
|
5234
|
+
return applyImport(this.store, request);
|
|
5235
|
+
}
|
|
5236
|
+
rollbackImport(batchId) {
|
|
5237
|
+
return rollbackImport(this.store, batchId);
|
|
5238
|
+
}
|
|
5239
|
+
backup(destinationPath) {
|
|
5240
|
+
return this.store.backup(destinationPath);
|
|
5241
|
+
}
|
|
5242
|
+
verifyBackup(backupPath) {
|
|
5243
|
+
return this.store.verifyBackup(backupPath);
|
|
5244
|
+
}
|
|
3468
5245
|
buildReport(options = {}) {
|
|
3469
5246
|
return buildUptimeReport(this.summary(), options);
|
|
3470
5247
|
}
|
|
3471
5248
|
async sendReport(options = {}) {
|
|
5249
|
+
if (this.store.mode === "hosted" && (options.email || options.sms || options.logs)) {
|
|
5250
|
+
throw new Error("hosted report delivery requires configured channel refs");
|
|
5251
|
+
}
|
|
3472
5252
|
return sendUptimeReport(this.summary(), options);
|
|
3473
5253
|
}
|
|
5254
|
+
createReportSchedule(input) {
|
|
5255
|
+
const store = this.reportStore();
|
|
5256
|
+
const schedule = store.createReportSchedule(input);
|
|
5257
|
+
this.audit("report_schedule.create", "report_schedule", schedule.id, `Created report schedule ${schedule.name}`, {
|
|
5258
|
+
name: schedule.name,
|
|
5259
|
+
enabled: schedule.enabled,
|
|
5260
|
+
intervalSeconds: schedule.intervalSeconds,
|
|
5261
|
+
channels: enabledReportChannels(schedule)
|
|
5262
|
+
});
|
|
5263
|
+
return schedule;
|
|
5264
|
+
}
|
|
5265
|
+
listReportSchedules(options = {}) {
|
|
5266
|
+
return this.reportStore().listReportSchedules(options);
|
|
5267
|
+
}
|
|
5268
|
+
getReportSchedule(idOrName) {
|
|
5269
|
+
return this.reportStore().getReportSchedule(idOrName);
|
|
5270
|
+
}
|
|
5271
|
+
updateReportSchedule(idOrName, input) {
|
|
5272
|
+
const store = this.reportStore();
|
|
5273
|
+
const schedule = store.updateReportSchedule(idOrName, input);
|
|
5274
|
+
this.audit("report_schedule.update", "report_schedule", schedule.id, `Updated report schedule ${schedule.name}`, {
|
|
5275
|
+
name: schedule.name,
|
|
5276
|
+
enabled: schedule.enabled,
|
|
5277
|
+
intervalSeconds: schedule.intervalSeconds,
|
|
5278
|
+
channels: enabledReportChannels(schedule)
|
|
5279
|
+
});
|
|
5280
|
+
return schedule;
|
|
5281
|
+
}
|
|
5282
|
+
deleteReportSchedule(idOrName) {
|
|
5283
|
+
const store = this.reportStore();
|
|
5284
|
+
const schedule = store.getReportSchedule(idOrName);
|
|
5285
|
+
const deleted = store.deleteReportSchedule(idOrName);
|
|
5286
|
+
if (deleted && schedule) {
|
|
5287
|
+
this.audit("report_schedule.delete", "report_schedule", schedule.id, `Deleted report schedule ${schedule.name}`, {
|
|
5288
|
+
name: schedule.name
|
|
5289
|
+
});
|
|
5290
|
+
}
|
|
5291
|
+
return deleted;
|
|
5292
|
+
}
|
|
5293
|
+
listReportRuns(options = {}) {
|
|
5294
|
+
return this.reportStore().listReportRuns(options);
|
|
5295
|
+
}
|
|
5296
|
+
listAuditEvents(options = {}) {
|
|
5297
|
+
return this.reportStore().listAuditEvents(options);
|
|
5298
|
+
}
|
|
5299
|
+
recordAuditEvent(input) {
|
|
5300
|
+
return this.reportStore().recordAuditEvent(input);
|
|
5301
|
+
}
|
|
5302
|
+
async runReportSchedule(idOrName, options = {}) {
|
|
5303
|
+
const store = this.reportStore();
|
|
5304
|
+
const schedule = store.getReportSchedule(idOrName);
|
|
5305
|
+
if (!schedule)
|
|
5306
|
+
throw new Error(`Report schedule not found: ${idOrName}`);
|
|
5307
|
+
if (!schedule.enabled)
|
|
5308
|
+
throw new Error(`Report schedule is disabled: ${schedule.name}`);
|
|
5309
|
+
if (this.inFlightReportSchedules.has(schedule.id))
|
|
5310
|
+
throw new Error(`Report schedule already running: ${schedule.name}`);
|
|
5311
|
+
this.inFlightReportSchedules.add(schedule.id);
|
|
5312
|
+
try {
|
|
5313
|
+
const startedAt = new Date().toISOString();
|
|
5314
|
+
let deliveries = [];
|
|
5315
|
+
let error = null;
|
|
5316
|
+
let reportJson = null;
|
|
5317
|
+
try {
|
|
5318
|
+
const report = this.buildReport({ subject: schedule.subject ?? undefined });
|
|
5319
|
+
reportJson = report.json;
|
|
5320
|
+
deliveries = await this.sendReport({
|
|
5321
|
+
subject: schedule.subject ?? undefined,
|
|
5322
|
+
email: schedule.channels.email,
|
|
5323
|
+
sms: schedule.channels.sms,
|
|
5324
|
+
logs: schedule.channels.logs,
|
|
5325
|
+
fetchImpl: options.fetchImpl
|
|
5326
|
+
});
|
|
5327
|
+
const failed = deliveries.filter((delivery) => !delivery.ok);
|
|
5328
|
+
if (failed.length > 0) {
|
|
5329
|
+
error = failed.map((delivery) => `${delivery.channel}: ${delivery.error ?? delivery.status ?? "failed"}`).join("; ");
|
|
5330
|
+
}
|
|
5331
|
+
} catch (caught) {
|
|
5332
|
+
error = caught instanceof Error ? caught.message : String(caught);
|
|
5333
|
+
}
|
|
5334
|
+
const finishedAt = new Date().toISOString();
|
|
5335
|
+
const run = store.recordReportRun({
|
|
5336
|
+
scheduleId: schedule.id,
|
|
5337
|
+
status: error ? "failed" : "success",
|
|
5338
|
+
startedAt,
|
|
5339
|
+
finishedAt,
|
|
5340
|
+
deliveries,
|
|
5341
|
+
error,
|
|
5342
|
+
reportJson
|
|
5343
|
+
});
|
|
5344
|
+
this.audit("report_schedule.run", "report_schedule", schedule.id, `Ran report schedule ${schedule.name}`, {
|
|
5345
|
+
runId: run.id,
|
|
5346
|
+
status: run.status,
|
|
5347
|
+
deliveryChannels: run.deliveries.map((delivery) => ({ channel: delivery.channel, ok: delivery.ok }))
|
|
5348
|
+
});
|
|
5349
|
+
return run;
|
|
5350
|
+
} finally {
|
|
5351
|
+
this.inFlightReportSchedules.delete(schedule.id);
|
|
5352
|
+
}
|
|
5353
|
+
}
|
|
5354
|
+
async runDueReportSchedules(now = new Date, options = {}) {
|
|
5355
|
+
const store = this.reportStore();
|
|
5356
|
+
const schedules = store.listDueReportSchedules(now.toISOString());
|
|
5357
|
+
const runs = [];
|
|
5358
|
+
for (const schedule of schedules) {
|
|
5359
|
+
runs.push(await this.runReportSchedule(schedule.id, options));
|
|
5360
|
+
}
|
|
5361
|
+
return runs;
|
|
5362
|
+
}
|
|
3474
5363
|
async checkMonitor(idOrName) {
|
|
5364
|
+
if (this.store.mode === "hosted")
|
|
5365
|
+
throw new Error("hosted checks require check_jobs and probes");
|
|
3475
5366
|
const monitor = this.store.getMonitor(idOrName);
|
|
3476
5367
|
if (!monitor)
|
|
3477
5368
|
throw new Error(`Monitor not found: ${idOrName}`);
|
|
@@ -3500,6 +5391,7 @@ class UptimeService {
|
|
|
3500
5391
|
latencyMs: last.latencyMs,
|
|
3501
5392
|
statusCode: last.statusCode ?? null,
|
|
3502
5393
|
error: last.error ?? null,
|
|
5394
|
+
evidence: last.evidence ?? null,
|
|
3503
5395
|
attemptCount,
|
|
3504
5396
|
expectedMonitorRevision: monitor.revision
|
|
3505
5397
|
});
|
|
@@ -3509,6 +5401,8 @@ class UptimeService {
|
|
|
3509
5401
|
}
|
|
3510
5402
|
}
|
|
3511
5403
|
async checkAll() {
|
|
5404
|
+
if (this.store.mode === "hosted")
|
|
5405
|
+
throw new Error("hosted checks require check_jobs and probes");
|
|
3512
5406
|
const monitors = this.store.listMonitors();
|
|
3513
5407
|
const results = [];
|
|
3514
5408
|
for (const monitor of monitors) {
|
|
@@ -3517,17 +5411,24 @@ class UptimeService {
|
|
|
3517
5411
|
return results;
|
|
3518
5412
|
}
|
|
3519
5413
|
startScheduler(options = {}) {
|
|
5414
|
+
if (this.store.mode === "hosted")
|
|
5415
|
+
throw new Error("hosted scheduler requires check_jobs and probes");
|
|
3520
5416
|
const tickMs = options.tickMs ?? 1000;
|
|
3521
5417
|
const timer = setInterval(() => {
|
|
3522
5418
|
this.runDueChecks().catch((error) => {
|
|
3523
5419
|
console.error(error instanceof Error ? error.message : String(error));
|
|
3524
5420
|
});
|
|
5421
|
+
this.runDueReportSchedules(new Date, { fetchImpl: options.reportFetchImpl }).catch((error) => {
|
|
5422
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
5423
|
+
});
|
|
3525
5424
|
}, tickMs);
|
|
3526
5425
|
return {
|
|
3527
5426
|
stop: () => clearInterval(timer)
|
|
3528
5427
|
};
|
|
3529
5428
|
}
|
|
3530
5429
|
async runDueChecks(now = new Date) {
|
|
5430
|
+
if (this.store.mode === "hosted")
|
|
5431
|
+
throw new Error("hosted checks require check_jobs and probes");
|
|
3531
5432
|
const due = this.store.listMonitors().filter((monitor) => this.isDue(monitor, now));
|
|
3532
5433
|
const results = [];
|
|
3533
5434
|
for (const monitor of due) {
|
|
@@ -3554,6 +5455,147 @@ class UptimeService {
|
|
|
3554
5455
|
const last = new Date(monitor.lastCheckedAt).getTime();
|
|
3555
5456
|
return now.getTime() - last >= monitor.intervalSeconds * 1000;
|
|
3556
5457
|
}
|
|
5458
|
+
probeStore() {
|
|
5459
|
+
if (this.store.mode === "hosted") {
|
|
5460
|
+
throw new Error("hosted probe APIs require cloud check_jobs, workspace stores, and audit logging");
|
|
5461
|
+
}
|
|
5462
|
+
const store = this.store;
|
|
5463
|
+
const required = [
|
|
5464
|
+
"createProbeIdentity",
|
|
5465
|
+
"listProbeIdentities",
|
|
5466
|
+
"getProbeIdentity",
|
|
5467
|
+
"updateProbeIdentity",
|
|
5468
|
+
"touchProbeIdentity",
|
|
5469
|
+
"createProbeCheckJob",
|
|
5470
|
+
"getProbeCheckJob",
|
|
5471
|
+
"claimProbeCheckJob",
|
|
5472
|
+
"completeProbeCheckJob",
|
|
5473
|
+
"getProbeSubmission",
|
|
5474
|
+
"recordProbeSubmission"
|
|
5475
|
+
];
|
|
5476
|
+
for (const method of required) {
|
|
5477
|
+
if (typeof store[method] !== "function") {
|
|
5478
|
+
throw new Error("probe support requires a probe-capable store");
|
|
5479
|
+
}
|
|
5480
|
+
}
|
|
5481
|
+
return store;
|
|
5482
|
+
}
|
|
5483
|
+
reportStore() {
|
|
5484
|
+
if (this.store.mode === "hosted") {
|
|
5485
|
+
throw new Error("hosted report schedules require cloud channel refs, workspace stores, and audit logging");
|
|
5486
|
+
}
|
|
5487
|
+
const store = this.store;
|
|
5488
|
+
const required = [
|
|
5489
|
+
"createReportSchedule",
|
|
5490
|
+
"listReportSchedules",
|
|
5491
|
+
"listDueReportSchedules",
|
|
5492
|
+
"getReportSchedule",
|
|
5493
|
+
"updateReportSchedule",
|
|
5494
|
+
"deleteReportSchedule",
|
|
5495
|
+
"recordReportRun",
|
|
5496
|
+
"listReportRuns",
|
|
5497
|
+
"recordAuditEvent",
|
|
5498
|
+
"listAuditEvents"
|
|
5499
|
+
];
|
|
5500
|
+
for (const method of required) {
|
|
5501
|
+
if (typeof store[method] !== "function") {
|
|
5502
|
+
throw new Error("report scheduling requires a report-capable store");
|
|
5503
|
+
}
|
|
5504
|
+
}
|
|
5505
|
+
return store;
|
|
5506
|
+
}
|
|
5507
|
+
audit(action, resourceType, resourceId, message, metadata) {
|
|
5508
|
+
this.reportStore().recordAuditEvent({
|
|
5509
|
+
action,
|
|
5510
|
+
resourceType,
|
|
5511
|
+
resourceId,
|
|
5512
|
+
message,
|
|
5513
|
+
metadata,
|
|
5514
|
+
actor: "local"
|
|
5515
|
+
});
|
|
5516
|
+
}
|
|
5517
|
+
submitProbeResultInTransaction(input) {
|
|
5518
|
+
const store = this.probeStore();
|
|
5519
|
+
const probe = store.getProbeIdentity(input.probeId);
|
|
5520
|
+
if (!probe)
|
|
5521
|
+
throw new Error(`Probe not found: ${input.probeId}`);
|
|
5522
|
+
if (!probe.enabled)
|
|
5523
|
+
throw new Error(`Probe is disabled: ${probe.name}`);
|
|
5524
|
+
const monitor = this.store.getMonitor(input.monitorId);
|
|
5525
|
+
if (!monitor)
|
|
5526
|
+
throw new Error(`Monitor not found: ${input.monitorId}`);
|
|
5527
|
+
if (!monitor.enabled)
|
|
5528
|
+
throw new Error(`Monitor is disabled: ${monitor.name}`);
|
|
5529
|
+
if (probe.id !== input.probeId)
|
|
5530
|
+
throw new Error("Probe result must use canonical probe id");
|
|
5531
|
+
if (monitor.id !== input.monitorId)
|
|
5532
|
+
throw new Error("Probe result must use canonical monitor id");
|
|
5533
|
+
validateProbeSubmission(input);
|
|
5534
|
+
const job = store.getProbeCheckJob(input.jobId);
|
|
5535
|
+
if (!job)
|
|
5536
|
+
throw new Error(`Probe job not found: ${input.jobId}`);
|
|
5537
|
+
if (job.monitorId !== monitor.id)
|
|
5538
|
+
throw new Error("Probe job does not match monitor");
|
|
5539
|
+
if (job.scheduleSlot !== input.scheduleSlot)
|
|
5540
|
+
throw new Error("Probe job scheduleSlot does not match submission");
|
|
5541
|
+
if (!verifyProbeResultSignature({ ...input, probeId: probe.id, monitorId: monitor.id }, probe.publicKeyPem)) {
|
|
5542
|
+
throw new Error("Probe result signature is invalid");
|
|
5543
|
+
}
|
|
5544
|
+
const existingReceipt = store.getProbeSubmission(probe.id, input.nonce);
|
|
5545
|
+
if (existingReceipt) {
|
|
5546
|
+
if (existingReceipt.jobId !== input.jobId || existingReceipt.monitorId !== monitor.id || existingReceipt.checkedAt !== input.checkedAt) {
|
|
5547
|
+
throw new Error("Probe nonce already submitted");
|
|
5548
|
+
}
|
|
5549
|
+
const existingResult = this.store.getCheckResult?.(existingReceipt.checkResultId);
|
|
5550
|
+
if (!existingResult)
|
|
5551
|
+
throw new Error("Probe nonce already submitted");
|
|
5552
|
+
return { result: existingResult, receipt: existingReceipt };
|
|
5553
|
+
}
|
|
5554
|
+
if (job.monitorRevision !== input.monitorRevision)
|
|
5555
|
+
throw new Error("Probe job monitorRevision does not match submission");
|
|
5556
|
+
if (job.monitorRevision !== monitor.revision)
|
|
5557
|
+
throw new StaleCheckResultError(`Monitor changed since probe job was created: ${monitor.name}`);
|
|
5558
|
+
if (job.status === "submitted")
|
|
5559
|
+
throw new Error("Probe job already submitted");
|
|
5560
|
+
if (job.status === "cancelled")
|
|
5561
|
+
throw new Error("Probe job is cancelled");
|
|
5562
|
+
if (job.status !== "claimed")
|
|
5563
|
+
throw new Error(`Probe job is not claimable for submission: ${job.status}`);
|
|
5564
|
+
if (job.claimedByProbeId !== probe.id)
|
|
5565
|
+
throw new Error("Probe job was claimed by another probe");
|
|
5566
|
+
if (job.fencingToken !== input.fencingToken)
|
|
5567
|
+
throw new Error("Probe job fencing token is invalid");
|
|
5568
|
+
if (!job.leaseExpiresAt || job.leaseExpiresAt <= new Date().toISOString())
|
|
5569
|
+
throw new Error("Probe job lease expired");
|
|
5570
|
+
const result = this.store.recordCheckResult({
|
|
5571
|
+
monitorId: monitor.id,
|
|
5572
|
+
checkedAt: input.checkedAt,
|
|
5573
|
+
status: input.status,
|
|
5574
|
+
latencyMs: input.latencyMs,
|
|
5575
|
+
statusCode: input.statusCode ?? null,
|
|
5576
|
+
error: input.error ?? null,
|
|
5577
|
+
evidence: input.evidence ?? null,
|
|
5578
|
+
attemptCount: input.attemptCount ?? 1,
|
|
5579
|
+
expectedMonitorRevision: input.monitorRevision
|
|
5580
|
+
});
|
|
5581
|
+
const receipt = store.recordProbeSubmission({
|
|
5582
|
+
probeId: probe.id,
|
|
5583
|
+
jobId: job.id,
|
|
5584
|
+
monitorId: monitor.id,
|
|
5585
|
+
checkResultId: result.id,
|
|
5586
|
+
nonce: input.nonce,
|
|
5587
|
+
checkedAt: input.checkedAt
|
|
5588
|
+
});
|
|
5589
|
+
store.completeProbeCheckJob({
|
|
5590
|
+
jobId: job.id,
|
|
5591
|
+
probeId: probe.id,
|
|
5592
|
+
fencingToken: input.fencingToken,
|
|
5593
|
+
checkResultId: result.id,
|
|
5594
|
+
submittedAt: receipt.submittedAt
|
|
5595
|
+
});
|
|
5596
|
+
store.touchProbeIdentity(probe.id, receipt.submittedAt);
|
|
5597
|
+
return { result, receipt };
|
|
5598
|
+
}
|
|
3557
5599
|
}
|
|
3558
5600
|
class MonitorCheckBusyError extends Error {
|
|
3559
5601
|
constructor(message) {
|
|
@@ -3561,16 +5603,71 @@ class MonitorCheckBusyError extends Error {
|
|
|
3561
5603
|
this.name = "MonitorCheckBusyError";
|
|
3562
5604
|
}
|
|
3563
5605
|
}
|
|
5606
|
+
function enabledReportChannels(schedule) {
|
|
5607
|
+
return ["email", "sms", "logs"].filter((channel) => Boolean(schedule.channels[channel]));
|
|
5608
|
+
}
|
|
5609
|
+
function validateProbeSubmission(input) {
|
|
5610
|
+
if (!input.jobId.trim())
|
|
5611
|
+
throw new Error("Probe submission jobId is required");
|
|
5612
|
+
if (!input.scheduleSlot.trim())
|
|
5613
|
+
throw new Error("Probe submission scheduleSlot is required");
|
|
5614
|
+
if (!input.fencingToken.trim())
|
|
5615
|
+
throw new Error("Probe submission fencingToken is required");
|
|
5616
|
+
if (!input.nonce.trim())
|
|
5617
|
+
throw new Error("Probe submission nonce is required");
|
|
5618
|
+
if (input.nonce.length > 128)
|
|
5619
|
+
throw new Error("Probe submission nonce is too long");
|
|
5620
|
+
if (/[\x00-\x1f\x7f-\x9f]/.test(input.nonce))
|
|
5621
|
+
throw new Error("Probe submission nonce must not contain control characters");
|
|
5622
|
+
if (input.status !== "up" && input.status !== "down")
|
|
5623
|
+
throw new Error("Probe result status must be up or down");
|
|
5624
|
+
if (input.latencyMs !== null && (!Number.isFinite(input.latencyMs) || input.latencyMs < 0)) {
|
|
5625
|
+
throw new Error("Probe result latencyMs must be null or a non-negative number");
|
|
5626
|
+
}
|
|
5627
|
+
if (input.statusCode !== undefined && input.statusCode !== null && (!Number.isInteger(input.statusCode) || input.statusCode < 100 || input.statusCode > 599)) {
|
|
5628
|
+
throw new Error("Probe result statusCode must be an HTTP status from 100 to 599");
|
|
5629
|
+
}
|
|
5630
|
+
if (input.attemptCount !== undefined && (!Number.isInteger(input.attemptCount) || input.attemptCount < 1 || input.attemptCount > 20)) {
|
|
5631
|
+
throw new Error("Probe result attemptCount must be an integer from 1 to 20");
|
|
5632
|
+
}
|
|
5633
|
+
const monitorRevision = input.monitorRevision;
|
|
5634
|
+
if (!Number.isInteger(monitorRevision) || monitorRevision < 1) {
|
|
5635
|
+
throw new Error("Probe result monitorRevision is required");
|
|
5636
|
+
}
|
|
5637
|
+
const checkedAtMs = Date.parse(input.checkedAt);
|
|
5638
|
+
if (!Number.isFinite(checkedAtMs))
|
|
5639
|
+
throw new Error("Probe result checkedAt must be an ISO timestamp");
|
|
5640
|
+
const now = Date.now();
|
|
5641
|
+
if (checkedAtMs > now + MAX_PROBE_RESULT_FUTURE_MS)
|
|
5642
|
+
throw new Error("Probe result checkedAt is too far in the future");
|
|
5643
|
+
if (checkedAtMs < now - MAX_PROBE_RESULT_AGE_MS)
|
|
5644
|
+
throw new Error("Probe result checkedAt is too old");
|
|
5645
|
+
if (!input.signature.trim())
|
|
5646
|
+
throw new Error("Probe result signature is required");
|
|
5647
|
+
}
|
|
5648
|
+
function normalizeProbePublicKeyPem(publicKeyPem) {
|
|
5649
|
+
try {
|
|
5650
|
+
const key = createPublicKey(publicKeyPem);
|
|
5651
|
+
if (key.asymmetricKeyType !== "ed25519") {
|
|
5652
|
+
throw new Error("Probe public key must be an Ed25519 public key");
|
|
5653
|
+
}
|
|
5654
|
+
return key.export({ format: "pem", type: "spki" }).toString();
|
|
5655
|
+
} catch (error) {
|
|
5656
|
+
if (error instanceof Error && error.message.includes("Ed25519"))
|
|
5657
|
+
throw error;
|
|
5658
|
+
throw new Error("Probe public key must be a valid PEM Ed25519 public key");
|
|
5659
|
+
}
|
|
5660
|
+
}
|
|
3564
5661
|
|
|
3565
5662
|
// src/version.ts
|
|
3566
5663
|
import { readFileSync } from "fs";
|
|
3567
|
-
import { dirname as dirname2, join as
|
|
5664
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
3568
5665
|
import { fileURLToPath } from "url";
|
|
3569
5666
|
function packageVersion() {
|
|
3570
5667
|
const here = dirname2(fileURLToPath(import.meta.url));
|
|
3571
5668
|
const candidates = [
|
|
3572
|
-
|
|
3573
|
-
|
|
5669
|
+
join3(here, "..", "package.json"),
|
|
5670
|
+
join3(here, "..", "..", "package.json")
|
|
3574
5671
|
];
|
|
3575
5672
|
for (const candidate of candidates) {
|
|
3576
5673
|
try {
|
|
@@ -3580,6 +5677,9 @@ function packageVersion() {
|
|
|
3580
5677
|
return "0.0.0";
|
|
3581
5678
|
}
|
|
3582
5679
|
|
|
5680
|
+
// src/api.ts
|
|
5681
|
+
import { timingSafeEqual } from "crypto";
|
|
5682
|
+
|
|
3583
5683
|
// src/dashboard.ts
|
|
3584
5684
|
function dashboardHtml() {
|
|
3585
5685
|
return `<!doctype html>
|
|
@@ -3796,7 +5896,7 @@ function dashboardHtml() {
|
|
|
3796
5896
|
clear(root);
|
|
3797
5897
|
for (const item of summary.monitors) {
|
|
3798
5898
|
const m = item.monitor;
|
|
3799
|
-
const target = m.kind === '
|
|
5899
|
+
const target = m.kind === 'tcp' ? m.host + ':' + m.port : m.url;
|
|
3800
5900
|
const incident = item.openIncident ? 'open since ' + new Date(item.openIncident.openedAt).toLocaleString() : '-';
|
|
3801
5901
|
const tr = document.createElement('tr');
|
|
3802
5902
|
const name = document.createElement('td');
|
|
@@ -3932,82 +6032,54 @@ function dashboardHtml() {
|
|
|
3932
6032
|
|
|
3933
6033
|
// src/api.ts
|
|
3934
6034
|
function createApiHandler(service, options = {}) {
|
|
6035
|
+
const mode = options.mode ? resolveRuntimeMode(options.mode) : service.store.mode;
|
|
6036
|
+
if (mode !== service.store.mode) {
|
|
6037
|
+
throw new Error(`API mode ${mode} does not match store mode ${service.store.mode}`);
|
|
6038
|
+
}
|
|
3935
6039
|
return async (request) => {
|
|
3936
6040
|
const url = new URL(request.url);
|
|
3937
6041
|
try {
|
|
3938
|
-
|
|
6042
|
+
if (request.method === "GET" && url.pathname === "/health") {
|
|
6043
|
+
return json({ ok: true, service: "uptime", mode, dataMode: service.store.dataMode });
|
|
6044
|
+
}
|
|
6045
|
+
if (mode === "hosted") {
|
|
6046
|
+
return await handleHostedRequest(service, request, url, options);
|
|
6047
|
+
} else {
|
|
6048
|
+
validateLocalMutationRequest(request, url, options);
|
|
6049
|
+
}
|
|
3939
6050
|
if (request.method === "GET" && url.pathname === "/") {
|
|
3940
6051
|
return html(dashboardHtml());
|
|
3941
6052
|
}
|
|
3942
|
-
|
|
3943
|
-
return json({ ok: true, service: "uptime" });
|
|
3944
|
-
}
|
|
3945
|
-
if (request.method === "GET" && url.pathname === "/api/summary") {
|
|
3946
|
-
return json(service.summary());
|
|
3947
|
-
}
|
|
3948
|
-
if (request.method === "GET" && url.pathname === "/api/report") {
|
|
3949
|
-
return json(service.buildReport());
|
|
3950
|
-
}
|
|
3951
|
-
if (request.method === "POST" && url.pathname === "/api/report") {
|
|
3952
|
-
const input = await jsonBody(request);
|
|
3953
|
-
return json(await service.sendReport({ ...input, fetchImpl: options.fetchImpl }));
|
|
3954
|
-
}
|
|
3955
|
-
if (request.method === "GET" && url.pathname === "/api/monitors") {
|
|
3956
|
-
return json(service.listMonitors({ includeDisabled: url.searchParams.get("includeDisabled") === "true" }));
|
|
3957
|
-
}
|
|
3958
|
-
if (request.method === "POST" && url.pathname === "/api/monitors") {
|
|
3959
|
-
return json(service.createMonitor(await jsonBody(request)), 201);
|
|
3960
|
-
}
|
|
3961
|
-
if (request.method === "GET" && url.pathname === "/api/incidents") {
|
|
3962
|
-
const status = url.searchParams.get("status");
|
|
3963
|
-
return json(service.listIncidents({
|
|
3964
|
-
status: status === "open" || status === "closed" ? status : undefined,
|
|
3965
|
-
monitorId: url.searchParams.get("monitorId") ?? undefined,
|
|
3966
|
-
limit: numericParam(url, "limit", 50)
|
|
3967
|
-
}));
|
|
3968
|
-
}
|
|
3969
|
-
if (request.method === "GET" && url.pathname === "/api/results") {
|
|
3970
|
-
return json(service.listResults({
|
|
3971
|
-
monitorId: url.searchParams.get("monitorId") ?? undefined,
|
|
3972
|
-
limit: numericParam(url, "limit", 50)
|
|
3973
|
-
}));
|
|
3974
|
-
}
|
|
3975
|
-
if (request.method === "POST" && url.pathname === "/api/check-all") {
|
|
3976
|
-
return json(await service.checkAll());
|
|
3977
|
-
}
|
|
3978
|
-
const monitorMatch = url.pathname.match(/^\/api\/monitors\/([^/]+)(?:\/(check))?$/);
|
|
3979
|
-
if (monitorMatch) {
|
|
3980
|
-
const id = decodeURIComponent(monitorMatch[1]);
|
|
3981
|
-
if (request.method === "GET" && !monitorMatch[2]) {
|
|
3982
|
-
const monitor = service.getMonitor(id);
|
|
3983
|
-
return monitor ? json(monitor) : json({ error: "not found" }, 404);
|
|
3984
|
-
}
|
|
3985
|
-
if (request.method === "PATCH" && !monitorMatch[2]) {
|
|
3986
|
-
return json(service.updateMonitor(id, await jsonBody(request)));
|
|
3987
|
-
}
|
|
3988
|
-
if (request.method === "DELETE" && !monitorMatch[2]) {
|
|
3989
|
-
return json({ deleted: service.deleteMonitor(id) });
|
|
3990
|
-
}
|
|
3991
|
-
if (request.method === "POST" && monitorMatch[2] === "check") {
|
|
3992
|
-
return json(await service.checkMonitor(id));
|
|
3993
|
-
}
|
|
3994
|
-
}
|
|
3995
|
-
return json({ error: "not found" }, 404);
|
|
6053
|
+
return await handleApiRoute(service, request, url, url.pathname, options, false);
|
|
3996
6054
|
} catch (error) {
|
|
3997
6055
|
return json({ error: error instanceof Error ? error.message : String(error) }, error instanceof ApiError ? error.status : 400);
|
|
3998
6056
|
}
|
|
3999
6057
|
};
|
|
4000
6058
|
}
|
|
4001
6059
|
function serveUptime(options = {}) {
|
|
6060
|
+
const requestedMode = options.mode ? resolveRuntimeMode(options.mode) : options.service?.store.mode ?? "local";
|
|
6061
|
+
if (requestedMode === "hosted" && resolveHostedTokens(options).length === 0) {
|
|
6062
|
+
throw new Error("hosted mode requires HASNA_UPTIME_HOSTED_TOKEN or --hosted-token");
|
|
6063
|
+
}
|
|
4002
6064
|
const service = options.service ?? new UptimeService(options);
|
|
6065
|
+
const mode = service.store.mode;
|
|
6066
|
+
if (mode !== requestedMode) {
|
|
6067
|
+
throw new Error(`serve mode ${requestedMode} does not match store mode ${mode}`);
|
|
6068
|
+
}
|
|
6069
|
+
if (mode === "hosted" && options.check) {
|
|
6070
|
+
throw new Error("hosted scheduler requires check_jobs and probes");
|
|
6071
|
+
}
|
|
4003
6072
|
const scheduler = options.check ? service.startScheduler() : undefined;
|
|
4004
6073
|
const server = Bun.serve({
|
|
4005
6074
|
hostname: options.host ?? "127.0.0.1",
|
|
4006
6075
|
port: options.port ?? 3899,
|
|
4007
6076
|
fetch: createApiHandler(service, {
|
|
4008
6077
|
apiToken: options.apiToken,
|
|
6078
|
+
hostedToken: options.hostedToken,
|
|
6079
|
+
hostedTokens: options.hostedTokens,
|
|
4009
6080
|
allowUnsafeRemoteMutations: options.allowUnsafeRemoteMutations,
|
|
4010
|
-
trustedLoopback: isLoopbackHost(options.host ?? "127.0.0.1")
|
|
6081
|
+
trustedLoopback: isLoopbackHost(options.host ?? "127.0.0.1"),
|
|
6082
|
+
mode
|
|
4011
6083
|
})
|
|
4012
6084
|
});
|
|
4013
6085
|
return { server, service, scheduler };
|
|
@@ -4039,7 +6111,7 @@ function numericParam(url, name, fallback) {
|
|
|
4039
6111
|
function validateLocalMutationRequest(request, url, options) {
|
|
4040
6112
|
if (!["POST", "PATCH", "DELETE"].includes(request.method))
|
|
4041
6113
|
return;
|
|
4042
|
-
const apiToken = options.apiToken
|
|
6114
|
+
const apiToken = resolveApiToken(options.apiToken);
|
|
4043
6115
|
const hasToken = apiToken ? hasValidApiToken(request, apiToken) : false;
|
|
4044
6116
|
const allowUnsafeRemote = options.allowUnsafeRemoteMutations || process.env.HASNA_UPTIME_ALLOW_REMOTE_MUTATIONS === "1";
|
|
4045
6117
|
const trustedLoopback = options.trustedLoopback ?? isLoopbackHost(url.hostname);
|
|
@@ -4051,15 +6123,252 @@ function validateLocalMutationRequest(request, url, options) {
|
|
|
4051
6123
|
throw new ApiError("cross-origin mutation rejected", 403);
|
|
4052
6124
|
}
|
|
4053
6125
|
}
|
|
6126
|
+
async function handleHostedRequest(service, request, url, options) {
|
|
6127
|
+
if (url.pathname === "/") {
|
|
6128
|
+
requireHostedActor(request, url, options, "uptime:read");
|
|
6129
|
+
throw new ApiError("hosted dashboard requires the cloud dashboard shell", 501);
|
|
6130
|
+
}
|
|
6131
|
+
if (!url.pathname.startsWith("/api/v1/")) {
|
|
6132
|
+
requireHostedActor(request, url, options, "uptime:read");
|
|
6133
|
+
return json({ error: "not found" }, 404);
|
|
6134
|
+
}
|
|
6135
|
+
const apiPath = `/api${url.pathname.slice("/api/v1".length)}`;
|
|
6136
|
+
const scope = hostedScopeFor(request.method, apiPath);
|
|
6137
|
+
requireHostedActor(request, url, options, scope);
|
|
6138
|
+
if (["POST", "PATCH", "DELETE"].includes(request.method)) {
|
|
6139
|
+
const origin = request.headers.get("origin");
|
|
6140
|
+
if (origin && origin !== `${url.protocol}//${url.host}`) {
|
|
6141
|
+
throw new ApiError("cross-origin mutation rejected", 403);
|
|
6142
|
+
}
|
|
6143
|
+
}
|
|
6144
|
+
return handleApiRoute(service, request, url, apiPath, options, true);
|
|
6145
|
+
}
|
|
6146
|
+
async function handleApiRoute(service, request, url, apiPath, options, hosted) {
|
|
6147
|
+
if (request.method === "GET" && apiPath === "/api/summary") {
|
|
6148
|
+
return json(service.summary());
|
|
6149
|
+
}
|
|
6150
|
+
if (request.method === "GET" && apiPath === "/api/report") {
|
|
6151
|
+
return json(service.buildReport());
|
|
6152
|
+
}
|
|
6153
|
+
if (request.method === "POST" && apiPath === "/api/report") {
|
|
6154
|
+
if (hosted)
|
|
6155
|
+
throw new ApiError("hosted report delivery requires configured channel refs", 501);
|
|
6156
|
+
const input = await jsonBody(request);
|
|
6157
|
+
return json(await service.sendReport({ ...input, fetchImpl: options.fetchImpl }));
|
|
6158
|
+
}
|
|
6159
|
+
if (hosted && (apiPath.startsWith("/api/report-schedules") || apiPath.startsWith("/api/report-runs") || apiPath.startsWith("/api/audit-events"))) {
|
|
6160
|
+
throw new ApiError("hosted report schedules require cloud channel refs, workspace stores, and audit logging", 501);
|
|
6161
|
+
}
|
|
6162
|
+
if (hosted && apiPath.startsWith("/api/probes")) {
|
|
6163
|
+
throw new ApiError("hosted probe APIs require cloud check_jobs, workspace stores, and audit logging", 501);
|
|
6164
|
+
}
|
|
6165
|
+
if (request.method === "GET" && apiPath === "/api/report-schedules") {
|
|
6166
|
+
return json(service.listReportSchedules({ includeDisabled: url.searchParams.get("includeDisabled") === "true" }));
|
|
6167
|
+
}
|
|
6168
|
+
if (request.method === "POST" && apiPath === "/api/report-schedules") {
|
|
6169
|
+
return json(service.createReportSchedule(await jsonBody(request)), 201);
|
|
6170
|
+
}
|
|
6171
|
+
if (request.method === "POST" && apiPath === "/api/report-schedules/run-due") {
|
|
6172
|
+
const input = await jsonBody(request);
|
|
6173
|
+
const now = input.now ? new Date(input.now) : new Date;
|
|
6174
|
+
return json(await service.runDueReportSchedules(now, { fetchImpl: options.fetchImpl }));
|
|
6175
|
+
}
|
|
6176
|
+
const reportScheduleRunMatch = apiPath.match(/^\/api\/report-schedules\/([^/]+)\/run$/);
|
|
6177
|
+
if (request.method === "POST" && reportScheduleRunMatch) {
|
|
6178
|
+
return json(await service.runReportSchedule(decodeURIComponent(reportScheduleRunMatch[1]), { fetchImpl: options.fetchImpl }));
|
|
6179
|
+
}
|
|
6180
|
+
const reportScheduleMatch = apiPath.match(/^\/api\/report-schedules\/([^/]+)$/);
|
|
6181
|
+
if (reportScheduleMatch) {
|
|
6182
|
+
const id = decodeURIComponent(reportScheduleMatch[1]);
|
|
6183
|
+
if (request.method === "GET") {
|
|
6184
|
+
const schedule = service.getReportSchedule(id);
|
|
6185
|
+
return schedule ? json(schedule) : json({ error: "not found" }, 404);
|
|
6186
|
+
}
|
|
6187
|
+
if (request.method === "PATCH") {
|
|
6188
|
+
return json(service.updateReportSchedule(id, await jsonBody(request)));
|
|
6189
|
+
}
|
|
6190
|
+
if (request.method === "DELETE") {
|
|
6191
|
+
return json({ deleted: service.deleteReportSchedule(id) });
|
|
6192
|
+
}
|
|
6193
|
+
}
|
|
6194
|
+
if (request.method === "GET" && apiPath === "/api/report-runs") {
|
|
6195
|
+
return json(service.listReportRuns({
|
|
6196
|
+
scheduleId: url.searchParams.get("scheduleId") ?? undefined,
|
|
6197
|
+
limit: numericParam(url, "limit", 50)
|
|
6198
|
+
}));
|
|
6199
|
+
}
|
|
6200
|
+
if (request.method === "GET" && apiPath === "/api/audit-events") {
|
|
6201
|
+
return json(service.listAuditEvents({
|
|
6202
|
+
resourceType: url.searchParams.get("resourceType") ?? undefined,
|
|
6203
|
+
resourceId: url.searchParams.get("resourceId") ?? undefined,
|
|
6204
|
+
limit: numericParam(url, "limit", 50)
|
|
6205
|
+
}));
|
|
6206
|
+
}
|
|
6207
|
+
if (request.method === "GET" && apiPath === "/api/monitors") {
|
|
6208
|
+
return json(service.listMonitors({ includeDisabled: url.searchParams.get("includeDisabled") === "true" }));
|
|
6209
|
+
}
|
|
6210
|
+
if (request.method === "POST" && apiPath === "/api/monitors") {
|
|
6211
|
+
return json(service.createMonitor(await jsonBody(request)), 201);
|
|
6212
|
+
}
|
|
6213
|
+
if (request.method === "GET" && apiPath === "/api/incidents") {
|
|
6214
|
+
const status = url.searchParams.get("status");
|
|
6215
|
+
return json(service.listIncidents({
|
|
6216
|
+
status: status === "open" || status === "closed" ? status : undefined,
|
|
6217
|
+
monitorId: url.searchParams.get("monitorId") ?? undefined,
|
|
6218
|
+
limit: numericParam(url, "limit", 50)
|
|
6219
|
+
}));
|
|
6220
|
+
}
|
|
6221
|
+
if (request.method === "GET" && apiPath === "/api/results") {
|
|
6222
|
+
return json(service.listResults({
|
|
6223
|
+
monitorId: url.searchParams.get("monitorId") ?? undefined,
|
|
6224
|
+
limit: numericParam(url, "limit", 50)
|
|
6225
|
+
}));
|
|
6226
|
+
}
|
|
6227
|
+
if (request.method === "GET" && apiPath === "/api/probes") {
|
|
6228
|
+
return json(service.listProbes({ includeDisabled: url.searchParams.get("includeDisabled") === "true" }));
|
|
6229
|
+
}
|
|
6230
|
+
if (request.method === "POST" && apiPath === "/api/probes") {
|
|
6231
|
+
const input = await jsonBody(request);
|
|
6232
|
+
if (!input.publicKeyPem)
|
|
6233
|
+
throw new ApiError("API probe creation requires publicKeyPem; generate keys in the probe agent or CLI", 400);
|
|
6234
|
+
return json(service.createProbe(input), 201);
|
|
6235
|
+
}
|
|
6236
|
+
if (request.method === "POST" && apiPath === "/api/probes/jobs") {
|
|
6237
|
+
return json(service.createProbeCheckJob(await jsonBody(request)), 201);
|
|
6238
|
+
}
|
|
6239
|
+
const probeJobMatch = apiPath.match(/^\/api\/probes\/jobs\/([^/]+)$/);
|
|
6240
|
+
if (probeJobMatch) {
|
|
6241
|
+
const jobId = decodeURIComponent(probeJobMatch[1]);
|
|
6242
|
+
if (request.method === "GET") {
|
|
6243
|
+
const job = service.getProbeCheckJob(jobId);
|
|
6244
|
+
return job ? json({ ...job, fencingToken: null }) : json({ error: "not found" }, 404);
|
|
6245
|
+
}
|
|
6246
|
+
}
|
|
6247
|
+
const probeJobClaimMatch = apiPath.match(/^\/api\/probes\/jobs\/([^/]+)\/claim$/);
|
|
6248
|
+
if (request.method === "POST" && probeJobClaimMatch) {
|
|
6249
|
+
const input = await jsonBody(request);
|
|
6250
|
+
return json(service.claimProbeCheckJob({
|
|
6251
|
+
jobId: decodeURIComponent(probeJobClaimMatch[1]),
|
|
6252
|
+
probeId: input.probeId,
|
|
6253
|
+
leaseTtlMs: input.leaseTtlMs
|
|
6254
|
+
}));
|
|
6255
|
+
}
|
|
6256
|
+
if (request.method === "POST" && apiPath === "/api/probes/results") {
|
|
6257
|
+
return json(service.submitProbeResult(await jsonBody(request)), 201);
|
|
6258
|
+
}
|
|
6259
|
+
if (request.method === "POST" && apiPath === "/api/imports/preview") {
|
|
6260
|
+
return json(service.previewImport(await jsonBody(request)));
|
|
6261
|
+
}
|
|
6262
|
+
if (request.method === "POST" && apiPath === "/api/imports/apply") {
|
|
6263
|
+
if (hosted)
|
|
6264
|
+
throw new ApiError("hosted import apply requires cloud import_batches and audit", 501);
|
|
6265
|
+
return json(service.applyImport(await jsonBody(request)), 201);
|
|
6266
|
+
}
|
|
6267
|
+
const importRollbackMatch = apiPath.match(/^\/api\/imports\/([^/]+)\/rollback$/);
|
|
6268
|
+
if (request.method === "POST" && importRollbackMatch) {
|
|
6269
|
+
if (hosted)
|
|
6270
|
+
throw new ApiError("hosted import rollback requires cloud import_batches and audit", 501);
|
|
6271
|
+
return json(service.rollbackImport(decodeURIComponent(importRollbackMatch[1])));
|
|
6272
|
+
}
|
|
6273
|
+
if (request.method === "POST" && apiPath === "/api/check-all") {
|
|
6274
|
+
if (hosted)
|
|
6275
|
+
throw new ApiError("hosted checks require check_jobs and probes", 501);
|
|
6276
|
+
return json(await service.checkAll());
|
|
6277
|
+
}
|
|
6278
|
+
const monitorMatch = apiPath.match(/^\/api\/monitors\/([^/]+)(?:\/(check))?$/);
|
|
6279
|
+
if (monitorMatch) {
|
|
6280
|
+
const id = decodeURIComponent(monitorMatch[1]);
|
|
6281
|
+
if (request.method === "GET" && !monitorMatch[2]) {
|
|
6282
|
+
const monitor = service.getMonitor(id);
|
|
6283
|
+
return monitor ? json(monitor) : json({ error: "not found" }, 404);
|
|
6284
|
+
}
|
|
6285
|
+
if (request.method === "PATCH" && !monitorMatch[2]) {
|
|
6286
|
+
return json(service.updateMonitor(id, await jsonBody(request)));
|
|
6287
|
+
}
|
|
6288
|
+
if (request.method === "DELETE" && !monitorMatch[2]) {
|
|
6289
|
+
return json({ deleted: service.deleteMonitor(id) });
|
|
6290
|
+
}
|
|
6291
|
+
if (request.method === "POST" && monitorMatch[2] === "check") {
|
|
6292
|
+
if (hosted)
|
|
6293
|
+
throw new ApiError("hosted checks require check_jobs and probes", 501);
|
|
6294
|
+
return json(await service.checkMonitor(id));
|
|
6295
|
+
}
|
|
6296
|
+
}
|
|
6297
|
+
return json({ error: "not found" }, 404);
|
|
6298
|
+
}
|
|
6299
|
+
function hostedScopeFor(method, apiPath) {
|
|
6300
|
+
if (method === "POST" && apiPath === "/api/report")
|
|
6301
|
+
return "uptime:report";
|
|
6302
|
+
if (apiPath.startsWith("/api/report-schedules") || apiPath.startsWith("/api/report-runs"))
|
|
6303
|
+
return method === "GET" ? "uptime:read" : "uptime:report";
|
|
6304
|
+
if (apiPath.startsWith("/api/audit-events"))
|
|
6305
|
+
return method === "GET" ? "uptime:read" : "uptime:admin";
|
|
6306
|
+
if (apiPath.startsWith("/api/probes"))
|
|
6307
|
+
return method === "GET" ? "uptime:read" : "uptime:probe";
|
|
6308
|
+
if (method === "POST" && (apiPath === "/api/check-all" || /\/check$/.test(apiPath)))
|
|
6309
|
+
return "uptime:probe";
|
|
6310
|
+
if (method === "GET")
|
|
6311
|
+
return "uptime:read";
|
|
6312
|
+
if (method === "POST" || method === "PATCH" || method === "DELETE")
|
|
6313
|
+
return "uptime:write";
|
|
6314
|
+
return "uptime:read";
|
|
6315
|
+
}
|
|
6316
|
+
function requireHostedActor(request, url, options, scope) {
|
|
6317
|
+
const tokens = resolveHostedTokens(options);
|
|
6318
|
+
if (tokens.length === 0)
|
|
6319
|
+
throw new ApiError("hosted auth token is not configured", 503);
|
|
6320
|
+
const candidate = bearerToken(request) ?? request.headers.get("x-uptime-hosted-token")?.trim();
|
|
6321
|
+
const token = candidate ? tokens.find((entry) => safeTokenEqual(candidate, entry.token)) : undefined;
|
|
6322
|
+
if (!token)
|
|
6323
|
+
throw new ApiError("authentication required", 401);
|
|
6324
|
+
const scopes = new Set(token.scopes);
|
|
6325
|
+
if (!scopes.has(scope) && !scopes.has("uptime:admin")) {
|
|
6326
|
+
throw new ApiError("insufficient scope", 403);
|
|
6327
|
+
}
|
|
6328
|
+
const workspaceId = token.workspaceId ?? "default";
|
|
6329
|
+
const requestedWorkspace = request.headers.get("x-uptime-workspace")?.trim() || url.searchParams.get("workspaceId")?.trim();
|
|
6330
|
+
if (requestedWorkspace && requestedWorkspace !== workspaceId) {
|
|
6331
|
+
throw new ApiError("workspace access denied", 403);
|
|
6332
|
+
}
|
|
6333
|
+
return { scopes, workspaceId };
|
|
6334
|
+
}
|
|
4054
6335
|
function isLoopbackHost(hostname) {
|
|
4055
6336
|
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
4056
6337
|
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
4057
6338
|
}
|
|
4058
6339
|
function hasValidApiToken(request, token) {
|
|
4059
|
-
const
|
|
4060
|
-
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
|
|
6340
|
+
const bearer = bearerToken(request);
|
|
4061
6341
|
const headerToken = request.headers.get("x-uptime-token")?.trim();
|
|
4062
|
-
return bearer
|
|
6342
|
+
return safeTokenEqual(bearer, token) || safeTokenEqual(headerToken, token);
|
|
6343
|
+
}
|
|
6344
|
+
function bearerToken(request) {
|
|
6345
|
+
const authorization = request.headers.get("authorization") ?? "";
|
|
6346
|
+
return authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
|
|
6347
|
+
}
|
|
6348
|
+
function resolveApiToken(token) {
|
|
6349
|
+
const value = token ?? process.env.HASNA_UPTIME_API_TOKEN;
|
|
6350
|
+
return value?.trim() || undefined;
|
|
6351
|
+
}
|
|
6352
|
+
function resolveHostedTokens(options) {
|
|
6353
|
+
if (options.hostedTokens?.length)
|
|
6354
|
+
return options.hostedTokens;
|
|
6355
|
+
const token = options.hostedToken ?? process.env.HASNA_UPTIME_HOSTED_TOKEN;
|
|
6356
|
+
if (!token?.trim())
|
|
6357
|
+
return [];
|
|
6358
|
+
return [{
|
|
6359
|
+
token: token.trim(),
|
|
6360
|
+
scopes: ["uptime:read", "uptime:write", "uptime:probe", "uptime:report"],
|
|
6361
|
+
workspaceId: process.env.HASNA_UPTIME_WORKSPACE_ID ?? "default"
|
|
6362
|
+
}];
|
|
6363
|
+
}
|
|
6364
|
+
function safeTokenEqual(candidate, expected) {
|
|
6365
|
+
if (!candidate)
|
|
6366
|
+
return false;
|
|
6367
|
+
const candidateBytes = Buffer.from(candidate);
|
|
6368
|
+
const expectedBytes = Buffer.from(expected);
|
|
6369
|
+
if (candidateBytes.length !== expectedBytes.length)
|
|
6370
|
+
return false;
|
|
6371
|
+
return timingSafeEqual(candidateBytes, expectedBytes);
|
|
4063
6372
|
}
|
|
4064
6373
|
async function jsonBody(request) {
|
|
4065
6374
|
const contentType = request.headers.get("content-type") ?? "";
|
|
@@ -4082,7 +6391,7 @@ class ApiError extends Error {
|
|
|
4082
6391
|
var program2 = new Command;
|
|
4083
6392
|
program2.name("uptime").description("Local-first uptime and downtime monitoring").version(packageVersion()).option("-j, --json", "print JSON");
|
|
4084
6393
|
function service() {
|
|
4085
|
-
return new UptimeService;
|
|
6394
|
+
return new UptimeService({ mode: "local" });
|
|
4086
6395
|
}
|
|
4087
6396
|
function wantsJson(opts) {
|
|
4088
6397
|
return Boolean(opts?.json || program2.opts().json);
|
|
@@ -4106,7 +6415,7 @@ program2.command("init").description("Initialize the local uptime store").option
|
|
|
4106
6415
|
ensureUptimeHome();
|
|
4107
6416
|
const svc = service();
|
|
4108
6417
|
svc.close();
|
|
4109
|
-
const data = { ok: true, home: uptimeHome(), dbPath: uptimeDbPath(), exists:
|
|
6418
|
+
const data = { ok: true, home: uptimeHome(), dbPath: uptimeDbPath(), exists: existsSync2(uptimeDbPath()) };
|
|
4110
6419
|
print(data, `Initialized ${data.dbPath}`, opts);
|
|
4111
6420
|
} catch (error) {
|
|
4112
6421
|
fail(error);
|
|
@@ -4298,6 +6607,93 @@ program2.command("report").description("Build or send an uptime report through M
|
|
|
4298
6607
|
fail(error);
|
|
4299
6608
|
}
|
|
4300
6609
|
});
|
|
6610
|
+
var reportSchedules = program2.command("report-schedules").alias("schedules").description("Manage scheduled uptime reports");
|
|
6611
|
+
reportSchedules.command("create <name>").description("Create a scheduled uptime report").requiredOption("--interval <seconds>", "report interval in seconds", parseInteger).option("--next-run-at <iso>", "first due timestamp", new Date().toISOString()).option("--subject <subject>", "report subject").option("--email <to>", "email recipients; Mailery send key is read from env at run time").option("--from <email>", "Mailery from address").option("--mailery-url <url>", "Mailery API URL").option("--sms <phone>", "SMS recipients").option("--sms-from <phone>", "Telephony from phone number").option("--telephony-url <url>", "Telephony API URL").option("--logs", "write scheduled report runs to Open Logs").option("--logs-url <url>", "Open Logs API URL").option("--logs-project <id>", "Open Logs project id").option("--disabled", "create the schedule disabled").option("-j, --json", "print JSON").action((name, opts) => {
|
|
6612
|
+
try {
|
|
6613
|
+
const svc = service();
|
|
6614
|
+
const schedule = svc.createReportSchedule({
|
|
6615
|
+
name,
|
|
6616
|
+
intervalSeconds: opts.interval,
|
|
6617
|
+
nextRunAt: opts.nextRunAt,
|
|
6618
|
+
enabled: opts.disabled ? false : true,
|
|
6619
|
+
subject: opts.subject,
|
|
6620
|
+
channels: buildReportScheduleChannels(opts)
|
|
6621
|
+
});
|
|
6622
|
+
svc.close();
|
|
6623
|
+
print(schedule, `Created report schedule ${schedule.name}`, opts);
|
|
6624
|
+
} catch (error) {
|
|
6625
|
+
fail(error);
|
|
6626
|
+
}
|
|
6627
|
+
});
|
|
6628
|
+
reportSchedules.command("list").description("List scheduled uptime reports").option("--all", "include disabled schedules").option("-j, --json", "print JSON").action((opts) => {
|
|
6629
|
+
try {
|
|
6630
|
+
const svc = service();
|
|
6631
|
+
const schedules = svc.listReportSchedules({ includeDisabled: opts.all });
|
|
6632
|
+
svc.close();
|
|
6633
|
+
print(schedules, renderReportSchedules(schedules), opts);
|
|
6634
|
+
} catch (error) {
|
|
6635
|
+
fail(error);
|
|
6636
|
+
}
|
|
6637
|
+
});
|
|
6638
|
+
reportSchedules.command("run <id-or-name>").description("Run one scheduled report now and record a run").option("-j, --json", "print JSON").action(async (idOrName, opts) => {
|
|
6639
|
+
try {
|
|
6640
|
+
const svc = service();
|
|
6641
|
+
const run = await svc.runReportSchedule(idOrName);
|
|
6642
|
+
svc.close();
|
|
6643
|
+
print(run, renderReportRuns([run]), opts);
|
|
6644
|
+
if (run.status === "failed")
|
|
6645
|
+
process.exit(1);
|
|
6646
|
+
} catch (error) {
|
|
6647
|
+
fail(error);
|
|
6648
|
+
}
|
|
6649
|
+
});
|
|
6650
|
+
reportSchedules.command("run-due").description("Run all due scheduled reports and record runs").option("--now <iso>", "due timestamp", new Date().toISOString()).option("-j, --json", "print JSON").action(async (opts) => {
|
|
6651
|
+
try {
|
|
6652
|
+
const svc = service();
|
|
6653
|
+
const runs = await svc.runDueReportSchedules(new Date(opts.now));
|
|
6654
|
+
svc.close();
|
|
6655
|
+
print(runs, renderReportRuns(runs), opts);
|
|
6656
|
+
if (runs.some((run) => run.status === "failed"))
|
|
6657
|
+
process.exit(1);
|
|
6658
|
+
} catch (error) {
|
|
6659
|
+
fail(error);
|
|
6660
|
+
}
|
|
6661
|
+
});
|
|
6662
|
+
reportSchedules.command("delete <id-or-name>").alias("rm").description("Delete a scheduled uptime report").option("-j, --json", "print JSON").action((idOrName, opts) => {
|
|
6663
|
+
try {
|
|
6664
|
+
const svc = service();
|
|
6665
|
+
const deleted = svc.deleteReportSchedule(idOrName);
|
|
6666
|
+
svc.close();
|
|
6667
|
+
print({ deleted }, deleted ? `Deleted report schedule ${idOrName}` : `Not found: ${idOrName}`, opts);
|
|
6668
|
+
} catch (error) {
|
|
6669
|
+
fail(error);
|
|
6670
|
+
}
|
|
6671
|
+
});
|
|
6672
|
+
reportSchedules.command("runs").description("List scheduled report runs").option("--schedule <id>", "filter by report schedule id").option("--limit <n>", "max rows", parseInteger, 20).option("-j, --json", "print JSON").action((opts) => {
|
|
6673
|
+
try {
|
|
6674
|
+
const svc = service();
|
|
6675
|
+
const runs = svc.listReportRuns({ scheduleId: opts.schedule, limit: opts.limit });
|
|
6676
|
+
svc.close();
|
|
6677
|
+
print(runs, renderReportRuns(runs), opts);
|
|
6678
|
+
} catch (error) {
|
|
6679
|
+
fail(error);
|
|
6680
|
+
}
|
|
6681
|
+
});
|
|
6682
|
+
program2.command("audit").description("List local audit events").option("--resource-type <type>", "filter by resource type").option("--resource-id <id>", "filter by resource id").option("--limit <n>", "max rows", parseInteger, 20).option("-j, --json", "print JSON").action((opts) => {
|
|
6683
|
+
try {
|
|
6684
|
+
const svc = service();
|
|
6685
|
+
const events = svc.listAuditEvents({
|
|
6686
|
+
resourceType: opts.resourceType,
|
|
6687
|
+
resourceId: opts.resourceId,
|
|
6688
|
+
limit: opts.limit
|
|
6689
|
+
});
|
|
6690
|
+
svc.close();
|
|
6691
|
+
print(events, events.length ? events.map((event) => `${event.createdAt} ${event.action} ${sanitizeField(event.resourceType ?? "-")} ${sanitizeField(event.resourceId ?? "-")} ${sanitizeField(event.message ?? "")}`).join(`
|
|
6692
|
+
`) : "No audit events", opts);
|
|
6693
|
+
} catch (error) {
|
|
6694
|
+
fail(error);
|
|
6695
|
+
}
|
|
6696
|
+
});
|
|
4301
6697
|
program2.command("results").description("List recent check results").option("--monitor <id>", "filter by monitor id").option("--limit <n>", "max rows", parseInteger, 20).option("-j, --json", "print JSON").action((opts) => {
|
|
4302
6698
|
try {
|
|
4303
6699
|
const svc = service();
|
|
@@ -4319,16 +6715,179 @@ program2.command("incidents").description("List incidents").addOption(new Option
|
|
|
4319
6715
|
fail(error);
|
|
4320
6716
|
}
|
|
4321
6717
|
});
|
|
4322
|
-
program2.command("
|
|
6718
|
+
var imports = program2.command("imports").description("Preview, apply, and rollback inventory imports");
|
|
6719
|
+
imports.command("preview").description("Preview monitor candidates from an import source without writing").requiredOption("--source <source>", "manual, projects, servers, domains, or deployment").option("--record <json>", "one JSON record").option("--file <path>", "JSON file containing an array or { records }").option("-j, --json", "print JSON").action((opts) => {
|
|
6720
|
+
try {
|
|
6721
|
+
const svc = service();
|
|
6722
|
+
const preview = svc.previewImport(parseImportPayload(opts));
|
|
6723
|
+
svc.close();
|
|
6724
|
+
print(preview, renderImportPreview(preview), opts);
|
|
6725
|
+
} catch (error) {
|
|
6726
|
+
fail(error);
|
|
6727
|
+
}
|
|
6728
|
+
});
|
|
6729
|
+
imports.command("apply").description("Apply monitor candidates from an import source idempotently").requiredOption("--source <source>", "manual, projects, servers, domains, or deployment").option("--record <json>", "one JSON record").option("--file <path>", "JSON file containing an array or { records }").option("-j, --json", "print JSON").action((opts) => {
|
|
6730
|
+
try {
|
|
6731
|
+
const svc = service();
|
|
6732
|
+
const result = svc.applyImport(parseImportPayload(opts));
|
|
6733
|
+
svc.close();
|
|
6734
|
+
print(result, `Applied import batch ${result.batchId}: ${renderImportTotals(result.totals)}`, opts);
|
|
6735
|
+
} catch (error) {
|
|
6736
|
+
fail(error);
|
|
6737
|
+
}
|
|
6738
|
+
});
|
|
6739
|
+
imports.command("rollback <batch-id>").description("Rollback config changes from an import batch while preserving check history").option("-j, --json", "print JSON").action((batchId, opts) => {
|
|
6740
|
+
try {
|
|
6741
|
+
const svc = service();
|
|
6742
|
+
const result = svc.rollbackImport(batchId);
|
|
6743
|
+
svc.close();
|
|
6744
|
+
print(result, `Rolled back import batch ${result.batchId}`, opts);
|
|
6745
|
+
} catch (error) {
|
|
6746
|
+
fail(error);
|
|
6747
|
+
}
|
|
6748
|
+
});
|
|
6749
|
+
var probes = program2.command("probes").description("Manage private probe identities and signed probe result submissions");
|
|
6750
|
+
probes.command("create <name>").description("Create a private probe identity; generates an Ed25519 keypair unless --public-key-file is provided").option("--public-key-file <path>", "PEM public key file for an externally managed probe key").option("--private-key-file <path>", "where to write a generated PEM private key; required unless --public-key-file is used").option("--disabled", "create the probe disabled").option("-j, --json", "print JSON").action((name, opts) => {
|
|
6751
|
+
let generatedPrivateKeyFile;
|
|
6752
|
+
let svc;
|
|
6753
|
+
try {
|
|
6754
|
+
if (opts.publicKeyFile && opts.privateKeyFile)
|
|
6755
|
+
throw new Error("Choose either --public-key-file or --private-key-file, not both");
|
|
6756
|
+
if (!opts.publicKeyFile && !opts.privateKeyFile)
|
|
6757
|
+
throw new Error("generated probe keys require --private-key-file");
|
|
6758
|
+
const generatedKeyPair = opts.publicKeyFile ? undefined : generateProbeKeyPair();
|
|
6759
|
+
if (generatedKeyPair) {
|
|
6760
|
+
writeFileSync(opts.privateKeyFile, generatedKeyPair.privateKeyPem, { mode: 384, flag: "wx" });
|
|
6761
|
+
generatedPrivateKeyFile = opts.privateKeyFile;
|
|
6762
|
+
}
|
|
6763
|
+
svc = service();
|
|
6764
|
+
const probe = svc.createProbe({
|
|
6765
|
+
name,
|
|
6766
|
+
publicKeyPem: opts.publicKeyFile ? readFileSync2(opts.publicKeyFile, "utf8") : generatedKeyPair?.publicKeyPem,
|
|
6767
|
+
enabled: opts.disabled ? false : true
|
|
6768
|
+
});
|
|
6769
|
+
svc.close();
|
|
6770
|
+
svc = undefined;
|
|
6771
|
+
const output = generatedPrivateKeyFile ? { ...probe, privateKeyFile: generatedPrivateKeyFile } : probe;
|
|
6772
|
+
print(output, `Created probe ${probe.name} (${probe.id})`, opts);
|
|
6773
|
+
} catch (error) {
|
|
6774
|
+
svc?.close();
|
|
6775
|
+
if (generatedPrivateKeyFile) {
|
|
6776
|
+
try {
|
|
6777
|
+
unlinkSync(generatedPrivateKeyFile);
|
|
6778
|
+
} catch {}
|
|
6779
|
+
}
|
|
6780
|
+
fail(error);
|
|
6781
|
+
}
|
|
6782
|
+
});
|
|
6783
|
+
probes.command("list").description("List private probe identities").option("--all", "include disabled probes").option("-j, --json", "print JSON").action((opts) => {
|
|
6784
|
+
try {
|
|
6785
|
+
const svc = service();
|
|
6786
|
+
const items = svc.listProbes({ includeDisabled: opts.all });
|
|
6787
|
+
svc.close();
|
|
6788
|
+
print(items, items.length ? items.map((item) => `${item.enabled ? "enabled " : "disabled"} ${item.id} ${sanitizeField(item.name)} ${item.lastSeenAt ?? "-"}`).join(`
|
|
6789
|
+
`) : "No probes", opts);
|
|
6790
|
+
} catch (error) {
|
|
6791
|
+
fail(error);
|
|
6792
|
+
}
|
|
6793
|
+
});
|
|
6794
|
+
var probeJobs = probes.command("jobs").description("Create and claim private probe check jobs");
|
|
6795
|
+
probeJobs.command("create").description("Create a probe check job for one monitor and schedule slot").requiredOption("--monitor <id>", "monitor id").requiredOption("--schedule-slot <slot>", "unique schedule slot for this monitor").option("--due-at <iso>", "when the job is due", new Date().toISOString()).option("-j, --json", "print JSON").action((opts) => {
|
|
6796
|
+
try {
|
|
6797
|
+
const svc = service();
|
|
6798
|
+
const job = svc.createProbeCheckJob({
|
|
6799
|
+
monitorId: opts.monitor,
|
|
6800
|
+
scheduleSlot: opts.scheduleSlot,
|
|
6801
|
+
dueAt: opts.dueAt
|
|
6802
|
+
});
|
|
6803
|
+
svc.close();
|
|
6804
|
+
print(job, `Created probe job ${job.id} for ${job.monitorId}`, opts);
|
|
6805
|
+
} catch (error) {
|
|
6806
|
+
fail(error);
|
|
6807
|
+
}
|
|
6808
|
+
});
|
|
6809
|
+
probeJobs.command("claim <job-id>").description("Claim a probe check job and receive its fencing token").requiredOption("--probe <id>", "probe id").option("--lease-ms <ms>", "lease duration in milliseconds", parseInteger, 120000).option("-j, --json", "print JSON").action((jobId, opts) => {
|
|
6810
|
+
try {
|
|
6811
|
+
const svc = service();
|
|
6812
|
+
const job = svc.claimProbeCheckJob({
|
|
6813
|
+
jobId,
|
|
6814
|
+
probeId: opts.probe,
|
|
6815
|
+
leaseTtlMs: opts.leaseMs
|
|
6816
|
+
});
|
|
6817
|
+
svc.close();
|
|
6818
|
+
print(job, `Claimed probe job ${job.id}`, opts);
|
|
6819
|
+
} catch (error) {
|
|
6820
|
+
fail(error);
|
|
6821
|
+
}
|
|
6822
|
+
});
|
|
6823
|
+
probes.command("submit").description("Submit a signed probe result locally or to a remote Open Uptime API").requiredOption("--probe <id>", "probe id").requiredOption("--job <id>", "claimed probe job id").requiredOption("--schedule-slot <slot>", "schedule slot from the claimed job").requiredOption("--fencing-token <token>", "fencing token from the claimed job").requiredOption("--monitor <id>", "monitor id").requiredOption("--private-key-file <path>", "PEM private key file used to sign the result").addOption(new Option("--status <status>", "probe result status").choices(["up", "down"]).makeOptionMandatory()).option("--nonce <nonce>", "unique submission nonce").option("--checked-at <iso>", "check timestamp", new Date().toISOString()).option("--latency <ms>", "latency in milliseconds", parseNumber).option("--status-code <status>", "HTTP status code", parseInteger).option("--error <message>", "failure message").option("--attempts <count>", "attempt count", parseInteger, 1).requiredOption("--monitor-revision <revision>", "monitor revision observed by the probe", parseInteger).option("--api-url <url>", "remote Open Uptime base URL; submits to /api/probes/results unless the URL already ends in /api or /api/v1").option("--token <token>", "Bearer token for the remote hosted API").option("-j, --json", "print JSON").action(async (opts) => {
|
|
6824
|
+
try {
|
|
6825
|
+
const submission = buildProbeSubmission(opts);
|
|
6826
|
+
if (opts.apiUrl) {
|
|
6827
|
+
const response = await fetch(probeSubmitUrl(opts.apiUrl), {
|
|
6828
|
+
method: "POST",
|
|
6829
|
+
headers: {
|
|
6830
|
+
"content-type": "application/json",
|
|
6831
|
+
accept: "application/json",
|
|
6832
|
+
...opts.token ? { authorization: `Bearer ${opts.token}` } : {}
|
|
6833
|
+
},
|
|
6834
|
+
body: JSON.stringify(submission)
|
|
6835
|
+
});
|
|
6836
|
+
const body = await response.json();
|
|
6837
|
+
print(body, response.ok ? `Submitted probe result for ${submission.monitorId}` : JSON.stringify(body), opts);
|
|
6838
|
+
if (!response.ok)
|
|
6839
|
+
process.exit(1);
|
|
6840
|
+
return;
|
|
6841
|
+
}
|
|
6842
|
+
const svc = service();
|
|
6843
|
+
const result = svc.submitProbeResult(submission);
|
|
6844
|
+
svc.close();
|
|
6845
|
+
print(result, `Submitted probe result for ${submission.monitorId}`, opts);
|
|
6846
|
+
} catch (error) {
|
|
6847
|
+
fail(error);
|
|
6848
|
+
}
|
|
6849
|
+
});
|
|
6850
|
+
program2.command("backup [path]").description("Create and verify a local SQLite backup").option("-j, --json", "print JSON").action((path, opts) => {
|
|
6851
|
+
try {
|
|
6852
|
+
const svc = service();
|
|
6853
|
+
const backup = svc.backup(path);
|
|
6854
|
+
const check = svc.verifyBackup(backup.backupPath);
|
|
6855
|
+
svc.close();
|
|
6856
|
+
const data = { ok: check.ok, backup, check };
|
|
6857
|
+
print(data, `Backed up ${backup.sourcePath} to ${backup.backupPath} (${backup.bytes} bytes)`, opts);
|
|
6858
|
+
if (!check.ok)
|
|
6859
|
+
process.exit(1);
|
|
6860
|
+
} catch (error) {
|
|
6861
|
+
fail(error);
|
|
6862
|
+
}
|
|
6863
|
+
});
|
|
6864
|
+
program2.command("restore <backup-path>").description("Restore a verified local SQLite backup").option("--db <path>", "destination database path", uptimeDbPath()).option("--yes", "confirm overwrite of the destination database").option("-j, --json", "print JSON").action((backupPath, opts) => {
|
|
6865
|
+
try {
|
|
6866
|
+
if (!opts.yes)
|
|
6867
|
+
throw new Error("restore requires --yes");
|
|
6868
|
+
const restored = UptimeStore.restoreBackup(backupPath, opts.db);
|
|
6869
|
+
const check = UptimeStore.verifyBackup(opts.db);
|
|
6870
|
+
const data = { ok: check.ok, restored, check };
|
|
6871
|
+
print(data, `Restored ${backupPath} to ${opts.db}`, opts);
|
|
6872
|
+
if (!check.ok)
|
|
6873
|
+
process.exit(1);
|
|
6874
|
+
} catch (error) {
|
|
6875
|
+
fail(error);
|
|
6876
|
+
}
|
|
6877
|
+
});
|
|
6878
|
+
program2.command("serve").description("Serve the local API and dashboard").option("--host <host>", "host to bind", "127.0.0.1").option("--port <port>", "port", parseInteger, 3899).option("--check", "run the scheduler while serving").addOption(new Option("--mode <mode>", "runtime mode").choices(["local", "hosted"]).default("local")).option("--api-token <token>", "token required for non-loopback mutation hosts").option("--hosted-token <token>", "scoped hosted-mode token").option("--allow-hosted-local-store", "allow hosted mode to use local SQLite as an explicit fallback").option("--allow-unsafe-remote-mutations", "allow state-changing requests from non-loopback hosts without a token").option("-j, --json", "print JSON").action((opts) => {
|
|
4323
6879
|
try {
|
|
4324
6880
|
const { server } = serveUptime({
|
|
4325
6881
|
host: opts.host,
|
|
4326
6882
|
port: opts.port,
|
|
4327
6883
|
check: opts.check,
|
|
6884
|
+
mode: opts.mode,
|
|
4328
6885
|
apiToken: opts.apiToken,
|
|
6886
|
+
hostedToken: opts.hostedToken,
|
|
6887
|
+
allowHostedLocalStore: opts.allowHostedLocalStore,
|
|
4329
6888
|
allowUnsafeRemoteMutations: opts.allowUnsafeRemoteMutations
|
|
4330
6889
|
});
|
|
4331
|
-
const data = { ok: true, url: `http://${server.hostname}:${server.port}`, scheduler: Boolean(opts.check) };
|
|
6890
|
+
const data = { ok: true, url: `http://${server.hostname}:${server.port}`, scheduler: Boolean(opts.check), mode: opts.mode };
|
|
4332
6891
|
if (wantsJson(opts))
|
|
4333
6892
|
console.log(JSON.stringify(data, null, 2));
|
|
4334
6893
|
else
|
|
@@ -4343,18 +6902,54 @@ function parseInteger(value) {
|
|
|
4343
6902
|
throw new Error(`Expected integer, got ${value}`);
|
|
4344
6903
|
return parsed;
|
|
4345
6904
|
}
|
|
6905
|
+
function parseNumber(value) {
|
|
6906
|
+
const parsed = Number(value);
|
|
6907
|
+
if (!Number.isFinite(parsed))
|
|
6908
|
+
throw new Error(`Expected number, got ${value}`);
|
|
6909
|
+
return parsed;
|
|
6910
|
+
}
|
|
6911
|
+
function buildProbeSubmission(opts) {
|
|
6912
|
+
const input = {
|
|
6913
|
+
probeId: opts.probe,
|
|
6914
|
+
jobId: opts.job,
|
|
6915
|
+
scheduleSlot: opts.scheduleSlot,
|
|
6916
|
+
fencingToken: opts.fencingToken,
|
|
6917
|
+
monitorId: opts.monitor,
|
|
6918
|
+
nonce: opts.nonce ?? `cli_${randomUUID4()}`,
|
|
6919
|
+
checkedAt: opts.checkedAt,
|
|
6920
|
+
status: opts.status,
|
|
6921
|
+
latencyMs: opts.latency ?? null,
|
|
6922
|
+
statusCode: opts.statusCode,
|
|
6923
|
+
error: opts.error,
|
|
6924
|
+
attemptCount: opts.attempts,
|
|
6925
|
+
monitorRevision: opts.monitorRevision,
|
|
6926
|
+
evidence: null
|
|
6927
|
+
};
|
|
6928
|
+
return {
|
|
6929
|
+
...input,
|
|
6930
|
+
signature: signProbeResult(input, readFileSync2(opts.privateKeyFile, "utf8"))
|
|
6931
|
+
};
|
|
6932
|
+
}
|
|
6933
|
+
function probeSubmitUrl(apiUrl) {
|
|
6934
|
+
const base = apiUrl.replace(/\/+$/, "");
|
|
6935
|
+
if (/\/api\/v1$/.test(base))
|
|
6936
|
+
return `${base}/probes/results`;
|
|
6937
|
+
if (/\/api$/.test(base))
|
|
6938
|
+
return `${base}/probes/results`;
|
|
6939
|
+
return `${base}/api/probes/results`;
|
|
6940
|
+
}
|
|
4346
6941
|
function renderMonitors(monitors) {
|
|
4347
6942
|
if (monitors.length === 0)
|
|
4348
6943
|
return "No monitors";
|
|
4349
6944
|
return monitors.map((monitor) => {
|
|
4350
|
-
const target = monitor.kind === "
|
|
6945
|
+
const target = monitor.kind === "tcp" ? `${monitor.host}:${monitor.port}` : monitor.url;
|
|
4351
6946
|
const status = renderStatus(monitor.status).padEnd(14);
|
|
4352
6947
|
return `${status} ${sanitizeField(monitor.name).padEnd(24)} ${monitor.kind.padEnd(4)} ${sanitizeField(target ?? "")}`;
|
|
4353
6948
|
}).join(`
|
|
4354
6949
|
`);
|
|
4355
6950
|
}
|
|
4356
6951
|
function renderMonitorDetail(monitor) {
|
|
4357
|
-
const target = monitor.kind === "
|
|
6952
|
+
const target = monitor.kind === "tcp" ? `${monitor.host}:${monitor.port}` : monitor.url;
|
|
4358
6953
|
return [
|
|
4359
6954
|
`${source_default.bold(sanitizeField(monitor.name))} ${renderStatus(monitor.status)}`,
|
|
4360
6955
|
`id: ${monitor.id}`,
|
|
@@ -4377,6 +6972,24 @@ function renderCheckResults(results) {
|
|
|
4377
6972
|
}).join(`
|
|
4378
6973
|
`);
|
|
4379
6974
|
}
|
|
6975
|
+
function parseImportPayload(opts) {
|
|
6976
|
+
if (opts.record && opts.file)
|
|
6977
|
+
throw new Error("Choose either --record or --file, not both");
|
|
6978
|
+
const raw = opts.record ?? (opts.file ? readFileSync2(opts.file, "utf8") : undefined);
|
|
6979
|
+
if (!raw)
|
|
6980
|
+
throw new Error("imports require --record or --file");
|
|
6981
|
+
const parsed = JSON.parse(raw);
|
|
6982
|
+
const records = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && Array.isArray(parsed.records) ? parsed.records : [parsed];
|
|
6983
|
+
return { source: opts.source, records };
|
|
6984
|
+
}
|
|
6985
|
+
function renderImportPreview(preview) {
|
|
6986
|
+
const rows = preview.items.map((item) => `${item.action.padEnd(9)} ${sanitizeField(item.candidate.name).padEnd(24)} ${item.candidate.kind}${item.reason ? ` ${sanitizeField(item.reason)}` : ""}`);
|
|
6987
|
+
return [`Import preview: ${renderImportTotals(preview.totals)}`, ...rows].join(`
|
|
6988
|
+
`);
|
|
6989
|
+
}
|
|
6990
|
+
function renderImportTotals(totals) {
|
|
6991
|
+
return Object.entries(totals).filter(([, count]) => count > 0).map(([action, count]) => `${action}=${count}`).join(" ") || "no changes";
|
|
6992
|
+
}
|
|
4380
6993
|
function renderSummary(summary) {
|
|
4381
6994
|
const lines = [
|
|
4382
6995
|
`monitors: ${summary.totals.monitors} up: ${summary.totals.up} down: ${summary.totals.down} open incidents: ${summary.totals.openIncidents}`
|
|
@@ -4389,6 +7002,50 @@ function renderSummary(summary) {
|
|
|
4389
7002
|
return lines.join(`
|
|
4390
7003
|
`);
|
|
4391
7004
|
}
|
|
7005
|
+
function buildReportScheduleChannels(opts) {
|
|
7006
|
+
const channels = {};
|
|
7007
|
+
if (opts.email) {
|
|
7008
|
+
channels.email = {
|
|
7009
|
+
apiUrl: opts.maileryUrl,
|
|
7010
|
+
from: opts.from,
|
|
7011
|
+
to: splitList(opts.email)
|
|
7012
|
+
};
|
|
7013
|
+
}
|
|
7014
|
+
if (opts.sms) {
|
|
7015
|
+
channels.sms = {
|
|
7016
|
+
apiUrl: opts.telephonyUrl,
|
|
7017
|
+
from: opts.smsFrom,
|
|
7018
|
+
to: splitList(opts.sms)
|
|
7019
|
+
};
|
|
7020
|
+
}
|
|
7021
|
+
if (opts.logs) {
|
|
7022
|
+
channels.logs = {
|
|
7023
|
+
apiUrl: opts.logsUrl,
|
|
7024
|
+
projectId: opts.logsProject
|
|
7025
|
+
};
|
|
7026
|
+
}
|
|
7027
|
+
return channels;
|
|
7028
|
+
}
|
|
7029
|
+
function renderReportSchedules(schedules) {
|
|
7030
|
+
if (schedules.length === 0)
|
|
7031
|
+
return "No report schedules";
|
|
7032
|
+
return schedules.map((schedule) => {
|
|
7033
|
+
const status = schedule.enabled ? "enabled " : "disabled";
|
|
7034
|
+
const channels = ["email", "sms", "logs"].filter((channel) => Boolean(schedule.channels[channel])).join(",");
|
|
7035
|
+
return `${status} ${schedule.id} ${sanitizeField(schedule.name).padEnd(24)} every ${schedule.intervalSeconds}s next ${schedule.nextRunAt} ${channels}`;
|
|
7036
|
+
}).join(`
|
|
7037
|
+
`);
|
|
7038
|
+
}
|
|
7039
|
+
function renderReportRuns(runs) {
|
|
7040
|
+
if (runs.length === 0)
|
|
7041
|
+
return "No report runs";
|
|
7042
|
+
return runs.map((run) => {
|
|
7043
|
+
const status = run.status === "success" ? source_default.green("success") : source_default.red("failed");
|
|
7044
|
+
const deliveries = run.deliveries.map((delivery) => `${delivery.channel}:${delivery.ok ? "ok" : "failed"}`).join(",");
|
|
7045
|
+
return `${status.padEnd(12)} ${run.id} ${run.scheduleId ?? "-"} ${run.finishedAt} ${deliveries}${run.error ? ` ${sanitizeField(run.error)}` : ""}`;
|
|
7046
|
+
}).join(`
|
|
7047
|
+
`);
|
|
7048
|
+
}
|
|
4392
7049
|
function renderDeliveries(deliveries) {
|
|
4393
7050
|
if (deliveries.length === 0)
|
|
4394
7051
|
return "No report deliveries requested";
|