@hasna/uptime 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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 { existsSync } from "fs";
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
- return runTcpCheck(monitor);
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 randomUUID2 } from "crypto";
2775
+ import { createPublicKey, randomUUID as randomUUID3 } from "crypto";
2647
2776
 
2648
- // src/store.ts
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,21 @@ 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 = ["schema_migrations", "monitors", "check_results", "incidents", "check_leases", "monitor_provenance", "import_batches", "probe_identities", "probe_check_jobs", "probe_submissions"];
3411
+ var PROBE_TABLES = new Set(["probe_identities", "probe_check_jobs", "probe_submissions"]);
3412
+ var CURRENT_SCHEMA_VERSION = "2";
3413
+
2680
3414
  class StaleCheckResultError extends Error {
2681
3415
  constructor(message) {
2682
3416
  super(message);
@@ -2686,9 +3420,20 @@ class StaleCheckResultError extends Error {
2686
3420
 
2687
3421
  class UptimeStore {
2688
3422
  dbPath;
3423
+ mode;
3424
+ dataMode;
2689
3425
  db;
2690
3426
  constructor(options = {}) {
2691
- this.dbPath = options.dbPath ?? uptimeDbPath();
3427
+ this.mode = resolveRuntimeMode(options.mode ?? "local");
3428
+ const cloudDatabaseUrl = options.cloudDatabaseUrl ?? process.env.HASNA_UPTIME_DATABASE_URL;
3429
+ if (this.mode === "hosted" && cloudDatabaseUrl) {
3430
+ throw new Error("hosted cloud database adapter is not implemented yet");
3431
+ }
3432
+ if (this.mode === "hosted" && !allowHostedLocalStore(options.allowHostedLocalStore)) {
3433
+ throw new Error("hosted mode requires a cloud data layer; set HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE=1 only for explicit local fallback testing");
3434
+ }
3435
+ this.dataMode = this.mode === "hosted" ? "hosted-local-sqlite" : "local-sqlite";
3436
+ this.dbPath = options.dbPath ?? (this.mode === "hosted" ? uptimeHostedFallbackDbPath() : uptimeDbPath());
2692
3437
  if (this.dbPath !== ":memory:") {
2693
3438
  mkdirSync2(dirname(this.dbPath), { recursive: true });
2694
3439
  }
@@ -2705,7 +3450,7 @@ class UptimeStore {
2705
3450
  CREATE TABLE IF NOT EXISTS monitors (
2706
3451
  id TEXT PRIMARY KEY,
2707
3452
  name TEXT NOT NULL UNIQUE,
2708
- kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp')),
3453
+ kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
2709
3454
  url TEXT,
2710
3455
  host TEXT,
2711
3456
  port INTEGER,
@@ -2723,6 +3468,7 @@ class UptimeStore {
2723
3468
  )
2724
3469
  `);
2725
3470
  this.ensureColumn("monitors", "revision", "INTEGER NOT NULL DEFAULT 1");
3471
+ this.ensureMonitorKindAllowsBrowserPage();
2726
3472
  this.db.run(`
2727
3473
  CREATE TABLE IF NOT EXISTS check_results (
2728
3474
  id TEXT PRIMARY KEY,
@@ -2732,9 +3478,11 @@ class UptimeStore {
2732
3478
  latency_ms REAL,
2733
3479
  status_code INTEGER,
2734
3480
  error TEXT,
2735
- attempt_count INTEGER NOT NULL DEFAULT 1
3481
+ attempt_count INTEGER NOT NULL DEFAULT 1,
3482
+ evidence_json TEXT
2736
3483
  )
2737
3484
  `);
3485
+ this.ensureColumn("check_results", "evidence_json", "TEXT");
2738
3486
  this.db.run(`
2739
3487
  CREATE TABLE IF NOT EXISTS incidents (
2740
3488
  id TEXT PRIMARY KEY,
@@ -2748,6 +3496,71 @@ class UptimeStore {
2748
3496
  reason TEXT
2749
3497
  )
2750
3498
  `);
3499
+ this.db.run(`
3500
+ CREATE TABLE IF NOT EXISTS monitor_provenance (
3501
+ monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
3502
+ source TEXT NOT NULL,
3503
+ source_id TEXT NOT NULL,
3504
+ source_label TEXT,
3505
+ imported_at TEXT NOT NULL,
3506
+ snapshot_json TEXT NOT NULL,
3507
+ PRIMARY KEY (source, source_id)
3508
+ )
3509
+ `);
3510
+ this.db.run(`
3511
+ CREATE TABLE IF NOT EXISTS import_batches (
3512
+ id TEXT PRIMARY KEY,
3513
+ source TEXT NOT NULL,
3514
+ status TEXT NOT NULL CHECK (status IN ('applied', 'rolled_back')),
3515
+ created_at TEXT NOT NULL,
3516
+ rolled_back_at TEXT,
3517
+ records_json TEXT NOT NULL
3518
+ )
3519
+ `);
3520
+ this.db.run(`
3521
+ CREATE TABLE IF NOT EXISTS probe_identities (
3522
+ id TEXT PRIMARY KEY,
3523
+ name TEXT NOT NULL UNIQUE,
3524
+ public_key_pem TEXT NOT NULL,
3525
+ public_key_fingerprint TEXT NOT NULL UNIQUE,
3526
+ enabled INTEGER NOT NULL DEFAULT 1,
3527
+ created_at TEXT NOT NULL,
3528
+ last_seen_at TEXT
3529
+ )
3530
+ `);
3531
+ this.db.run(`
3532
+ CREATE TABLE IF NOT EXISTS probe_submissions (
3533
+ id TEXT PRIMARY KEY,
3534
+ probe_id TEXT NOT NULL REFERENCES probe_identities(id) ON DELETE CASCADE,
3535
+ job_id TEXT NOT NULL,
3536
+ monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
3537
+ check_result_id TEXT NOT NULL REFERENCES check_results(id) ON DELETE CASCADE,
3538
+ nonce TEXT NOT NULL,
3539
+ checked_at TEXT NOT NULL,
3540
+ submitted_at TEXT NOT NULL,
3541
+ UNIQUE (probe_id, nonce)
3542
+ )
3543
+ `);
3544
+ this.ensureColumn("probe_submissions", "job_id", "TEXT");
3545
+ this.db.run(`
3546
+ CREATE TABLE IF NOT EXISTS probe_check_jobs (
3547
+ id TEXT PRIMARY KEY,
3548
+ monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
3549
+ monitor_revision INTEGER NOT NULL DEFAULT 1,
3550
+ schedule_slot TEXT NOT NULL,
3551
+ status TEXT NOT NULL CHECK (status IN ('pending', 'claimed', 'submitted', 'expired', 'cancelled')),
3552
+ claimed_by_probe_id TEXT REFERENCES probe_identities(id) ON DELETE SET NULL,
3553
+ fencing_token TEXT,
3554
+ due_at TEXT NOT NULL,
3555
+ claimed_at TEXT,
3556
+ lease_expires_at TEXT,
3557
+ submitted_result_id TEXT REFERENCES check_results(id) ON DELETE SET NULL,
3558
+ created_at TEXT NOT NULL,
3559
+ updated_at TEXT NOT NULL,
3560
+ UNIQUE (monitor_id, schedule_slot)
3561
+ )
3562
+ `);
3563
+ this.ensureColumn("probe_check_jobs", "monitor_revision", "INTEGER NOT NULL DEFAULT 1");
2751
3564
  this.db.run(`
2752
3565
  CREATE TABLE IF NOT EXISTS check_leases (
2753
3566
  monitor_id TEXT PRIMARY KEY REFERENCES monitors(id) ON DELETE CASCADE,
@@ -2756,12 +3569,71 @@ class UptimeStore {
2756
3569
  acquired_at TEXT NOT NULL
2757
3570
  )
2758
3571
  `);
3572
+ this.db.run(`
3573
+ CREATE TABLE IF NOT EXISTS schema_migrations (
3574
+ key TEXT PRIMARY KEY,
3575
+ value TEXT NOT NULL,
3576
+ updated_at TEXT NOT NULL
3577
+ )
3578
+ `);
3579
+ this.db.query("INSERT OR REPLACE INTO schema_migrations (key, value, updated_at) VALUES ('schema_version', ?, ?)").run(CURRENT_SCHEMA_VERSION, new Date().toISOString());
2759
3580
  this.db.run("CREATE INDEX IF NOT EXISTS idx_results_monitor_time ON check_results(monitor_id, checked_at DESC)");
2760
3581
  this.db.run("CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents(monitor_id, status)");
2761
3582
  this.db.run("CREATE INDEX IF NOT EXISTS idx_check_leases_until ON check_leases(leased_until)");
3583
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_monitor_provenance_monitor ON monitor_provenance(monitor_id)");
3584
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_status_due ON probe_check_jobs(status, due_at)");
3585
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_probe_status ON probe_check_jobs(claimed_by_probe_id, status)");
3586
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_probe_time ON probe_submissions(probe_id, submitted_at DESC)");
3587
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_monitor_time ON probe_submissions(monitor_id, checked_at DESC)");
3588
+ 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 != ''");
2762
3589
  }
2763
- createMonitor(input) {
2764
- const normalized = normalizeCreateMonitor(input);
3590
+ backup(destinationPath) {
3591
+ if (this.dbPath === ":memory:" && !destinationPath) {
3592
+ throw new Error("backup path is required for in-memory stores");
3593
+ }
3594
+ const createdAt = new Date().toISOString();
3595
+ const backupPath = destinationPath ?? join2(dirname(this.dbPath), "backups", `uptime-${createdAt.replace(/[:.]/g, "-")}.db`);
3596
+ mkdirSync2(dirname(backupPath), { recursive: true });
3597
+ if (this.dbPath === ":memory:") {
3598
+ this.vacuumInto(backupPath);
3599
+ } else {
3600
+ this.db.run("PRAGMA wal_checkpoint(TRUNCATE)");
3601
+ copyFileSync(this.dbPath, backupPath);
3602
+ }
3603
+ const bytes = statSync(backupPath).size;
3604
+ return { sourcePath: this.dbPath, backupPath, bytes, createdAt };
3605
+ }
3606
+ verifyBackup(backupPath) {
3607
+ return verifyBackupFile(backupPath);
3608
+ }
3609
+ static verifyBackup(backupPath) {
3610
+ return verifyBackupFile(backupPath);
3611
+ }
3612
+ static restoreBackup(backupPath, destinationPath = uptimeDbPath()) {
3613
+ const check = verifyBackupFile(backupPath);
3614
+ if (!check.ok)
3615
+ throw new Error(`backup integrity check failed: ${check.integrity}`);
3616
+ if (destinationPath === ":memory:")
3617
+ throw new Error("cannot restore a backup to an in-memory store");
3618
+ if (existsSync(destinationPath) || existsSync(`${destinationPath}-wal`) || existsSync(`${destinationPath}-shm`)) {
3619
+ throw new Error("restore destination already exists or has SQLite sidecar files");
3620
+ }
3621
+ mkdirSync2(dirname(destinationPath), { recursive: true });
3622
+ copyFileSync(backupPath, destinationPath);
3623
+ const bytes = statSync(destinationPath).size;
3624
+ return {
3625
+ sourcePath: backupPath,
3626
+ backupPath: destinationPath,
3627
+ bytes,
3628
+ createdAt: new Date().toISOString()
3629
+ };
3630
+ }
3631
+ createMonitor(input, options = {}) {
3632
+ if (this.mode === "hosted")
3633
+ assertHostedTargetAllowed(input);
3634
+ const normalized = normalizeCreateMonitor(input, options.allowBrowserPage === true);
3635
+ if (this.mode === "hosted")
3636
+ assertHostedTargetAllowed(normalized);
2765
3637
  const now = new Date().toISOString();
2766
3638
  const monitor = {
2767
3639
  id: newId("mon"),
@@ -2797,12 +3669,22 @@ class UptimeStore {
2797
3669
  const row = this.db.query("SELECT * FROM monitors WHERE id = ? OR name = ?").get(idOrName, idOrName);
2798
3670
  return row ? monitorFromRow(row) : null;
2799
3671
  }
2800
- updateMonitor(idOrName, input) {
3672
+ updateMonitor(idOrName, input, options = {}) {
2801
3673
  const current = this.getMonitor(idOrName);
2802
3674
  if (!current)
2803
3675
  throw new Error(`Monitor not found: ${idOrName}`);
3676
+ if (this.mode === "hosted") {
3677
+ assertHostedTargetAllowed({
3678
+ kind: input.kind ?? current.kind,
3679
+ url: input.url ?? current.url ?? undefined,
3680
+ host: input.host ?? current.host ?? undefined,
3681
+ port: input.port ?? current.port ?? undefined
3682
+ });
3683
+ }
2804
3684
  const updatedAt = new Date().toISOString();
2805
- const next = normalizeUpdateMonitor(current, input, updatedAt);
3685
+ const next = normalizeUpdateMonitor(current, input, updatedAt, options.allowBrowserPage === true);
3686
+ if (this.mode === "hosted")
3687
+ assertHostedTargetAllowed(next);
2806
3688
  this.db.query(`UPDATE monitors SET
2807
3689
  name = ?, kind = ?, url = ?, host = ?, port = ?, method = ?,
2808
3690
  expected_status = ?, interval_seconds = ?, timeout_ms = ?,
@@ -2821,6 +3703,185 @@ class UptimeStore {
2821
3703
  this.db.query("DELETE FROM monitors WHERE id = ?").run(current.id);
2822
3704
  return true;
2823
3705
  }
3706
+ createProbeIdentity(input) {
3707
+ const name = input.name.trim();
3708
+ if (!name)
3709
+ throw new Error("Probe name is required");
3710
+ rejectControlCharacters2(name, "Probe name");
3711
+ const now = new Date().toISOString();
3712
+ const probe = {
3713
+ id: newId("prb"),
3714
+ name,
3715
+ publicKeyPem: input.publicKeyPem.trim(),
3716
+ publicKeyFingerprint: input.publicKeyFingerprint,
3717
+ enabled: input.enabled ?? true,
3718
+ createdAt: now,
3719
+ lastSeenAt: null
3720
+ };
3721
+ if (!probe.publicKeyPem)
3722
+ throw new Error("Probe public key is required");
3723
+ this.db.query(`INSERT INTO probe_identities (
3724
+ id, name, public_key_pem, public_key_fingerprint, enabled, created_at, last_seen_at
3725
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(probe.id, probe.name, probe.publicKeyPem, probe.publicKeyFingerprint, probe.enabled ? 1 : 0, probe.createdAt, probe.lastSeenAt);
3726
+ return probe;
3727
+ }
3728
+ listProbeIdentities(options = {}) {
3729
+ 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();
3730
+ return rows.map(probeIdentityFromRow);
3731
+ }
3732
+ getProbeIdentity(idOrName) {
3733
+ const row = this.db.query("SELECT * FROM probe_identities WHERE id = ? OR name = ?").get(idOrName, idOrName);
3734
+ return row ? probeIdentityFromRow(row) : null;
3735
+ }
3736
+ updateProbeIdentity(idOrName, input) {
3737
+ const current = this.getProbeIdentity(idOrName);
3738
+ if (!current)
3739
+ throw new Error(`Probe not found: ${idOrName}`);
3740
+ const name = input.name === undefined ? current.name : input.name.trim();
3741
+ if (!name)
3742
+ throw new Error("Probe name is required");
3743
+ rejectControlCharacters2(name, "Probe name");
3744
+ const enabled = input.enabled ?? current.enabled;
3745
+ this.db.query("UPDATE probe_identities SET name = ?, enabled = ? WHERE id = ?").run(name, enabled ? 1 : 0, current.id);
3746
+ return this.getProbeIdentity(current.id);
3747
+ }
3748
+ touchProbeIdentity(idOrName, seenAt = new Date().toISOString()) {
3749
+ const probe = this.getProbeIdentity(idOrName);
3750
+ if (!probe)
3751
+ throw new Error(`Probe not found: ${idOrName}`);
3752
+ this.db.query("UPDATE probe_identities SET last_seen_at = ? WHERE id = ?").run(seenAt, probe.id);
3753
+ }
3754
+ createProbeCheckJob(input) {
3755
+ const monitor = this.getMonitor(input.monitorId);
3756
+ if (!monitor)
3757
+ throw new Error(`Monitor not found: ${input.monitorId}`);
3758
+ if (!monitor.enabled)
3759
+ throw new Error(`Monitor is disabled: ${monitor.name}`);
3760
+ const scheduleSlot = normalizeScheduleSlot(input.scheduleSlot);
3761
+ const dueAt = input.dueAt ?? new Date().toISOString();
3762
+ assertIsoTimestamp(dueAt, "Probe job dueAt");
3763
+ const now = new Date().toISOString();
3764
+ const existing = this.db.query("SELECT * FROM probe_check_jobs WHERE monitor_id = ? AND schedule_slot = ?").get(monitor.id, scheduleSlot);
3765
+ if (existing)
3766
+ return probeCheckJobFromRow(existing);
3767
+ const job = {
3768
+ id: newId("job"),
3769
+ monitorId: monitor.id,
3770
+ monitorRevision: monitor.revision,
3771
+ scheduleSlot,
3772
+ status: "pending",
3773
+ claimedByProbeId: null,
3774
+ fencingToken: null,
3775
+ dueAt,
3776
+ claimedAt: null,
3777
+ leaseExpiresAt: null,
3778
+ submittedResultId: null,
3779
+ createdAt: now,
3780
+ updatedAt: now
3781
+ };
3782
+ this.db.query(`INSERT INTO probe_check_jobs (
3783
+ id, monitor_id, monitor_revision, schedule_slot, status, claimed_by_probe_id, fencing_token,
3784
+ due_at, claimed_at, lease_expires_at, submitted_result_id, created_at, updated_at
3785
+ ) 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);
3786
+ return job;
3787
+ }
3788
+ getProbeCheckJob(id) {
3789
+ const row = this.db.query("SELECT * FROM probe_check_jobs WHERE id = ?").get(id);
3790
+ return row ? probeCheckJobFromRow(row) : null;
3791
+ }
3792
+ claimProbeCheckJob(input) {
3793
+ const tx = this.db.transaction(() => {
3794
+ const probe = this.getProbeIdentity(input.probeId);
3795
+ if (!probe)
3796
+ throw new Error(`Probe not found: ${input.probeId}`);
3797
+ if (!probe.enabled)
3798
+ throw new Error(`Probe is disabled: ${probe.name}`);
3799
+ const current = this.getProbeCheckJob(input.jobId);
3800
+ if (!current)
3801
+ throw new Error(`Probe job not found: ${input.jobId}`);
3802
+ const now = new Date;
3803
+ const nowIso = now.toISOString();
3804
+ if (current.status === "submitted")
3805
+ throw new Error("Probe job already submitted");
3806
+ if (current.status === "cancelled")
3807
+ throw new Error("Probe job is cancelled");
3808
+ if (current.dueAt > nowIso)
3809
+ throw new Error("Probe job is not due yet");
3810
+ const leaseExpired = Boolean(current.leaseExpiresAt && current.leaseExpiresAt <= nowIso);
3811
+ if (current.status === "claimed" && !leaseExpired && current.claimedByProbeId !== probe.id) {
3812
+ throw new Error("Probe job already claimed by another probe");
3813
+ }
3814
+ if (current.status !== "pending" && current.status !== "claimed" && current.status !== "expired") {
3815
+ throw new Error(`Probe job is not claimable: ${current.status}`);
3816
+ }
3817
+ const leaseExpiresAt = new Date(now.getTime() + Math.max(1000, input.leaseTtlMs ?? 120000)).toISOString();
3818
+ const fencingToken = newId("fence");
3819
+ const update = this.db.query(`UPDATE probe_check_jobs
3820
+ SET status = 'claimed', claimed_by_probe_id = ?, fencing_token = ?, claimed_at = ?, lease_expires_at = ?, updated_at = ?
3821
+ WHERE id = ?
3822
+ AND submitted_result_id IS NULL
3823
+ AND (
3824
+ status IN ('pending', 'expired')
3825
+ OR (status = 'claimed' AND (claimed_by_probe_id = ? OR lease_expires_at <= ?))
3826
+ )`).run(probe.id, fencingToken, nowIso, leaseExpiresAt, nowIso, current.id, probe.id, nowIso);
3827
+ if (statementChanges(update) !== 1)
3828
+ throw new Error("Probe job claim raced; retry");
3829
+ this.touchProbeIdentity(probe.id, nowIso);
3830
+ return this.getProbeCheckJob(current.id);
3831
+ });
3832
+ return tx();
3833
+ }
3834
+ completeProbeCheckJob(input) {
3835
+ const job = this.getProbeCheckJob(input.jobId);
3836
+ if (!job)
3837
+ throw new Error(`Probe job not found: ${input.jobId}`);
3838
+ const submittedAt = input.submittedAt ?? new Date().toISOString();
3839
+ if (job.status !== "claimed")
3840
+ throw new Error(`Probe job is not claimable for submission: ${job.status}`);
3841
+ if (job.claimedByProbeId !== input.probeId)
3842
+ throw new Error("Probe job was claimed by another probe");
3843
+ if (job.fencingToken !== input.fencingToken)
3844
+ throw new Error("Probe job fencing token is invalid");
3845
+ if (!job.leaseExpiresAt || job.leaseExpiresAt <= submittedAt) {
3846
+ this.expireProbeCheckJob(job.id, submittedAt);
3847
+ throw new Error("Probe job lease expired");
3848
+ }
3849
+ const update = this.db.query(`UPDATE probe_check_jobs
3850
+ SET status = 'submitted', submitted_result_id = ?, updated_at = ?
3851
+ WHERE id = ?
3852
+ AND status = 'claimed'
3853
+ AND claimed_by_probe_id = ?
3854
+ AND fencing_token = ?
3855
+ AND lease_expires_at > ?
3856
+ AND submitted_result_id IS NULL`).run(input.checkResultId, submittedAt, job.id, input.probeId, input.fencingToken, submittedAt);
3857
+ if (statementChanges(update) !== 1)
3858
+ throw new Error("Probe job submission raced; retry");
3859
+ return this.getProbeCheckJob(job.id);
3860
+ }
3861
+ expireProbeCheckJob(jobId, updatedAt = new Date().toISOString()) {
3862
+ this.db.query("UPDATE probe_check_jobs SET status = 'expired', updated_at = ? WHERE id = ? AND status != 'submitted'").run(updatedAt, jobId);
3863
+ }
3864
+ getProbeSubmission(probeId, nonce) {
3865
+ const row = this.db.query("SELECT * FROM probe_submissions WHERE probe_id = ? AND nonce = ?").get(probeId, nonce);
3866
+ return row ? probeSubmissionFromRow(row) : null;
3867
+ }
3868
+ recordProbeSubmission(input) {
3869
+ const submittedAt = input.submittedAt ?? new Date().toISOString();
3870
+ const receipt = {
3871
+ id: newId("psb"),
3872
+ probeId: input.probeId,
3873
+ jobId: input.jobId,
3874
+ monitorId: input.monitorId,
3875
+ checkResultId: input.checkResultId,
3876
+ nonce: input.nonce,
3877
+ checkedAt: input.checkedAt,
3878
+ submittedAt
3879
+ };
3880
+ this.db.query(`INSERT INTO probe_submissions (
3881
+ id, probe_id, job_id, monitor_id, check_result_id, nonce, checked_at, submitted_at
3882
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(receipt.id, receipt.probeId, receipt.jobId, receipt.monitorId, receipt.checkResultId, receipt.nonce, receipt.checkedAt, receipt.submittedAt);
3883
+ return receipt;
3884
+ }
2824
3885
  acquireCheckLease(monitorId, owner, ttlMs) {
2825
3886
  const now = new Date;
2826
3887
  const nowIso = now.toISOString();
@@ -2855,7 +3916,8 @@ class UptimeStore {
2855
3916
  latencyMs: input.latencyMs,
2856
3917
  statusCode: input.statusCode,
2857
3918
  error: input.error,
2858
- attemptCount: Math.max(1, input.attemptCount)
3919
+ attemptCount: Math.max(1, input.attemptCount),
3920
+ evidence: input.evidence ?? null
2859
3921
  };
2860
3922
  const tx = this.db.transaction(() => {
2861
3923
  const current = this.db.query("SELECT * FROM monitors WHERE id = ?").get(result.monitorId);
@@ -2868,19 +3930,59 @@ class UptimeStore {
2868
3930
  throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${current.name}`);
2869
3931
  }
2870
3932
  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);
3933
+ id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count, evidence_json
3934
+ ) 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
3935
  this.db.query("UPDATE monitors SET status = ?, last_checked_at = ?, updated_at = ? WHERE id = ?").run(result.status, result.checkedAt, result.checkedAt, result.monitorId);
2874
3936
  this.reconcileIncidentInTransaction(result);
2875
3937
  });
2876
3938
  tx();
2877
3939
  return result;
2878
3940
  }
3941
+ getCheckResult(id) {
3942
+ const row = this.db.query("SELECT * FROM check_results WHERE id = ?").get(id);
3943
+ return row ? checkResultFromRow(row) : null;
3944
+ }
2879
3945
  listResults(options = {}) {
2880
3946
  const limit = clampLimit(options.limit ?? 50);
2881
3947
  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
3948
  return rows.map(checkResultFromRow);
2883
3949
  }
3950
+ getProvenance(source, sourceId) {
3951
+ const row = this.db.query("SELECT * FROM monitor_provenance WHERE source = ? AND source_id = ?").get(source, sourceId);
3952
+ return row ? provenanceFromRow(row) : null;
3953
+ }
3954
+ upsertMonitorProvenance(input) {
3955
+ const importedAt = new Date().toISOString();
3956
+ this.db.query(`INSERT INTO monitor_provenance (
3957
+ monitor_id, source, source_id, source_label, imported_at, snapshot_json
3958
+ ) VALUES (?, ?, ?, ?, ?, ?)
3959
+ ON CONFLICT(source, source_id) DO UPDATE SET
3960
+ monitor_id = excluded.monitor_id,
3961
+ source_label = excluded.source_label,
3962
+ imported_at = excluded.imported_at,
3963
+ snapshot_json = excluded.snapshot_json`).run(input.monitorId, input.source, input.sourceId, input.sourceLabel ?? null, importedAt, JSON.stringify(input.snapshot));
3964
+ return this.getProvenance(input.source, input.sourceId);
3965
+ }
3966
+ saveImportBatch(input) {
3967
+ const createdAt = new Date().toISOString();
3968
+ 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));
3969
+ return this.getImportBatch(input.id);
3970
+ }
3971
+ getImportBatch(batchId) {
3972
+ const row = this.db.query("SELECT * FROM import_batches WHERE id = ?").get(batchId);
3973
+ return row ? importBatchFromRow(row) : null;
3974
+ }
3975
+ markImportBatchRolledBack(batchId) {
3976
+ const rolledBackAt = new Date().toISOString();
3977
+ this.db.query("UPDATE import_batches SET status = 'rolled_back', rolled_back_at = ? WHERE id = ?").run(rolledBackAt, batchId);
3978
+ const batch = this.getImportBatch(batchId);
3979
+ if (!batch)
3980
+ throw new Error(`Import batch not found: ${batchId}`);
3981
+ return batch;
3982
+ }
3983
+ runInTransaction(fn) {
3984
+ return this.db.transaction(fn)();
3985
+ }
2884
3986
  listIncidents(options = {}) {
2885
3987
  const clauses = [];
2886
3988
  const args = [];
@@ -2968,16 +4070,115 @@ class UptimeStore {
2968
4070
  this.db.run(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
2969
4071
  }
2970
4072
  }
4073
+ ensureMonitorKindAllowsBrowserPage() {
4074
+ const row = this.db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'monitors'").get();
4075
+ if (!row?.sql || row.sql.includes("browser_page"))
4076
+ return;
4077
+ this.db.run("PRAGMA foreign_keys = OFF");
4078
+ this.db.run("PRAGMA legacy_alter_table = ON");
4079
+ try {
4080
+ const migrate = this.db.transaction(() => {
4081
+ this.db.run("ALTER TABLE monitors RENAME TO monitors_old_kind");
4082
+ this.db.run(`
4083
+ CREATE TABLE monitors (
4084
+ id TEXT PRIMARY KEY,
4085
+ name TEXT NOT NULL UNIQUE,
4086
+ kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
4087
+ url TEXT,
4088
+ host TEXT,
4089
+ port INTEGER,
4090
+ method TEXT NOT NULL DEFAULT 'GET',
4091
+ expected_status INTEGER,
4092
+ interval_seconds INTEGER NOT NULL DEFAULT 60,
4093
+ timeout_ms INTEGER NOT NULL DEFAULT 5000,
4094
+ retry_count INTEGER NOT NULL DEFAULT 0,
4095
+ enabled INTEGER NOT NULL DEFAULT 1,
4096
+ status TEXT NOT NULL DEFAULT 'unknown',
4097
+ last_checked_at TEXT,
4098
+ revision INTEGER NOT NULL DEFAULT 1,
4099
+ created_at TEXT NOT NULL,
4100
+ updated_at TEXT NOT NULL
4101
+ )
4102
+ `);
4103
+ this.db.run(`
4104
+ INSERT INTO monitors (
4105
+ id, name, kind, url, host, port, method, expected_status,
4106
+ interval_seconds, timeout_ms, retry_count, enabled, status,
4107
+ last_checked_at, revision, created_at, updated_at
4108
+ )
4109
+ SELECT
4110
+ id, name, kind, url, host, port, method, expected_status,
4111
+ interval_seconds, timeout_ms, retry_count, enabled, status,
4112
+ last_checked_at, revision, created_at, updated_at
4113
+ FROM monitors_old_kind
4114
+ `);
4115
+ this.db.run("DROP TABLE monitors_old_kind");
4116
+ });
4117
+ migrate();
4118
+ } finally {
4119
+ this.db.run("PRAGMA legacy_alter_table = OFF");
4120
+ this.db.run("PRAGMA foreign_keys = ON");
4121
+ }
4122
+ }
4123
+ vacuumInto(backupPath) {
4124
+ const quoted = backupPath.replace(/'/g, "''");
4125
+ this.db.run(`VACUUM INTO '${quoted}'`);
4126
+ }
4127
+ }
4128
+ function resolveRuntimeMode(mode) {
4129
+ const value = mode ?? process.env.HASNA_UPTIME_MODE ?? "local";
4130
+ if (value === "local" || value === "hosted")
4131
+ return value;
4132
+ throw new Error("HASNA_UPTIME_MODE must be local or hosted");
4133
+ }
4134
+ function allowHostedLocalStore(value) {
4135
+ return value === true || process.env.HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE === "1";
4136
+ }
4137
+ function verifyBackupFile(backupPath) {
4138
+ const db = new Database(backupPath, { readonly: true });
4139
+ try {
4140
+ const integrityRow = db.query("PRAGMA integrity_check").get();
4141
+ const integrity = String(integrityRow?.integrity_check ?? "unknown");
4142
+ const missingTables = REQUIRED_TABLES.filter((table) => !tableExists(db, table));
4143
+ const schemaVersion = missingTables.includes("schema_migrations") ? null : db.query("SELECT value FROM schema_migrations WHERE key = 'schema_version'").get()?.value ?? null;
4144
+ const currentOk = missingTables.length === 0 && schemaVersion === CURRENT_SCHEMA_VERSION;
4145
+ const restorableV1 = schemaVersion === "1" && missingTables.every((table) => PROBE_TABLES.has(table));
4146
+ return {
4147
+ ok: integrity === "ok" && (currentOk || restorableV1),
4148
+ backupPath,
4149
+ integrity,
4150
+ schemaVersion,
4151
+ missingTables,
4152
+ monitors: tableCount(db, "monitors"),
4153
+ results: tableCount(db, "check_results"),
4154
+ incidents: tableCount(db, "incidents")
4155
+ };
4156
+ } finally {
4157
+ db.close();
4158
+ }
4159
+ }
4160
+ function tableCount(db, table) {
4161
+ if (!tableExists(db, table))
4162
+ return 0;
4163
+ const row = db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
4164
+ return Number(row?.count ?? 0);
4165
+ }
4166
+ function tableExists(db, table) {
4167
+ const row = db.query("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
4168
+ return Number(row?.count ?? 0) > 0;
2971
4169
  }
2972
- function normalizeCreateMonitor(input) {
4170
+ function normalizeCreateMonitor(input, allowBrowserPage = false) {
2973
4171
  const name = input.name?.trim();
2974
4172
  if (!name)
2975
4173
  throw new Error("Monitor name is required");
2976
- rejectControlCharacters(name, "Monitor name");
4174
+ rejectControlCharacters2(name, "Monitor name");
2977
4175
  const method = normalizeMethod(input.method ?? "GET");
2978
4176
  const expectedStatus = normalizeExpectedStatus(input.expectedStatus);
2979
4177
  const enabled = normalizeEnabled(input.enabled);
2980
- if (input.kind === "http") {
4178
+ if (input.kind === "http" || input.kind === "browser_page") {
4179
+ if (input.kind === "browser_page" && !allowBrowserPage) {
4180
+ throw new Error("browser_page monitors must be imported with explicit browser evidence support");
4181
+ }
2981
4182
  const url = normalizeHttpUrl(input.url);
2982
4183
  return {
2983
4184
  name,
@@ -2985,16 +4186,16 @@ function normalizeCreateMonitor(input) {
2985
4186
  url,
2986
4187
  method,
2987
4188
  expectedStatus,
2988
- intervalSeconds: boundedInteger(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
2989
- timeoutMs: boundedInteger(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
2990
- retryCount: boundedInteger(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
4189
+ intervalSeconds: boundedInteger2(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
4190
+ timeoutMs: boundedInteger2(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
4191
+ retryCount: boundedInteger2(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
2991
4192
  enabled
2992
4193
  };
2993
4194
  } else if (input.kind === "tcp") {
2994
4195
  const host = input.host?.trim();
2995
4196
  if (!host)
2996
4197
  throw new Error("TCP monitors require host");
2997
- rejectControlCharacters(host, "TCP host");
4198
+ rejectControlCharacters2(host, "TCP host");
2998
4199
  if (!Number.isInteger(input.port) || input.port <= 0 || input.port > 65535) {
2999
4200
  throw new Error("TCP monitors require a port from 1 to 65535");
3000
4201
  }
@@ -3005,19 +4206,19 @@ function normalizeCreateMonitor(input) {
3005
4206
  port: input.port,
3006
4207
  method,
3007
4208
  expectedStatus: null,
3008
- intervalSeconds: boundedInteger(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
3009
- timeoutMs: boundedInteger(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
3010
- retryCount: boundedInteger(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
4209
+ intervalSeconds: boundedInteger2(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
4210
+ timeoutMs: boundedInteger2(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
4211
+ retryCount: boundedInteger2(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
3011
4212
  enabled
3012
4213
  };
3013
4214
  } else {
3014
- throw new Error("Monitor kind must be http or tcp");
4215
+ throw new Error("Monitor kind must be http, tcp, or browser_page");
3015
4216
  }
3016
4217
  }
3017
4218
  function definitionChanged(current, next) {
3018
4219
  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
4220
  }
3020
- function normalizeUpdateMonitor(current, input, updatedAt) {
4221
+ function normalizeUpdateMonitor(current, input, updatedAt, allowBrowserPage = false) {
3021
4222
  const merged = {
3022
4223
  ...current,
3023
4224
  ...input,
@@ -3036,7 +4237,7 @@ function normalizeUpdateMonitor(current, input, updatedAt) {
3036
4237
  timeoutMs: merged.timeoutMs,
3037
4238
  retryCount: merged.retryCount,
3038
4239
  enabled: merged.enabled
3039
- });
4240
+ }, allowBrowserPage || current.kind === "browser_page");
3040
4241
  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
4242
  const status = normalized.enabled ? checkDefinitionChanged || !current.enabled ? "unknown" : current.status : "paused";
3042
4243
  return {
@@ -3065,6 +4266,11 @@ function normalizeHttpUrl(value) {
3065
4266
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
3066
4267
  throw new Error("HTTP monitor url must use http or https");
3067
4268
  }
4269
+ for (const key of [...parsed.searchParams.keys()]) {
4270
+ if (SECRET_URL_PARAM_PATTERN.test(key))
4271
+ parsed.searchParams.set(key, "[redacted]");
4272
+ }
4273
+ parsed.hash = "";
3068
4274
  return parsed.toString();
3069
4275
  }
3070
4276
  function normalizeMethod(value) {
@@ -3088,11 +4294,25 @@ function normalizeEnabled(value) {
3088
4294
  throw new Error("enabled must be a boolean");
3089
4295
  return value;
3090
4296
  }
3091
- function rejectControlCharacters(value, label) {
4297
+ function rejectControlCharacters2(value, label) {
3092
4298
  if (/[\x00-\x1f\x7f-\x9f]/.test(value)) {
3093
4299
  throw new Error(`${label} must not contain control characters`);
3094
4300
  }
3095
4301
  }
4302
+ function normalizeScheduleSlot(value) {
4303
+ const slot = value.trim();
4304
+ if (!slot)
4305
+ throw new Error("Probe job scheduleSlot is required");
4306
+ if (slot.length > 128)
4307
+ throw new Error("Probe job scheduleSlot is too long");
4308
+ rejectControlCharacters2(slot, "Probe job scheduleSlot");
4309
+ return slot;
4310
+ }
4311
+ function assertIsoTimestamp(value, label) {
4312
+ if (!Number.isFinite(Date.parse(value))) {
4313
+ throw new Error(`${label} must be an ISO timestamp`);
4314
+ }
4315
+ }
3096
4316
  function monitorFromRow(row) {
3097
4317
  return {
3098
4318
  id: row.id,
@@ -3123,9 +4343,83 @@ function checkResultFromRow(row) {
3123
4343
  latencyMs: row.latency_ms,
3124
4344
  statusCode: row.status_code,
3125
4345
  error: row.error,
3126
- attemptCount: row.attempt_count
4346
+ attemptCount: row.attempt_count,
4347
+ evidence: parseEvidence(row.evidence_json)
4348
+ };
4349
+ }
4350
+ function provenanceFromRow(row) {
4351
+ return {
4352
+ monitorId: row.monitor_id,
4353
+ source: row.source,
4354
+ sourceId: row.source_id,
4355
+ sourceLabel: row.source_label,
4356
+ importedAt: row.imported_at,
4357
+ snapshot: parseJson(row.snapshot_json)
4358
+ };
4359
+ }
4360
+ function importBatchFromRow(row) {
4361
+ return {
4362
+ id: row.id,
4363
+ source: row.source,
4364
+ status: row.status,
4365
+ createdAt: row.created_at,
4366
+ rolledBackAt: row.rolled_back_at,
4367
+ records: Array.isArray(parseJson(row.records_json)) ? parseJson(row.records_json) : []
4368
+ };
4369
+ }
4370
+ function probeIdentityFromRow(row) {
4371
+ return {
4372
+ id: row.id,
4373
+ name: row.name,
4374
+ publicKeyPem: row.public_key_pem,
4375
+ publicKeyFingerprint: row.public_key_fingerprint,
4376
+ enabled: Boolean(row.enabled),
4377
+ createdAt: row.created_at,
4378
+ lastSeenAt: row.last_seen_at
4379
+ };
4380
+ }
4381
+ function probeSubmissionFromRow(row) {
4382
+ return {
4383
+ id: row.id,
4384
+ probeId: row.probe_id,
4385
+ jobId: row.job_id ?? "",
4386
+ monitorId: row.monitor_id,
4387
+ checkResultId: row.check_result_id,
4388
+ nonce: row.nonce,
4389
+ checkedAt: row.checked_at,
4390
+ submittedAt: row.submitted_at
4391
+ };
4392
+ }
4393
+ function probeCheckJobFromRow(row) {
4394
+ return {
4395
+ id: row.id,
4396
+ monitorId: row.monitor_id,
4397
+ monitorRevision: row.monitor_revision ?? 1,
4398
+ scheduleSlot: row.schedule_slot,
4399
+ status: row.status,
4400
+ claimedByProbeId: row.claimed_by_probe_id,
4401
+ fencingToken: row.fencing_token,
4402
+ dueAt: row.due_at,
4403
+ claimedAt: row.claimed_at,
4404
+ leaseExpiresAt: row.lease_expires_at,
4405
+ submittedResultId: row.submitted_result_id,
4406
+ createdAt: row.created_at,
4407
+ updatedAt: row.updated_at
3127
4408
  };
3128
4409
  }
4410
+ function parseEvidence(value) {
4411
+ if (!value)
4412
+ return null;
4413
+ const parsed = parseJson(value);
4414
+ return parsed && typeof parsed === "object" ? parsed : null;
4415
+ }
4416
+ function parseJson(value) {
4417
+ try {
4418
+ return JSON.parse(value);
4419
+ } catch {
4420
+ return null;
4421
+ }
4422
+ }
3129
4423
  function incidentFromRow(row) {
3130
4424
  return {
3131
4425
  id: row.id,
@@ -3140,9 +4434,9 @@ function incidentFromRow(row) {
3140
4434
  };
3141
4435
  }
3142
4436
  function newId(prefix) {
3143
- return `${prefix}_${randomUUID().replace(/-/g, "").slice(0, 18)}`;
4437
+ return `${prefix}_${randomUUID2().replace(/-/g, "").slice(0, 18)}`;
3144
4438
  }
3145
- function boundedInteger(value, label, min, max) {
4439
+ function boundedInteger2(value, label, min, max) {
3146
4440
  if (!Number.isInteger(value) || value < min || value > max) {
3147
4441
  throw new Error(`${label} must be an integer from ${min} to ${max}`);
3148
4442
  }
@@ -3153,6 +4447,9 @@ function clampLimit(value) {
3153
4447
  return 50;
3154
4448
  return Math.max(1, Math.min(Math.floor(value), MAX_RESULT_LIMIT));
3155
4449
  }
4450
+ function statementChanges(result) {
4451
+ return Number(result?.changes ?? 0);
4452
+ }
3156
4453
  function round(value, places) {
3157
4454
  const factor = 10 ** places;
3158
4455
  return Math.round(value * factor) / factor;
@@ -3227,7 +4524,7 @@ function renderMonitorLine(item) {
3227
4524
  return `- ${item.monitor.status.toUpperCase()} ${item.monitor.name} (${targetLabel(item)}): uptime ${uptime}, latency ${latency}${incident}`;
3228
4525
  }
3229
4526
  function targetLabel(item) {
3230
- return item.monitor.kind === "http" ? item.monitor.url ?? "" : `${item.monitor.host}:${item.monitor.port}`;
4527
+ return item.monitor.kind === "tcp" ? `${item.monitor.host}:${item.monitor.port}` : item.monitor.url ?? "";
3231
4528
  }
3232
4529
  function resolveEmailTarget(value) {
3233
4530
  const target = typeof value === "boolean" ? {} : value;
@@ -3429,13 +4726,16 @@ function redactOptional(value, secrets) {
3429
4726
  }
3430
4727
 
3431
4728
  // src/service.ts
4729
+ var MAX_PROBE_RESULT_AGE_MS = 15 * 60000;
4730
+ var MAX_PROBE_RESULT_FUTURE_MS = 5 * 60000;
4731
+
3432
4732
  class UptimeService {
3433
4733
  store;
3434
4734
  checkRunner;
3435
- leaseOwner = `svc_${randomUUID2().replace(/-/g, "").slice(0, 18)}`;
4735
+ leaseOwner = `svc_${randomUUID3().replace(/-/g, "").slice(0, 18)}`;
3436
4736
  inFlightChecks = new Set;
3437
4737
  constructor(options = {}) {
3438
- this.store = options.store ?? new UptimeStore(options);
4738
+ this.store = options.store ?? new UptimeStore({ mode: "local", ...options });
3439
4739
  this.checkRunner = options.checkRunner ?? runMonitorCheck;
3440
4740
  }
3441
4741
  close() {
@@ -3465,13 +4765,71 @@ class UptimeService {
3465
4765
  summary() {
3466
4766
  return this.store.summary();
3467
4767
  }
4768
+ createProbe(input) {
4769
+ const store = this.probeStore();
4770
+ const publicKeyPem = input.publicKeyPem ? normalizeProbePublicKeyPem(input.publicKeyPem) : undefined;
4771
+ const keyPair = publicKeyPem ? {
4772
+ publicKeyPem,
4773
+ privateKeyPem: undefined,
4774
+ publicKeyFingerprint: probePublicKeyFingerprint(publicKeyPem)
4775
+ } : generateProbeKeyPair();
4776
+ const probe = store.createProbeIdentity({
4777
+ name: input.name,
4778
+ publicKeyPem: keyPair.publicKeyPem,
4779
+ publicKeyFingerprint: keyPair.publicKeyFingerprint,
4780
+ enabled: input.enabled
4781
+ });
4782
+ return { ...probe, privateKeyPem: keyPair.privateKeyPem };
4783
+ }
4784
+ listProbes(options = {}) {
4785
+ return this.probeStore().listProbeIdentities(options);
4786
+ }
4787
+ getProbe(idOrName) {
4788
+ return this.probeStore().getProbeIdentity(idOrName);
4789
+ }
4790
+ updateProbe(idOrName, input) {
4791
+ return this.probeStore().updateProbeIdentity(idOrName, input);
4792
+ }
4793
+ createProbeCheckJob(input) {
4794
+ return this.probeStore().createProbeCheckJob(input);
4795
+ }
4796
+ getProbeCheckJob(id) {
4797
+ return this.probeStore().getProbeCheckJob(id);
4798
+ }
4799
+ claimProbeCheckJob(input) {
4800
+ return this.probeStore().claimProbeCheckJob(input);
4801
+ }
4802
+ submitProbeResult(input) {
4803
+ const execute = () => this.submitProbeResultInTransaction(input);
4804
+ return this.store.runInTransaction ? this.store.runInTransaction(execute) : execute();
4805
+ }
4806
+ previewImport(request) {
4807
+ return previewImport(this.store, request);
4808
+ }
4809
+ applyImport(request) {
4810
+ return applyImport(this.store, request);
4811
+ }
4812
+ rollbackImport(batchId) {
4813
+ return rollbackImport(this.store, batchId);
4814
+ }
4815
+ backup(destinationPath) {
4816
+ return this.store.backup(destinationPath);
4817
+ }
4818
+ verifyBackup(backupPath) {
4819
+ return this.store.verifyBackup(backupPath);
4820
+ }
3468
4821
  buildReport(options = {}) {
3469
4822
  return buildUptimeReport(this.summary(), options);
3470
4823
  }
3471
4824
  async sendReport(options = {}) {
4825
+ if (this.store.mode === "hosted" && (options.email || options.sms || options.logs)) {
4826
+ throw new Error("hosted report delivery requires configured channel refs");
4827
+ }
3472
4828
  return sendUptimeReport(this.summary(), options);
3473
4829
  }
3474
4830
  async checkMonitor(idOrName) {
4831
+ if (this.store.mode === "hosted")
4832
+ throw new Error("hosted checks require check_jobs and probes");
3475
4833
  const monitor = this.store.getMonitor(idOrName);
3476
4834
  if (!monitor)
3477
4835
  throw new Error(`Monitor not found: ${idOrName}`);
@@ -3500,6 +4858,7 @@ class UptimeService {
3500
4858
  latencyMs: last.latencyMs,
3501
4859
  statusCode: last.statusCode ?? null,
3502
4860
  error: last.error ?? null,
4861
+ evidence: last.evidence ?? null,
3503
4862
  attemptCount,
3504
4863
  expectedMonitorRevision: monitor.revision
3505
4864
  });
@@ -3509,6 +4868,8 @@ class UptimeService {
3509
4868
  }
3510
4869
  }
3511
4870
  async checkAll() {
4871
+ if (this.store.mode === "hosted")
4872
+ throw new Error("hosted checks require check_jobs and probes");
3512
4873
  const monitors = this.store.listMonitors();
3513
4874
  const results = [];
3514
4875
  for (const monitor of monitors) {
@@ -3517,6 +4878,8 @@ class UptimeService {
3517
4878
  return results;
3518
4879
  }
3519
4880
  startScheduler(options = {}) {
4881
+ if (this.store.mode === "hosted")
4882
+ throw new Error("hosted scheduler requires check_jobs and probes");
3520
4883
  const tickMs = options.tickMs ?? 1000;
3521
4884
  const timer = setInterval(() => {
3522
4885
  this.runDueChecks().catch((error) => {
@@ -3528,6 +4891,8 @@ class UptimeService {
3528
4891
  };
3529
4892
  }
3530
4893
  async runDueChecks(now = new Date) {
4894
+ if (this.store.mode === "hosted")
4895
+ throw new Error("hosted checks require check_jobs and probes");
3531
4896
  const due = this.store.listMonitors().filter((monitor) => this.isDue(monitor, now));
3532
4897
  const results = [];
3533
4898
  for (const monitor of due) {
@@ -3554,6 +4919,113 @@ class UptimeService {
3554
4919
  const last = new Date(monitor.lastCheckedAt).getTime();
3555
4920
  return now.getTime() - last >= monitor.intervalSeconds * 1000;
3556
4921
  }
4922
+ probeStore() {
4923
+ if (this.store.mode === "hosted") {
4924
+ throw new Error("hosted probe APIs require cloud check_jobs, workspace stores, and audit logging");
4925
+ }
4926
+ const store = this.store;
4927
+ const required = [
4928
+ "createProbeIdentity",
4929
+ "listProbeIdentities",
4930
+ "getProbeIdentity",
4931
+ "updateProbeIdentity",
4932
+ "touchProbeIdentity",
4933
+ "createProbeCheckJob",
4934
+ "getProbeCheckJob",
4935
+ "claimProbeCheckJob",
4936
+ "completeProbeCheckJob",
4937
+ "getProbeSubmission",
4938
+ "recordProbeSubmission"
4939
+ ];
4940
+ for (const method of required) {
4941
+ if (typeof store[method] !== "function") {
4942
+ throw new Error("probe support requires a probe-capable store");
4943
+ }
4944
+ }
4945
+ return store;
4946
+ }
4947
+ submitProbeResultInTransaction(input) {
4948
+ const store = this.probeStore();
4949
+ const probe = store.getProbeIdentity(input.probeId);
4950
+ if (!probe)
4951
+ throw new Error(`Probe not found: ${input.probeId}`);
4952
+ if (!probe.enabled)
4953
+ throw new Error(`Probe is disabled: ${probe.name}`);
4954
+ const monitor = this.store.getMonitor(input.monitorId);
4955
+ if (!monitor)
4956
+ throw new Error(`Monitor not found: ${input.monitorId}`);
4957
+ if (!monitor.enabled)
4958
+ throw new Error(`Monitor is disabled: ${monitor.name}`);
4959
+ if (probe.id !== input.probeId)
4960
+ throw new Error("Probe result must use canonical probe id");
4961
+ if (monitor.id !== input.monitorId)
4962
+ throw new Error("Probe result must use canonical monitor id");
4963
+ validateProbeSubmission(input);
4964
+ const job = store.getProbeCheckJob(input.jobId);
4965
+ if (!job)
4966
+ throw new Error(`Probe job not found: ${input.jobId}`);
4967
+ if (job.monitorId !== monitor.id)
4968
+ throw new Error("Probe job does not match monitor");
4969
+ if (job.scheduleSlot !== input.scheduleSlot)
4970
+ throw new Error("Probe job scheduleSlot does not match submission");
4971
+ if (!verifyProbeResultSignature({ ...input, probeId: probe.id, monitorId: monitor.id }, probe.publicKeyPem)) {
4972
+ throw new Error("Probe result signature is invalid");
4973
+ }
4974
+ const existingReceipt = store.getProbeSubmission(probe.id, input.nonce);
4975
+ if (existingReceipt) {
4976
+ if (existingReceipt.jobId !== input.jobId || existingReceipt.monitorId !== monitor.id || existingReceipt.checkedAt !== input.checkedAt) {
4977
+ throw new Error("Probe nonce already submitted");
4978
+ }
4979
+ const existingResult = this.store.getCheckResult?.(existingReceipt.checkResultId);
4980
+ if (!existingResult)
4981
+ throw new Error("Probe nonce already submitted");
4982
+ return { result: existingResult, receipt: existingReceipt };
4983
+ }
4984
+ if (job.monitorRevision !== input.monitorRevision)
4985
+ throw new Error("Probe job monitorRevision does not match submission");
4986
+ if (job.monitorRevision !== monitor.revision)
4987
+ throw new StaleCheckResultError(`Monitor changed since probe job was created: ${monitor.name}`);
4988
+ if (job.status === "submitted")
4989
+ throw new Error("Probe job already submitted");
4990
+ if (job.status === "cancelled")
4991
+ throw new Error("Probe job is cancelled");
4992
+ if (job.status !== "claimed")
4993
+ throw new Error(`Probe job is not claimable for submission: ${job.status}`);
4994
+ if (job.claimedByProbeId !== probe.id)
4995
+ throw new Error("Probe job was claimed by another probe");
4996
+ if (job.fencingToken !== input.fencingToken)
4997
+ throw new Error("Probe job fencing token is invalid");
4998
+ if (!job.leaseExpiresAt || job.leaseExpiresAt <= new Date().toISOString())
4999
+ throw new Error("Probe job lease expired");
5000
+ const result = this.store.recordCheckResult({
5001
+ monitorId: monitor.id,
5002
+ checkedAt: input.checkedAt,
5003
+ status: input.status,
5004
+ latencyMs: input.latencyMs,
5005
+ statusCode: input.statusCode ?? null,
5006
+ error: input.error ?? null,
5007
+ evidence: input.evidence ?? null,
5008
+ attemptCount: input.attemptCount ?? 1,
5009
+ expectedMonitorRevision: input.monitorRevision
5010
+ });
5011
+ const receipt = store.recordProbeSubmission({
5012
+ probeId: probe.id,
5013
+ jobId: job.id,
5014
+ monitorId: monitor.id,
5015
+ checkResultId: result.id,
5016
+ nonce: input.nonce,
5017
+ checkedAt: input.checkedAt
5018
+ });
5019
+ store.completeProbeCheckJob({
5020
+ jobId: job.id,
5021
+ probeId: probe.id,
5022
+ fencingToken: input.fencingToken,
5023
+ checkResultId: result.id,
5024
+ submittedAt: receipt.submittedAt
5025
+ });
5026
+ store.touchProbeIdentity(probe.id, receipt.submittedAt);
5027
+ return { result, receipt };
5028
+ }
3557
5029
  }
3558
5030
  class MonitorCheckBusyError extends Error {
3559
5031
  constructor(message) {
@@ -3561,16 +5033,68 @@ class MonitorCheckBusyError extends Error {
3561
5033
  this.name = "MonitorCheckBusyError";
3562
5034
  }
3563
5035
  }
5036
+ function validateProbeSubmission(input) {
5037
+ if (!input.jobId.trim())
5038
+ throw new Error("Probe submission jobId is required");
5039
+ if (!input.scheduleSlot.trim())
5040
+ throw new Error("Probe submission scheduleSlot is required");
5041
+ if (!input.fencingToken.trim())
5042
+ throw new Error("Probe submission fencingToken is required");
5043
+ if (!input.nonce.trim())
5044
+ throw new Error("Probe submission nonce is required");
5045
+ if (input.nonce.length > 128)
5046
+ throw new Error("Probe submission nonce is too long");
5047
+ if (/[\x00-\x1f\x7f-\x9f]/.test(input.nonce))
5048
+ throw new Error("Probe submission nonce must not contain control characters");
5049
+ if (input.status !== "up" && input.status !== "down")
5050
+ throw new Error("Probe result status must be up or down");
5051
+ if (input.latencyMs !== null && (!Number.isFinite(input.latencyMs) || input.latencyMs < 0)) {
5052
+ throw new Error("Probe result latencyMs must be null or a non-negative number");
5053
+ }
5054
+ if (input.statusCode !== undefined && input.statusCode !== null && (!Number.isInteger(input.statusCode) || input.statusCode < 100 || input.statusCode > 599)) {
5055
+ throw new Error("Probe result statusCode must be an HTTP status from 100 to 599");
5056
+ }
5057
+ if (input.attemptCount !== undefined && (!Number.isInteger(input.attemptCount) || input.attemptCount < 1 || input.attemptCount > 20)) {
5058
+ throw new Error("Probe result attemptCount must be an integer from 1 to 20");
5059
+ }
5060
+ const monitorRevision = input.monitorRevision;
5061
+ if (!Number.isInteger(monitorRevision) || monitorRevision < 1) {
5062
+ throw new Error("Probe result monitorRevision is required");
5063
+ }
5064
+ const checkedAtMs = Date.parse(input.checkedAt);
5065
+ if (!Number.isFinite(checkedAtMs))
5066
+ throw new Error("Probe result checkedAt must be an ISO timestamp");
5067
+ const now = Date.now();
5068
+ if (checkedAtMs > now + MAX_PROBE_RESULT_FUTURE_MS)
5069
+ throw new Error("Probe result checkedAt is too far in the future");
5070
+ if (checkedAtMs < now - MAX_PROBE_RESULT_AGE_MS)
5071
+ throw new Error("Probe result checkedAt is too old");
5072
+ if (!input.signature.trim())
5073
+ throw new Error("Probe result signature is required");
5074
+ }
5075
+ function normalizeProbePublicKeyPem(publicKeyPem) {
5076
+ try {
5077
+ const key = createPublicKey(publicKeyPem);
5078
+ if (key.asymmetricKeyType !== "ed25519") {
5079
+ throw new Error("Probe public key must be an Ed25519 public key");
5080
+ }
5081
+ return key.export({ format: "pem", type: "spki" }).toString();
5082
+ } catch (error) {
5083
+ if (error instanceof Error && error.message.includes("Ed25519"))
5084
+ throw error;
5085
+ throw new Error("Probe public key must be a valid PEM Ed25519 public key");
5086
+ }
5087
+ }
3564
5088
 
3565
5089
  // src/version.ts
3566
5090
  import { readFileSync } from "fs";
3567
- import { dirname as dirname2, join as join2 } from "path";
5091
+ import { dirname as dirname2, join as join3 } from "path";
3568
5092
  import { fileURLToPath } from "url";
3569
5093
  function packageVersion() {
3570
5094
  const here = dirname2(fileURLToPath(import.meta.url));
3571
5095
  const candidates = [
3572
- join2(here, "..", "package.json"),
3573
- join2(here, "..", "..", "package.json")
5096
+ join3(here, "..", "package.json"),
5097
+ join3(here, "..", "..", "package.json")
3574
5098
  ];
3575
5099
  for (const candidate of candidates) {
3576
5100
  try {
@@ -3580,6 +5104,9 @@ function packageVersion() {
3580
5104
  return "0.0.0";
3581
5105
  }
3582
5106
 
5107
+ // src/api.ts
5108
+ import { timingSafeEqual } from "crypto";
5109
+
3583
5110
  // src/dashboard.ts
3584
5111
  function dashboardHtml() {
3585
5112
  return `<!doctype html>
@@ -3796,7 +5323,7 @@ function dashboardHtml() {
3796
5323
  clear(root);
3797
5324
  for (const item of summary.monitors) {
3798
5325
  const m = item.monitor;
3799
- const target = m.kind === 'http' ? m.url : m.host + ':' + m.port;
5326
+ const target = m.kind === 'tcp' ? m.host + ':' + m.port : m.url;
3800
5327
  const incident = item.openIncident ? 'open since ' + new Date(item.openIncident.openedAt).toLocaleString() : '-';
3801
5328
  const tr = document.createElement('tr');
3802
5329
  const name = document.createElement('td');
@@ -3932,82 +5459,54 @@ function dashboardHtml() {
3932
5459
 
3933
5460
  // src/api.ts
3934
5461
  function createApiHandler(service, options = {}) {
5462
+ const mode = options.mode ? resolveRuntimeMode(options.mode) : service.store.mode;
5463
+ if (mode !== service.store.mode) {
5464
+ throw new Error(`API mode ${mode} does not match store mode ${service.store.mode}`);
5465
+ }
3935
5466
  return async (request) => {
3936
5467
  const url = new URL(request.url);
3937
5468
  try {
3938
- validateLocalMutationRequest(request, url, options);
3939
- if (request.method === "GET" && url.pathname === "/") {
3940
- return html(dashboardHtml());
3941
- }
3942
5469
  if (request.method === "GET" && url.pathname === "/health") {
3943
- return json({ ok: true, service: "uptime" });
3944
- }
3945
- if (request.method === "GET" && url.pathname === "/api/summary") {
3946
- return json(service.summary());
5470
+ return json({ ok: true, service: "uptime", mode, dataMode: service.store.dataMode });
3947
5471
  }
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());
5472
+ if (mode === "hosted") {
5473
+ return await handleHostedRequest(service, request, url, options);
5474
+ } else {
5475
+ validateLocalMutationRequest(request, url, options);
3977
5476
  }
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
- }
5477
+ if (request.method === "GET" && url.pathname === "/") {
5478
+ return html(dashboardHtml());
3994
5479
  }
3995
- return json({ error: "not found" }, 404);
5480
+ return await handleApiRoute(service, request, url, url.pathname, options, false);
3996
5481
  } catch (error) {
3997
5482
  return json({ error: error instanceof Error ? error.message : String(error) }, error instanceof ApiError ? error.status : 400);
3998
5483
  }
3999
5484
  };
4000
5485
  }
4001
5486
  function serveUptime(options = {}) {
5487
+ const requestedMode = options.mode ? resolveRuntimeMode(options.mode) : options.service?.store.mode ?? "local";
5488
+ if (requestedMode === "hosted" && resolveHostedTokens(options).length === 0) {
5489
+ throw new Error("hosted mode requires HASNA_UPTIME_HOSTED_TOKEN or --hosted-token");
5490
+ }
4002
5491
  const service = options.service ?? new UptimeService(options);
5492
+ const mode = service.store.mode;
5493
+ if (mode !== requestedMode) {
5494
+ throw new Error(`serve mode ${requestedMode} does not match store mode ${mode}`);
5495
+ }
5496
+ if (mode === "hosted" && options.check) {
5497
+ throw new Error("hosted scheduler requires check_jobs and probes");
5498
+ }
4003
5499
  const scheduler = options.check ? service.startScheduler() : undefined;
4004
5500
  const server = Bun.serve({
4005
5501
  hostname: options.host ?? "127.0.0.1",
4006
5502
  port: options.port ?? 3899,
4007
5503
  fetch: createApiHandler(service, {
4008
5504
  apiToken: options.apiToken,
5505
+ hostedToken: options.hostedToken,
5506
+ hostedTokens: options.hostedTokens,
4009
5507
  allowUnsafeRemoteMutations: options.allowUnsafeRemoteMutations,
4010
- trustedLoopback: isLoopbackHost(options.host ?? "127.0.0.1")
5508
+ trustedLoopback: isLoopbackHost(options.host ?? "127.0.0.1"),
5509
+ mode
4011
5510
  })
4012
5511
  });
4013
5512
  return { server, service, scheduler };
@@ -4039,7 +5538,7 @@ function numericParam(url, name, fallback) {
4039
5538
  function validateLocalMutationRequest(request, url, options) {
4040
5539
  if (!["POST", "PATCH", "DELETE"].includes(request.method))
4041
5540
  return;
4042
- const apiToken = options.apiToken ?? process.env.HASNA_UPTIME_API_TOKEN;
5541
+ const apiToken = resolveApiToken(options.apiToken);
4043
5542
  const hasToken = apiToken ? hasValidApiToken(request, apiToken) : false;
4044
5543
  const allowUnsafeRemote = options.allowUnsafeRemoteMutations || process.env.HASNA_UPTIME_ALLOW_REMOTE_MUTATIONS === "1";
4045
5544
  const trustedLoopback = options.trustedLoopback ?? isLoopbackHost(url.hostname);
@@ -4051,15 +5550,203 @@ function validateLocalMutationRequest(request, url, options) {
4051
5550
  throw new ApiError("cross-origin mutation rejected", 403);
4052
5551
  }
4053
5552
  }
5553
+ async function handleHostedRequest(service, request, url, options) {
5554
+ if (url.pathname === "/") {
5555
+ requireHostedActor(request, url, options, "uptime:read");
5556
+ throw new ApiError("hosted dashboard requires the cloud dashboard shell", 501);
5557
+ }
5558
+ if (!url.pathname.startsWith("/api/v1/")) {
5559
+ requireHostedActor(request, url, options, "uptime:read");
5560
+ return json({ error: "not found" }, 404);
5561
+ }
5562
+ const apiPath = `/api${url.pathname.slice("/api/v1".length)}`;
5563
+ const scope = hostedScopeFor(request.method, apiPath);
5564
+ requireHostedActor(request, url, options, scope);
5565
+ if (["POST", "PATCH", "DELETE"].includes(request.method)) {
5566
+ const origin = request.headers.get("origin");
5567
+ if (origin && origin !== `${url.protocol}//${url.host}`) {
5568
+ throw new ApiError("cross-origin mutation rejected", 403);
5569
+ }
5570
+ }
5571
+ return handleApiRoute(service, request, url, apiPath, options, true);
5572
+ }
5573
+ async function handleApiRoute(service, request, url, apiPath, options, hosted) {
5574
+ if (request.method === "GET" && apiPath === "/api/summary") {
5575
+ return json(service.summary());
5576
+ }
5577
+ if (request.method === "GET" && apiPath === "/api/report") {
5578
+ return json(service.buildReport());
5579
+ }
5580
+ if (request.method === "POST" && apiPath === "/api/report") {
5581
+ if (hosted)
5582
+ throw new ApiError("hosted report delivery requires configured channel refs", 501);
5583
+ const input = await jsonBody(request);
5584
+ return json(await service.sendReport({ ...input, fetchImpl: options.fetchImpl }));
5585
+ }
5586
+ if (hosted && apiPath.startsWith("/api/probes")) {
5587
+ throw new ApiError("hosted probe APIs require cloud check_jobs, workspace stores, and audit logging", 501);
5588
+ }
5589
+ if (request.method === "GET" && apiPath === "/api/monitors") {
5590
+ return json(service.listMonitors({ includeDisabled: url.searchParams.get("includeDisabled") === "true" }));
5591
+ }
5592
+ if (request.method === "POST" && apiPath === "/api/monitors") {
5593
+ return json(service.createMonitor(await jsonBody(request)), 201);
5594
+ }
5595
+ if (request.method === "GET" && apiPath === "/api/incidents") {
5596
+ const status = url.searchParams.get("status");
5597
+ return json(service.listIncidents({
5598
+ status: status === "open" || status === "closed" ? status : undefined,
5599
+ monitorId: url.searchParams.get("monitorId") ?? undefined,
5600
+ limit: numericParam(url, "limit", 50)
5601
+ }));
5602
+ }
5603
+ if (request.method === "GET" && apiPath === "/api/results") {
5604
+ return json(service.listResults({
5605
+ monitorId: url.searchParams.get("monitorId") ?? undefined,
5606
+ limit: numericParam(url, "limit", 50)
5607
+ }));
5608
+ }
5609
+ if (request.method === "GET" && apiPath === "/api/probes") {
5610
+ return json(service.listProbes({ includeDisabled: url.searchParams.get("includeDisabled") === "true" }));
5611
+ }
5612
+ if (request.method === "POST" && apiPath === "/api/probes") {
5613
+ const input = await jsonBody(request);
5614
+ if (!input.publicKeyPem)
5615
+ throw new ApiError("API probe creation requires publicKeyPem; generate keys in the probe agent or CLI", 400);
5616
+ return json(service.createProbe(input), 201);
5617
+ }
5618
+ if (request.method === "POST" && apiPath === "/api/probes/jobs") {
5619
+ return json(service.createProbeCheckJob(await jsonBody(request)), 201);
5620
+ }
5621
+ const probeJobMatch = apiPath.match(/^\/api\/probes\/jobs\/([^/]+)$/);
5622
+ if (probeJobMatch) {
5623
+ const jobId = decodeURIComponent(probeJobMatch[1]);
5624
+ if (request.method === "GET") {
5625
+ const job = service.getProbeCheckJob(jobId);
5626
+ return job ? json({ ...job, fencingToken: null }) : json({ error: "not found" }, 404);
5627
+ }
5628
+ }
5629
+ const probeJobClaimMatch = apiPath.match(/^\/api\/probes\/jobs\/([^/]+)\/claim$/);
5630
+ if (request.method === "POST" && probeJobClaimMatch) {
5631
+ const input = await jsonBody(request);
5632
+ return json(service.claimProbeCheckJob({
5633
+ jobId: decodeURIComponent(probeJobClaimMatch[1]),
5634
+ probeId: input.probeId,
5635
+ leaseTtlMs: input.leaseTtlMs
5636
+ }));
5637
+ }
5638
+ if (request.method === "POST" && apiPath === "/api/probes/results") {
5639
+ return json(service.submitProbeResult(await jsonBody(request)), 201);
5640
+ }
5641
+ if (request.method === "POST" && apiPath === "/api/imports/preview") {
5642
+ return json(service.previewImport(await jsonBody(request)));
5643
+ }
5644
+ if (request.method === "POST" && apiPath === "/api/imports/apply") {
5645
+ if (hosted)
5646
+ throw new ApiError("hosted import apply requires cloud import_batches and audit", 501);
5647
+ return json(service.applyImport(await jsonBody(request)), 201);
5648
+ }
5649
+ const importRollbackMatch = apiPath.match(/^\/api\/imports\/([^/]+)\/rollback$/);
5650
+ if (request.method === "POST" && importRollbackMatch) {
5651
+ if (hosted)
5652
+ throw new ApiError("hosted import rollback requires cloud import_batches and audit", 501);
5653
+ return json(service.rollbackImport(decodeURIComponent(importRollbackMatch[1])));
5654
+ }
5655
+ if (request.method === "POST" && apiPath === "/api/check-all") {
5656
+ if (hosted)
5657
+ throw new ApiError("hosted checks require check_jobs and probes", 501);
5658
+ return json(await service.checkAll());
5659
+ }
5660
+ const monitorMatch = apiPath.match(/^\/api\/monitors\/([^/]+)(?:\/(check))?$/);
5661
+ if (monitorMatch) {
5662
+ const id = decodeURIComponent(monitorMatch[1]);
5663
+ if (request.method === "GET" && !monitorMatch[2]) {
5664
+ const monitor = service.getMonitor(id);
5665
+ return monitor ? json(monitor) : json({ error: "not found" }, 404);
5666
+ }
5667
+ if (request.method === "PATCH" && !monitorMatch[2]) {
5668
+ return json(service.updateMonitor(id, await jsonBody(request)));
5669
+ }
5670
+ if (request.method === "DELETE" && !monitorMatch[2]) {
5671
+ return json({ deleted: service.deleteMonitor(id) });
5672
+ }
5673
+ if (request.method === "POST" && monitorMatch[2] === "check") {
5674
+ if (hosted)
5675
+ throw new ApiError("hosted checks require check_jobs and probes", 501);
5676
+ return json(await service.checkMonitor(id));
5677
+ }
5678
+ }
5679
+ return json({ error: "not found" }, 404);
5680
+ }
5681
+ function hostedScopeFor(method, apiPath) {
5682
+ if (method === "POST" && apiPath === "/api/report")
5683
+ return "uptime:report";
5684
+ if (apiPath.startsWith("/api/probes"))
5685
+ return method === "GET" ? "uptime:read" : "uptime:probe";
5686
+ if (method === "POST" && (apiPath === "/api/check-all" || /\/check$/.test(apiPath)))
5687
+ return "uptime:probe";
5688
+ if (method === "GET")
5689
+ return "uptime:read";
5690
+ if (method === "POST" || method === "PATCH" || method === "DELETE")
5691
+ return "uptime:write";
5692
+ return "uptime:read";
5693
+ }
5694
+ function requireHostedActor(request, url, options, scope) {
5695
+ const tokens = resolveHostedTokens(options);
5696
+ if (tokens.length === 0)
5697
+ throw new ApiError("hosted auth token is not configured", 503);
5698
+ const candidate = bearerToken(request) ?? request.headers.get("x-uptime-hosted-token")?.trim();
5699
+ const token = candidate ? tokens.find((entry) => safeTokenEqual(candidate, entry.token)) : undefined;
5700
+ if (!token)
5701
+ throw new ApiError("authentication required", 401);
5702
+ const scopes = new Set(token.scopes);
5703
+ if (!scopes.has(scope) && !scopes.has("uptime:admin")) {
5704
+ throw new ApiError("insufficient scope", 403);
5705
+ }
5706
+ const workspaceId = token.workspaceId ?? "default";
5707
+ const requestedWorkspace = request.headers.get("x-uptime-workspace")?.trim() || url.searchParams.get("workspaceId")?.trim();
5708
+ if (requestedWorkspace && requestedWorkspace !== workspaceId) {
5709
+ throw new ApiError("workspace access denied", 403);
5710
+ }
5711
+ return { scopes, workspaceId };
5712
+ }
4054
5713
  function isLoopbackHost(hostname) {
4055
5714
  const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
4056
5715
  return host === "localhost" || host === "127.0.0.1" || host === "::1";
4057
5716
  }
4058
5717
  function hasValidApiToken(request, token) {
4059
- const authorization = request.headers.get("authorization") ?? "";
4060
- const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
5718
+ const bearer = bearerToken(request);
4061
5719
  const headerToken = request.headers.get("x-uptime-token")?.trim();
4062
- return bearer === token || headerToken === token;
5720
+ return safeTokenEqual(bearer, token) || safeTokenEqual(headerToken, token);
5721
+ }
5722
+ function bearerToken(request) {
5723
+ const authorization = request.headers.get("authorization") ?? "";
5724
+ return authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
5725
+ }
5726
+ function resolveApiToken(token) {
5727
+ const value = token ?? process.env.HASNA_UPTIME_API_TOKEN;
5728
+ return value?.trim() || undefined;
5729
+ }
5730
+ function resolveHostedTokens(options) {
5731
+ if (options.hostedTokens?.length)
5732
+ return options.hostedTokens;
5733
+ const token = options.hostedToken ?? process.env.HASNA_UPTIME_HOSTED_TOKEN;
5734
+ if (!token?.trim())
5735
+ return [];
5736
+ return [{
5737
+ token: token.trim(),
5738
+ scopes: ["uptime:read", "uptime:write", "uptime:probe", "uptime:report"],
5739
+ workspaceId: process.env.HASNA_UPTIME_WORKSPACE_ID ?? "default"
5740
+ }];
5741
+ }
5742
+ function safeTokenEqual(candidate, expected) {
5743
+ if (!candidate)
5744
+ return false;
5745
+ const candidateBytes = Buffer.from(candidate);
5746
+ const expectedBytes = Buffer.from(expected);
5747
+ if (candidateBytes.length !== expectedBytes.length)
5748
+ return false;
5749
+ return timingSafeEqual(candidateBytes, expectedBytes);
4063
5750
  }
4064
5751
  async function jsonBody(request) {
4065
5752
  const contentType = request.headers.get("content-type") ?? "";
@@ -4082,7 +5769,7 @@ class ApiError extends Error {
4082
5769
  var program2 = new Command;
4083
5770
  program2.name("uptime").description("Local-first uptime and downtime monitoring").version(packageVersion()).option("-j, --json", "print JSON");
4084
5771
  function service() {
4085
- return new UptimeService;
5772
+ return new UptimeService({ mode: "local" });
4086
5773
  }
4087
5774
  function wantsJson(opts) {
4088
5775
  return Boolean(opts?.json || program2.opts().json);
@@ -4106,7 +5793,7 @@ program2.command("init").description("Initialize the local uptime store").option
4106
5793
  ensureUptimeHome();
4107
5794
  const svc = service();
4108
5795
  svc.close();
4109
- const data = { ok: true, home: uptimeHome(), dbPath: uptimeDbPath(), exists: existsSync(uptimeDbPath()) };
5796
+ const data = { ok: true, home: uptimeHome(), dbPath: uptimeDbPath(), exists: existsSync2(uptimeDbPath()) };
4110
5797
  print(data, `Initialized ${data.dbPath}`, opts);
4111
5798
  } catch (error) {
4112
5799
  fail(error);
@@ -4319,16 +6006,179 @@ program2.command("incidents").description("List incidents").addOption(new Option
4319
6006
  fail(error);
4320
6007
  }
4321
6008
  });
4322
- 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").option("--api-token <token>", "token required for non-loopback mutation hosts").option("--allow-unsafe-remote-mutations", "allow state-changing requests from non-loopback hosts without a token").option("-j, --json", "print JSON").action((opts) => {
6009
+ var imports = program2.command("imports").description("Preview, apply, and rollback inventory imports");
6010
+ 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) => {
6011
+ try {
6012
+ const svc = service();
6013
+ const preview = svc.previewImport(parseImportPayload(opts));
6014
+ svc.close();
6015
+ print(preview, renderImportPreview(preview), opts);
6016
+ } catch (error) {
6017
+ fail(error);
6018
+ }
6019
+ });
6020
+ 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) => {
6021
+ try {
6022
+ const svc = service();
6023
+ const result = svc.applyImport(parseImportPayload(opts));
6024
+ svc.close();
6025
+ print(result, `Applied import batch ${result.batchId}: ${renderImportTotals(result.totals)}`, opts);
6026
+ } catch (error) {
6027
+ fail(error);
6028
+ }
6029
+ });
6030
+ 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) => {
6031
+ try {
6032
+ const svc = service();
6033
+ const result = svc.rollbackImport(batchId);
6034
+ svc.close();
6035
+ print(result, `Rolled back import batch ${result.batchId}`, opts);
6036
+ } catch (error) {
6037
+ fail(error);
6038
+ }
6039
+ });
6040
+ var probes = program2.command("probes").description("Manage private probe identities and signed probe result submissions");
6041
+ 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) => {
6042
+ let generatedPrivateKeyFile;
6043
+ let svc;
6044
+ try {
6045
+ if (opts.publicKeyFile && opts.privateKeyFile)
6046
+ throw new Error("Choose either --public-key-file or --private-key-file, not both");
6047
+ if (!opts.publicKeyFile && !opts.privateKeyFile)
6048
+ throw new Error("generated probe keys require --private-key-file");
6049
+ const generatedKeyPair = opts.publicKeyFile ? undefined : generateProbeKeyPair();
6050
+ if (generatedKeyPair) {
6051
+ writeFileSync(opts.privateKeyFile, generatedKeyPair.privateKeyPem, { mode: 384, flag: "wx" });
6052
+ generatedPrivateKeyFile = opts.privateKeyFile;
6053
+ }
6054
+ svc = service();
6055
+ const probe = svc.createProbe({
6056
+ name,
6057
+ publicKeyPem: opts.publicKeyFile ? readFileSync2(opts.publicKeyFile, "utf8") : generatedKeyPair?.publicKeyPem,
6058
+ enabled: opts.disabled ? false : true
6059
+ });
6060
+ svc.close();
6061
+ svc = undefined;
6062
+ const output = generatedPrivateKeyFile ? { ...probe, privateKeyFile: generatedPrivateKeyFile } : probe;
6063
+ print(output, `Created probe ${probe.name} (${probe.id})`, opts);
6064
+ } catch (error) {
6065
+ svc?.close();
6066
+ if (generatedPrivateKeyFile) {
6067
+ try {
6068
+ unlinkSync(generatedPrivateKeyFile);
6069
+ } catch {}
6070
+ }
6071
+ fail(error);
6072
+ }
6073
+ });
6074
+ probes.command("list").description("List private probe identities").option("--all", "include disabled probes").option("-j, --json", "print JSON").action((opts) => {
6075
+ try {
6076
+ const svc = service();
6077
+ const items = svc.listProbes({ includeDisabled: opts.all });
6078
+ svc.close();
6079
+ print(items, items.length ? items.map((item) => `${item.enabled ? "enabled " : "disabled"} ${item.id} ${sanitizeField(item.name)} ${item.lastSeenAt ?? "-"}`).join(`
6080
+ `) : "No probes", opts);
6081
+ } catch (error) {
6082
+ fail(error);
6083
+ }
6084
+ });
6085
+ var probeJobs = probes.command("jobs").description("Create and claim private probe check jobs");
6086
+ 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) => {
6087
+ try {
6088
+ const svc = service();
6089
+ const job = svc.createProbeCheckJob({
6090
+ monitorId: opts.monitor,
6091
+ scheduleSlot: opts.scheduleSlot,
6092
+ dueAt: opts.dueAt
6093
+ });
6094
+ svc.close();
6095
+ print(job, `Created probe job ${job.id} for ${job.monitorId}`, opts);
6096
+ } catch (error) {
6097
+ fail(error);
6098
+ }
6099
+ });
6100
+ 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) => {
6101
+ try {
6102
+ const svc = service();
6103
+ const job = svc.claimProbeCheckJob({
6104
+ jobId,
6105
+ probeId: opts.probe,
6106
+ leaseTtlMs: opts.leaseMs
6107
+ });
6108
+ svc.close();
6109
+ print(job, `Claimed probe job ${job.id}`, opts);
6110
+ } catch (error) {
6111
+ fail(error);
6112
+ }
6113
+ });
6114
+ 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) => {
6115
+ try {
6116
+ const submission = buildProbeSubmission(opts);
6117
+ if (opts.apiUrl) {
6118
+ const response = await fetch(probeSubmitUrl(opts.apiUrl), {
6119
+ method: "POST",
6120
+ headers: {
6121
+ "content-type": "application/json",
6122
+ accept: "application/json",
6123
+ ...opts.token ? { authorization: `Bearer ${opts.token}` } : {}
6124
+ },
6125
+ body: JSON.stringify(submission)
6126
+ });
6127
+ const body = await response.json();
6128
+ print(body, response.ok ? `Submitted probe result for ${submission.monitorId}` : JSON.stringify(body), opts);
6129
+ if (!response.ok)
6130
+ process.exit(1);
6131
+ return;
6132
+ }
6133
+ const svc = service();
6134
+ const result = svc.submitProbeResult(submission);
6135
+ svc.close();
6136
+ print(result, `Submitted probe result for ${submission.monitorId}`, opts);
6137
+ } catch (error) {
6138
+ fail(error);
6139
+ }
6140
+ });
6141
+ program2.command("backup [path]").description("Create and verify a local SQLite backup").option("-j, --json", "print JSON").action((path, opts) => {
6142
+ try {
6143
+ const svc = service();
6144
+ const backup = svc.backup(path);
6145
+ const check = svc.verifyBackup(backup.backupPath);
6146
+ svc.close();
6147
+ const data = { ok: check.ok, backup, check };
6148
+ print(data, `Backed up ${backup.sourcePath} to ${backup.backupPath} (${backup.bytes} bytes)`, opts);
6149
+ if (!check.ok)
6150
+ process.exit(1);
6151
+ } catch (error) {
6152
+ fail(error);
6153
+ }
6154
+ });
6155
+ 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) => {
6156
+ try {
6157
+ if (!opts.yes)
6158
+ throw new Error("restore requires --yes");
6159
+ const restored = UptimeStore.restoreBackup(backupPath, opts.db);
6160
+ const check = UptimeStore.verifyBackup(opts.db);
6161
+ const data = { ok: check.ok, restored, check };
6162
+ print(data, `Restored ${backupPath} to ${opts.db}`, opts);
6163
+ if (!check.ok)
6164
+ process.exit(1);
6165
+ } catch (error) {
6166
+ fail(error);
6167
+ }
6168
+ });
6169
+ 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
6170
  try {
4324
6171
  const { server } = serveUptime({
4325
6172
  host: opts.host,
4326
6173
  port: opts.port,
4327
6174
  check: opts.check,
6175
+ mode: opts.mode,
4328
6176
  apiToken: opts.apiToken,
6177
+ hostedToken: opts.hostedToken,
6178
+ allowHostedLocalStore: opts.allowHostedLocalStore,
4329
6179
  allowUnsafeRemoteMutations: opts.allowUnsafeRemoteMutations
4330
6180
  });
4331
- const data = { ok: true, url: `http://${server.hostname}:${server.port}`, scheduler: Boolean(opts.check) };
6181
+ const data = { ok: true, url: `http://${server.hostname}:${server.port}`, scheduler: Boolean(opts.check), mode: opts.mode };
4332
6182
  if (wantsJson(opts))
4333
6183
  console.log(JSON.stringify(data, null, 2));
4334
6184
  else
@@ -4343,18 +6193,54 @@ function parseInteger(value) {
4343
6193
  throw new Error(`Expected integer, got ${value}`);
4344
6194
  return parsed;
4345
6195
  }
6196
+ function parseNumber(value) {
6197
+ const parsed = Number(value);
6198
+ if (!Number.isFinite(parsed))
6199
+ throw new Error(`Expected number, got ${value}`);
6200
+ return parsed;
6201
+ }
6202
+ function buildProbeSubmission(opts) {
6203
+ const input = {
6204
+ probeId: opts.probe,
6205
+ jobId: opts.job,
6206
+ scheduleSlot: opts.scheduleSlot,
6207
+ fencingToken: opts.fencingToken,
6208
+ monitorId: opts.monitor,
6209
+ nonce: opts.nonce ?? `cli_${randomUUID4()}`,
6210
+ checkedAt: opts.checkedAt,
6211
+ status: opts.status,
6212
+ latencyMs: opts.latency ?? null,
6213
+ statusCode: opts.statusCode,
6214
+ error: opts.error,
6215
+ attemptCount: opts.attempts,
6216
+ monitorRevision: opts.monitorRevision,
6217
+ evidence: null
6218
+ };
6219
+ return {
6220
+ ...input,
6221
+ signature: signProbeResult(input, readFileSync2(opts.privateKeyFile, "utf8"))
6222
+ };
6223
+ }
6224
+ function probeSubmitUrl(apiUrl) {
6225
+ const base = apiUrl.replace(/\/+$/, "");
6226
+ if (/\/api\/v1$/.test(base))
6227
+ return `${base}/probes/results`;
6228
+ if (/\/api$/.test(base))
6229
+ return `${base}/probes/results`;
6230
+ return `${base}/api/probes/results`;
6231
+ }
4346
6232
  function renderMonitors(monitors) {
4347
6233
  if (monitors.length === 0)
4348
6234
  return "No monitors";
4349
6235
  return monitors.map((monitor) => {
4350
- const target = monitor.kind === "http" ? monitor.url : `${monitor.host}:${monitor.port}`;
6236
+ const target = monitor.kind === "tcp" ? `${monitor.host}:${monitor.port}` : monitor.url;
4351
6237
  const status = renderStatus(monitor.status).padEnd(14);
4352
6238
  return `${status} ${sanitizeField(monitor.name).padEnd(24)} ${monitor.kind.padEnd(4)} ${sanitizeField(target ?? "")}`;
4353
6239
  }).join(`
4354
6240
  `);
4355
6241
  }
4356
6242
  function renderMonitorDetail(monitor) {
4357
- const target = monitor.kind === "http" ? monitor.url : `${monitor.host}:${monitor.port}`;
6243
+ const target = monitor.kind === "tcp" ? `${monitor.host}:${monitor.port}` : monitor.url;
4358
6244
  return [
4359
6245
  `${source_default.bold(sanitizeField(monitor.name))} ${renderStatus(monitor.status)}`,
4360
6246
  `id: ${monitor.id}`,
@@ -4377,6 +6263,24 @@ function renderCheckResults(results) {
4377
6263
  }).join(`
4378
6264
  `);
4379
6265
  }
6266
+ function parseImportPayload(opts) {
6267
+ if (opts.record && opts.file)
6268
+ throw new Error("Choose either --record or --file, not both");
6269
+ const raw = opts.record ?? (opts.file ? readFileSync2(opts.file, "utf8") : undefined);
6270
+ if (!raw)
6271
+ throw new Error("imports require --record or --file");
6272
+ const parsed = JSON.parse(raw);
6273
+ const records = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && Array.isArray(parsed.records) ? parsed.records : [parsed];
6274
+ return { source: opts.source, records };
6275
+ }
6276
+ function renderImportPreview(preview) {
6277
+ const rows = preview.items.map((item) => `${item.action.padEnd(9)} ${sanitizeField(item.candidate.name).padEnd(24)} ${item.candidate.kind}${item.reason ? ` ${sanitizeField(item.reason)}` : ""}`);
6278
+ return [`Import preview: ${renderImportTotals(preview.totals)}`, ...rows].join(`
6279
+ `);
6280
+ }
6281
+ function renderImportTotals(totals) {
6282
+ return Object.entries(totals).filter(([, count]) => count > 0).map(([action, count]) => `${action}=${count}`).join(" ") || "no changes";
6283
+ }
4380
6284
  function renderSummary(summary) {
4381
6285
  const lines = [
4382
6286
  `monitors: ${summary.totals.monitors} up: ${summary.totals.up} down: ${summary.totals.down} open incidents: ${summary.totals.openIncidents}`