@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/mcp/index.js CHANGED
@@ -14303,7 +14303,11 @@ async function runMonitorCheck(monitor, options = {}) {
14303
14303
  }
14304
14304
  if (monitor.kind === "http")
14305
14305
  return runHttpCheck(monitor, options.fetch ?? fetch);
14306
- return runTcpCheck(monitor);
14306
+ if (monitor.kind === "browser_page")
14307
+ return runBrowserPageCheck(monitor, { fetch: options.fetch, runner: options.browserPage });
14308
+ if (monitor.kind === "tcp")
14309
+ return runTcpCheck(monitor);
14310
+ return { status: "down", latencyMs: null, error: `unsupported monitor kind: ${monitor.kind ?? "unknown"}` };
14307
14311
  }
14308
14312
  async function runHttpCheck(monitor, fetchImpl = fetch) {
14309
14313
  if (!monitor.url)
@@ -14361,14 +14365,741 @@ async function runTcpCheck(monitor) {
14361
14365
  });
14362
14366
  });
14363
14367
  }
14368
+ async function runBrowserPageCheck(monitor, options = {}) {
14369
+ if (!monitor.url)
14370
+ return { status: "down", latencyMs: null, error: "missing url" };
14371
+ validateBrowserPageUrl(monitor.url);
14372
+ if (!options.runner) {
14373
+ const evidence = normalizeBrowserEvidence(monitor.url, {
14374
+ finalUrl: monitor.url,
14375
+ navigationStatus: null,
14376
+ pageErrors: ["browser_page checks require a configured browser runner"]
14377
+ });
14378
+ return {
14379
+ status: "down",
14380
+ latencyMs: null,
14381
+ statusCode: null,
14382
+ error: "browser_page checks require a configured browser runner",
14383
+ evidence
14384
+ };
14385
+ }
14386
+ const started = performance.now();
14387
+ try {
14388
+ const raw = await options.runner(monitor);
14389
+ const latencyMs = raw.latencyMs ?? Math.round((performance.now() - started) * 100) / 100;
14390
+ const evidence = normalizeBrowserEvidence(monitor.url, raw);
14391
+ const statusCode = raw.navigationStatus ?? evidence.navigationStatus;
14392
+ const statusOk = statusCode == null ? false : monitor.expectedStatus == null ? statusCode >= 200 && statusCode < 400 : statusCode === monitor.expectedStatus;
14393
+ const browserFailures = evidence.consoleErrors.length + evidence.pageErrors.length + evidence.failedRequests.length;
14394
+ return {
14395
+ status: statusOk && browserFailures === 0 ? "up" : "down",
14396
+ latencyMs,
14397
+ statusCode,
14398
+ error: statusOk ? browserFailures === 0 ? null : `browser page captured ${browserFailures} error signal${browserFailures === 1 ? "" : "s"}` : `unexpected navigation status ${statusCode ?? "unknown"}`,
14399
+ evidence
14400
+ };
14401
+ } catch (error51) {
14402
+ const safeError = redactText(error51 instanceof Error ? error51.message : String(error51));
14403
+ const evidence = normalizeBrowserEvidence(monitor.url, {
14404
+ finalUrl: monitor.url,
14405
+ navigationStatus: null,
14406
+ pageErrors: [safeError]
14407
+ });
14408
+ return {
14409
+ status: "down",
14410
+ latencyMs: Math.round((performance.now() - started) * 100) / 100,
14411
+ statusCode: null,
14412
+ error: safeError,
14413
+ evidence
14414
+ };
14415
+ }
14416
+ }
14417
+ function normalizeBrowserEvidence(sourceUrl, raw) {
14418
+ return {
14419
+ kind: "browser_page",
14420
+ finalUrl: raw.finalUrl ? redactUrl(raw.finalUrl) : redactUrl(sourceUrl),
14421
+ navigationStatus: raw.navigationStatus ?? null,
14422
+ consoleErrors: sanitizeStrings(raw.consoleErrors ?? []),
14423
+ pageErrors: sanitizeStrings(raw.pageErrors ?? []),
14424
+ failedRequests: (raw.failedRequests ?? []).slice(0, 50).map((request) => ({
14425
+ url: redactUrl(request.url),
14426
+ statusCode: request.statusCode ?? null,
14427
+ error: request.error ? redactText(request.error) : null
14428
+ })),
14429
+ screenshot: raw.screenshot ? sanitizeArtifact(raw.screenshot) : null,
14430
+ artifacts: (raw.artifacts ?? []).slice(0, 20).map(sanitizeArtifact),
14431
+ redacted: true,
14432
+ redactionStatus: "redacted",
14433
+ retentionClass: "short"
14434
+ };
14435
+ }
14436
+ function validateBrowserPageUrl(value) {
14437
+ const parsed = new URL(value);
14438
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
14439
+ throw new Error("browser_page monitors require an http or https URL");
14440
+ }
14441
+ if (parsed.username || parsed.password) {
14442
+ throw new Error("browser_page URLs must not contain userinfo");
14443
+ }
14444
+ }
14445
+ function sanitizeStrings(values) {
14446
+ return values.slice(0, 50).map(redactText).filter(Boolean);
14447
+ }
14448
+ function sanitizeArtifact(artifact) {
14449
+ const ref = artifact.ref.trim();
14450
+ if (artifact.path || ref.startsWith("/") || ref.toLowerCase().startsWith("file:")) {
14451
+ throw new Error("browser evidence artifacts must use redacted artifact refs, not local paths");
14452
+ }
14453
+ if (!artifact.sha256 || !/^[a-f0-9]{64}$/i.test(artifact.sha256)) {
14454
+ throw new Error("browser evidence artifacts require a sha256 checksum");
14455
+ }
14456
+ const bytes = artifact.bytes;
14457
+ if (!Number.isInteger(bytes) || bytes == null || bytes < 0) {
14458
+ throw new Error("browser evidence artifacts require a byte size");
14459
+ }
14460
+ return {
14461
+ ref: redactText(ref),
14462
+ sha256: artifact.sha256,
14463
+ bytes,
14464
+ contentType: redactText(artifact.contentType ?? "application/octet-stream") || "application/octet-stream",
14465
+ retentionClass: "short"
14466
+ };
14467
+ }
14468
+ function redactUrl(value) {
14469
+ try {
14470
+ const parsed = new URL(value);
14471
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
14472
+ return "[blocked-url]";
14473
+ }
14474
+ parsed.username = "";
14475
+ parsed.password = "";
14476
+ parsed.hash = "";
14477
+ for (const key of parsed.searchParams.keys()) {
14478
+ if (isSecretKey(key))
14479
+ parsed.searchParams.set(key, "[redacted]");
14480
+ }
14481
+ return parsed.toString();
14482
+ } catch {
14483
+ return redactText(value);
14484
+ }
14485
+ }
14486
+ function redactText(value) {
14487
+ return value.replace(/\/(?:home|Users)\/[^\s"'<>]+/g, "[local-path]").replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]").replace(/((?:token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)[=:]\s*)[^\s&]+/gi, "$1[redacted]");
14488
+ }
14489
+ function isSecretKey(value) {
14490
+ return /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i.test(value);
14491
+ }
14364
14492
 
14365
14493
  // src/service.ts
14366
- import { randomUUID as randomUUID2 } from "crypto";
14494
+ import { createPublicKey, randomUUID as randomUUID3 } from "crypto";
14367
14495
 
14368
- // src/store.ts
14369
- import { mkdirSync } from "fs";
14370
- import { dirname } from "path";
14496
+ // src/imports.ts
14371
14497
  import { randomUUID } from "crypto";
14498
+
14499
+ // src/limits.ts
14500
+ var MIN_INTERVAL_SECONDS = 1;
14501
+ var MAX_INTERVAL_SECONDS = 86400;
14502
+ var MIN_TIMEOUT_MS = 1;
14503
+ var MAX_TIMEOUT_MS = 60000;
14504
+ var MIN_RETRY_COUNT = 0;
14505
+ var MAX_RETRY_COUNT = 10;
14506
+ var MAX_RESULT_LIMIT = 1000;
14507
+
14508
+ // src/target-policy.ts
14509
+ import net2 from "net";
14510
+ var SECRET_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
14511
+ function assertHostedTargetAllowed(target) {
14512
+ if (target.kind === "http" || target.kind === "browser_page") {
14513
+ if (!target.url)
14514
+ throw new Error("HTTP monitors require url");
14515
+ assertHostedHttpUrlAllowed(target.url);
14516
+ return;
14517
+ }
14518
+ if (target.kind === "tcp") {
14519
+ if (!target.host)
14520
+ throw new Error("TCP monitors require host");
14521
+ assertHostedHostAllowed(target.host, "TCP host");
14522
+ if (!Number.isInteger(target.port) || target.port <= 0 || target.port > 65535) {
14523
+ throw new Error("TCP monitors require a port from 1 to 65535");
14524
+ }
14525
+ return;
14526
+ }
14527
+ throw new Error("Monitor kind must be http, tcp, or browser_page");
14528
+ }
14529
+ function assertHostedHttpUrlAllowed(value) {
14530
+ const parsed = new URL(value);
14531
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
14532
+ throw new Error("HTTP monitor url must use http or https");
14533
+ }
14534
+ if (parsed.username || parsed.password) {
14535
+ throw new Error("hosted target URLs must not contain userinfo");
14536
+ }
14537
+ for (const key of parsed.searchParams.keys()) {
14538
+ if (SECRET_PARAM_PATTERN.test(key)) {
14539
+ throw new Error(`hosted target URL query parameter is not allowed: ${key}`);
14540
+ }
14541
+ }
14542
+ if (parsed.hash && SECRET_PARAM_PATTERN.test(parsed.hash)) {
14543
+ throw new Error("hosted target URL fragment contains secret-like data");
14544
+ }
14545
+ assertHostedHostAllowed(parsed.hostname, "HTTP host");
14546
+ }
14547
+ function assertHostedHostAllowed(hostname3, label = "host") {
14548
+ const host = normalizeHost(hostname3);
14549
+ if (!host)
14550
+ throw new Error(`${label} is required`);
14551
+ if (host === "localhost" || host.endsWith(".localhost")) {
14552
+ throw new Error(`${label} is not allowed in hosted mode: localhost`);
14553
+ }
14554
+ if (host.endsWith(".local") || host.endsWith(".internal")) {
14555
+ throw new Error(`${label} is not allowed in hosted mode: private DNS name`);
14556
+ }
14557
+ const ipVersion = net2.isIP(host);
14558
+ if (ipVersion === 4 && isDeniedIpv4(host)) {
14559
+ throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv4`);
14560
+ }
14561
+ if (ipVersion === 6 && isDeniedIpv6(host)) {
14562
+ throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv6`);
14563
+ }
14564
+ }
14565
+ function normalizeHost(hostname3) {
14566
+ return hostname3.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
14567
+ }
14568
+ function isDeniedIpv4(ip) {
14569
+ const parts = ip.split(".").map((part) => Number(part));
14570
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
14571
+ return true;
14572
+ }
14573
+ const [a, b] = parts;
14574
+ return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a >= 224;
14575
+ }
14576
+ function isDeniedIpv6(ip) {
14577
+ const normalized = ip.toLowerCase();
14578
+ return normalized === "::" || normalized === "::1" || normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("ff") || normalized.startsWith("::ffff:127.") || normalized.startsWith("::ffff:10.") || normalized.startsWith("::ffff:169.254.") || /^::ffff:172\.(1[6-9]|2\d|3[0-1])\./.test(normalized) || normalized.startsWith("::ffff:192.168.");
14579
+ }
14580
+
14581
+ // src/imports.ts
14582
+ function previewImport(store, request) {
14583
+ const source = normalizeSource(request.source);
14584
+ const items = dedupePreviewItems(request.records.map((record2) => previewRecord(store, source, record2, request.defaults ?? {})));
14585
+ return {
14586
+ source,
14587
+ generatedAt: new Date().toISOString(),
14588
+ dryRun: true,
14589
+ items,
14590
+ totals: countActions(items)
14591
+ };
14592
+ }
14593
+ function dedupePreviewItems(items) {
14594
+ const seenSources = new Set;
14595
+ const seenNames = new Set;
14596
+ return items.map((item) => {
14597
+ if (item.action === "blocked")
14598
+ return item;
14599
+ const sourceKey = `${item.candidate.source}:${item.candidate.sourceId}`;
14600
+ const nameKey = item.candidate.name.toLowerCase();
14601
+ if (seenSources.has(sourceKey) || seenNames.has(nameKey)) {
14602
+ return {
14603
+ ...item,
14604
+ action: "conflict",
14605
+ monitor: item.monitor,
14606
+ warnings: [...item.warnings, "duplicate import candidate in request"],
14607
+ reason: "duplicate import candidate in request"
14608
+ };
14609
+ }
14610
+ seenSources.add(sourceKey);
14611
+ seenNames.add(nameKey);
14612
+ return item;
14613
+ });
14614
+ }
14615
+ function applyImport(store, request) {
14616
+ if (store.mode === "hosted") {
14617
+ throw new Error("hosted import apply requires cloud import_batches and audit");
14618
+ }
14619
+ const execute = () => {
14620
+ const preview = previewImport(store, request);
14621
+ const appliedAt = new Date().toISOString();
14622
+ const items = preview.items.map((item) => applyPreviewItem(store, item));
14623
+ const batchId = `imp_${randomUUID().replace(/-/g, "").slice(0, 18)}`;
14624
+ store.saveImportBatch({
14625
+ id: batchId,
14626
+ source: preview.source,
14627
+ records: items.map((item) => ({
14628
+ action: item.action,
14629
+ sourceId: item.candidate.sourceId,
14630
+ monitorId: item.after?.id ?? item.monitor?.id ?? item.before?.id ?? null,
14631
+ before: item.before,
14632
+ after: item.after,
14633
+ candidate: item.candidate
14634
+ }))
14635
+ });
14636
+ return { batchId, source: preview.source, appliedAt, items, totals: countActions(items) };
14637
+ };
14638
+ return store.runInTransaction ? store.runInTransaction(execute) : execute();
14639
+ }
14640
+ function rollbackImport(store, batchId) {
14641
+ if (store.mode === "hosted") {
14642
+ throw new Error("hosted import rollback requires cloud import_batches and audit");
14643
+ }
14644
+ const batch = store.getImportBatch(batchId);
14645
+ if (!batch)
14646
+ throw new Error(`Import batch not found: ${batchId}`);
14647
+ if (batch.status === "rolled_back")
14648
+ throw new Error(`Import batch already rolled back: ${batchId}`);
14649
+ const items = [...batch.records].reverse().map((record2) => rollbackRecord(store, record2));
14650
+ const rolledBack = store.markImportBatchRolledBack(batchId);
14651
+ return {
14652
+ batchId,
14653
+ source: rolledBack.source,
14654
+ rolledBackAt: rolledBack.rolledBackAt ?? new Date().toISOString(),
14655
+ items
14656
+ };
14657
+ }
14658
+ function previewRecord(store, source, record2, defaults) {
14659
+ const warnings = [];
14660
+ let candidate;
14661
+ try {
14662
+ if (store.mode === "hosted")
14663
+ assertHostedTargetAllowed(rawTargetForHostedPolicy(source, record2, defaults));
14664
+ candidate = normalizeCandidate(source, record2, defaults);
14665
+ validateCandidate(candidate);
14666
+ if (store.mode === "hosted")
14667
+ assertHostedTargetAllowed(candidate);
14668
+ } catch (error51) {
14669
+ return {
14670
+ candidate: fallbackCandidate(source, record2),
14671
+ action: "blocked",
14672
+ monitor: null,
14673
+ provenance: null,
14674
+ warnings,
14675
+ reason: error51 instanceof Error ? error51.message : String(error51)
14676
+ };
14677
+ }
14678
+ const provenance = store.getProvenance(candidate.source, candidate.sourceId);
14679
+ const monitor = provenance ? store.getMonitor(provenance.monitorId) : store.getMonitor(candidate.name);
14680
+ if (provenance && !monitor) {
14681
+ return { candidate, action: "create", monitor: null, provenance, warnings: ["source provenance points to a missing monitor"], reason: null };
14682
+ }
14683
+ if (provenance && monitor) {
14684
+ const nameOwner = store.getMonitor(candidate.name);
14685
+ if (nameOwner && nameOwner.id !== monitor.id) {
14686
+ return {
14687
+ candidate,
14688
+ action: "conflict",
14689
+ monitor,
14690
+ provenance,
14691
+ warnings,
14692
+ reason: "monitor name already exists on another monitor"
14693
+ };
14694
+ }
14695
+ return {
14696
+ candidate,
14697
+ action: sameTarget(monitor, candidate) ? "unchanged" : "update",
14698
+ monitor,
14699
+ provenance,
14700
+ warnings,
14701
+ reason: null
14702
+ };
14703
+ }
14704
+ if (monitor) {
14705
+ return {
14706
+ candidate,
14707
+ action: "conflict",
14708
+ monitor,
14709
+ provenance: null,
14710
+ warnings,
14711
+ reason: "monitor name already exists without matching source provenance"
14712
+ };
14713
+ }
14714
+ return { candidate, action: "create", monitor: null, provenance: null, warnings, reason: null };
14715
+ }
14716
+ function applyPreviewItem(store, item) {
14717
+ if (item.action === "blocked" || item.action === "conflict") {
14718
+ return { ...item, before: item.monitor, after: item.monitor };
14719
+ }
14720
+ const input = candidateToMonitorInput(item.candidate);
14721
+ const before = item.monitor;
14722
+ const after = item.action === "create" ? store.createMonitor(input, { allowBrowserPage: true }) : item.action === "update" ? store.updateMonitor(item.monitor.id, input, { allowBrowserPage: true }) : item.monitor;
14723
+ if (after) {
14724
+ store.upsertMonitorProvenance({
14725
+ monitorId: after.id,
14726
+ source: item.candidate.source,
14727
+ sourceId: item.candidate.sourceId,
14728
+ sourceLabel: item.candidate.sourceLabel,
14729
+ snapshot: item.candidate.snapshot
14730
+ });
14731
+ }
14732
+ return { ...item, before, after };
14733
+ }
14734
+ function rollbackRecord(store, record2) {
14735
+ const value = asRecord(record2);
14736
+ const action = stringValue(value.action);
14737
+ const monitorId = stringValue(value.monitorId);
14738
+ const before = isMonitor(value.before) ? value.before : null;
14739
+ const after = isMonitor(value.after) ? value.after : null;
14740
+ const targetId = after?.id ?? before?.id ?? monitorId;
14741
+ if (!targetId)
14742
+ return { monitorId: null, action: "skipped", reason: "batch record has no monitor id" };
14743
+ if (action === "create") {
14744
+ const hasHistory = store.listResults({ monitorId: targetId, limit: 1 }).length > 0;
14745
+ if (hasHistory) {
14746
+ store.updateMonitor(targetId, { enabled: false }, { allowBrowserPage: true });
14747
+ return { monitorId: targetId, action: "disabled", reason: "created monitor has check history, so rollback preserved history and disabled it" };
14748
+ }
14749
+ return { monitorId: targetId, action: store.deleteMonitor(targetId) ? "deleted" : "skipped", reason: null };
14750
+ }
14751
+ if (action === "update" && before) {
14752
+ store.updateMonitor(targetId, monitorToUpdateInput(before), { allowBrowserPage: true });
14753
+ return { monitorId: targetId, action: "restored", reason: null };
14754
+ }
14755
+ return { monitorId: targetId, action: "skipped", reason: `no rollback needed for ${action || "unknown"} action` };
14756
+ }
14757
+ function normalizeCandidate(source, record2, defaults) {
14758
+ const value = asRecord(record2);
14759
+ const monitor = asRecord(value.monitor);
14760
+ const sourceId = sanitizeIdentity(stringValue(value.sourceId) ?? stringValue(value.id) ?? stringValue(value.slug) ?? stringValue(value.name));
14761
+ let url2 = stringValue(monitor.url) ?? stringValue(value.url) ?? stringValue(value.healthUrl) ?? stringValue(value.homepageUrl) ?? stringValue(value.environmentUrl);
14762
+ if (source === "domains" && !url2 && stringValue(value.domain)) {
14763
+ url2 = `https://${stringValue(value.domain)}`;
14764
+ }
14765
+ const rawHost = stringValue(monitor.host) ?? stringValue(value.host) ?? stringValue(value.hostname);
14766
+ const rawKind = stringValue(monitor.kind) ?? stringValue(value.kind) ?? (url2 ? "http" : "tcp");
14767
+ const kind = normalizeKind(rawKind);
14768
+ const normalizedUrl = normalizeCandidateUrl(url2 ?? defaults.url);
14769
+ const normalizedHost = kind === "tcp" ? rawHost ?? defaults.host : undefined;
14770
+ const port = numberValue(monitor.port) ?? numberValue(value.port) ?? defaults.port;
14771
+ const normalizedTargetKey = sanitizeGeneratedTargetKey(kind, normalizedUrl, normalizedHost, port);
14772
+ const normalizedSourceId = sourceId ?? `${source}:${normalizedTargetKey}`;
14773
+ const name = stringValue(monitor.name) ?? stringValue(value.monitorName) ?? stringValue(value.name) ?? stringValue(value.slug) ?? (source === "domains" ? stringValue(value.domain) : undefined) ?? (kind === "tcp" ? stringValue(value.hostname) : undefined) ?? `${source}-${normalizedTargetKey}`;
14774
+ const expectedStatus = firstDefined(nullableNumberValue(monitor.expectedStatus), nullableNumberValue(value.expectedStatus), defaults.expectedStatus);
14775
+ const candidate = {
14776
+ source,
14777
+ sourceId: normalizedSourceId,
14778
+ sourceLabel: sanitizeIdentity(stringValue(value.label) ?? stringValue(value.name) ?? stringValue(value.slug)) ?? null,
14779
+ name: sanitizeIdentity(name) ?? name,
14780
+ kind,
14781
+ url: normalizedUrl,
14782
+ host: normalizedHost,
14783
+ port,
14784
+ method: normalizeCandidateMethod(stringValue(monitor.method) ?? stringValue(value.method) ?? defaults.method),
14785
+ expectedStatus,
14786
+ intervalSeconds: numberValue(monitor.intervalSeconds) ?? numberValue(value.intervalSeconds) ?? defaults.intervalSeconds,
14787
+ timeoutMs: numberValue(monitor.timeoutMs) ?? numberValue(value.timeoutMs) ?? defaults.timeoutMs,
14788
+ retryCount: numberValue(monitor.retryCount) ?? numberValue(value.retryCount) ?? defaults.retryCount,
14789
+ enabled: booleanValue(monitor.enabled) ?? booleanValue(value.enabled) ?? defaults.enabled,
14790
+ snapshot: sanitizeSnapshot(record2)
14791
+ };
14792
+ return candidate;
14793
+ }
14794
+ function rawTargetForHostedPolicy(source, record2, defaults) {
14795
+ const value = asRecord(record2);
14796
+ const monitor = asRecord(value.monitor);
14797
+ let url2 = stringValue(monitor.url) ?? stringValue(value.url) ?? stringValue(value.healthUrl) ?? stringValue(value.homepageUrl) ?? stringValue(value.environmentUrl);
14798
+ if (source === "domains" && !url2 && stringValue(value.domain)) {
14799
+ url2 = `https://${stringValue(value.domain)}`;
14800
+ }
14801
+ const host = stringValue(monitor.host) ?? stringValue(value.host) ?? stringValue(value.hostname) ?? defaults.host;
14802
+ const rawKind = stringValue(monitor.kind) ?? stringValue(value.kind) ?? (url2 ? "http" : "tcp");
14803
+ const kind = normalizeKind(rawKind);
14804
+ return {
14805
+ kind,
14806
+ url: url2 ?? defaults.url,
14807
+ host: kind === "tcp" ? host : undefined,
14808
+ port: numberValue(monitor.port) ?? numberValue(value.port) ?? defaults.port
14809
+ };
14810
+ }
14811
+ function validateCandidate(candidate) {
14812
+ if (!candidate.name.trim())
14813
+ throw new Error("import candidate requires name");
14814
+ rejectControlCharacters(candidate.name.trim(), "Monitor name");
14815
+ if (candidate.method !== undefined && !/^[A-Z]+$/.test(candidate.method)) {
14816
+ throw new Error("HTTP method must contain only letters");
14817
+ }
14818
+ if (candidate.expectedStatus !== undefined && candidate.expectedStatus !== null) {
14819
+ if (!Number.isInteger(candidate.expectedStatus) || candidate.expectedStatus < 100 || candidate.expectedStatus > 599) {
14820
+ throw new Error("expectedStatus must be an HTTP status from 100 to 599");
14821
+ }
14822
+ }
14823
+ if (candidate.intervalSeconds !== undefined) {
14824
+ boundedInteger(candidate.intervalSeconds, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS);
14825
+ }
14826
+ if (candidate.timeoutMs !== undefined) {
14827
+ boundedInteger(candidate.timeoutMs, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS);
14828
+ }
14829
+ if (candidate.retryCount !== undefined) {
14830
+ boundedInteger(candidate.retryCount, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT);
14831
+ }
14832
+ if (candidate.kind === "http" || candidate.kind === "browser_page") {
14833
+ if (!candidate.url)
14834
+ throw new Error(`${candidate.kind} import candidate requires url`);
14835
+ const parsed = new URL(candidate.url);
14836
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
14837
+ throw new Error(`${candidate.kind} import candidate URL must use http or https`);
14838
+ }
14839
+ if (parsed.username || parsed.password)
14840
+ throw new Error(`${candidate.kind} import candidate URL must not contain userinfo`);
14841
+ return;
14842
+ }
14843
+ if (candidate.kind === "tcp") {
14844
+ if (!candidate.host)
14845
+ throw new Error("tcp import candidate requires host");
14846
+ rejectControlCharacters(candidate.host, "TCP host");
14847
+ if (!Number.isInteger(candidate.port) || candidate.port <= 0 || candidate.port > 65535) {
14848
+ throw new Error("tcp import candidate requires a port from 1 to 65535");
14849
+ }
14850
+ return;
14851
+ }
14852
+ throw new Error(`unsupported import candidate kind: ${candidate.kind}`);
14853
+ }
14854
+ function candidateToMonitorInput(candidate) {
14855
+ return {
14856
+ name: candidate.name,
14857
+ kind: candidate.kind,
14858
+ url: candidate.url,
14859
+ host: candidate.host,
14860
+ port: candidate.port,
14861
+ method: candidate.method,
14862
+ expectedStatus: candidate.expectedStatus,
14863
+ intervalSeconds: candidate.intervalSeconds,
14864
+ timeoutMs: candidate.timeoutMs,
14865
+ retryCount: candidate.retryCount,
14866
+ enabled: candidate.enabled
14867
+ };
14868
+ }
14869
+ function monitorToUpdateInput(monitor) {
14870
+ return {
14871
+ name: monitor.name,
14872
+ kind: monitor.kind,
14873
+ url: monitor.url ?? undefined,
14874
+ host: monitor.host ?? undefined,
14875
+ port: monitor.port ?? undefined,
14876
+ method: monitor.method,
14877
+ expectedStatus: monitor.expectedStatus,
14878
+ intervalSeconds: monitor.intervalSeconds,
14879
+ timeoutMs: monitor.timeoutMs,
14880
+ retryCount: monitor.retryCount,
14881
+ enabled: monitor.enabled
14882
+ };
14883
+ }
14884
+ function sameTarget(monitor, candidate) {
14885
+ return monitor.kind === candidate.kind && monitor.name === candidate.name && monitor.url === (candidate.url ?? null) && monitor.host === (candidate.host ?? null) && monitor.port === (candidate.port ?? null) && monitor.method === (candidate.method ?? monitor.method) && (candidate.expectedStatus === undefined || monitor.expectedStatus === candidate.expectedStatus) && monitor.intervalSeconds === (candidate.intervalSeconds ?? monitor.intervalSeconds) && monitor.timeoutMs === (candidate.timeoutMs ?? monitor.timeoutMs) && monitor.retryCount === (candidate.retryCount ?? monitor.retryCount) && monitor.enabled === (candidate.enabled ?? monitor.enabled);
14886
+ }
14887
+ function countActions(items) {
14888
+ return {
14889
+ create: items.filter((item) => item.action === "create").length,
14890
+ update: items.filter((item) => item.action === "update").length,
14891
+ unchanged: items.filter((item) => item.action === "unchanged").length,
14892
+ blocked: items.filter((item) => item.action === "blocked").length,
14893
+ conflict: items.filter((item) => item.action === "conflict").length
14894
+ };
14895
+ }
14896
+ function normalizeSource(source) {
14897
+ if (["manual", "projects", "servers", "domains", "deployment"].includes(source))
14898
+ return source;
14899
+ throw new Error(`unsupported import source: ${source}`);
14900
+ }
14901
+ function normalizeKind(value) {
14902
+ if (value === "http" || value === "tcp" || value === "browser_page")
14903
+ return value;
14904
+ return value === "browser" || value === "page" ? "browser_page" : "http";
14905
+ }
14906
+ function targetKey(kind, url2, host, port) {
14907
+ return kind === "tcp" ? `${host ?? "host"}:${port ?? "port"}` : url2 ?? "url";
14908
+ }
14909
+ function sanitizeGeneratedTargetKey(kind, url2, host, port) {
14910
+ const key = targetKey(kind, url2, host, port);
14911
+ return kind === "tcp" ? key : sanitizeIdentity(key) ?? key;
14912
+ }
14913
+ function normalizeCandidateUrl(value) {
14914
+ if (!value)
14915
+ return;
14916
+ try {
14917
+ const parsed = new URL(value);
14918
+ for (const key of [...parsed.searchParams.keys()]) {
14919
+ if (isSecretKey2(key))
14920
+ parsed.searchParams.set(key, "[redacted]");
14921
+ }
14922
+ parsed.hash = "";
14923
+ return parsed.toString();
14924
+ } catch {
14925
+ return value;
14926
+ }
14927
+ }
14928
+ function normalizeCandidateMethod(value) {
14929
+ return value?.trim().toUpperCase();
14930
+ }
14931
+ function fallbackCandidate(source, record2) {
14932
+ const value = asRecord(record2);
14933
+ const monitor = asRecord(value.monitor);
14934
+ const name = stringValue(monitor.name) ?? stringValue(value.name) ?? stringValue(value.domain) ?? "invalid import candidate";
14935
+ const rawUrl = stringValue(monitor.url) ?? stringValue(value.url) ?? stringValue(value.domain);
14936
+ const kind = normalizeKind(stringValue(monitor.kind) ?? stringValue(value.kind) ?? "http");
14937
+ return {
14938
+ source,
14939
+ sourceId: sanitizeIdentity(stringValue(value.sourceId) ?? stringValue(value.id)) ?? `${source}:invalid`,
14940
+ sourceLabel: sanitizeIdentity(stringValue(value.label)) ?? null,
14941
+ name: sanitizeIdentity(name) ?? name,
14942
+ kind,
14943
+ url: redactUrlForDisplay(rawUrl),
14944
+ host: kind === "tcp" ? sanitizeHost(stringValue(monitor.host) ?? stringValue(value.host) ?? stringValue(value.hostname)) : undefined,
14945
+ port: numberValue(monitor.port) ?? numberValue(value.port),
14946
+ snapshot: sanitizeSnapshot(record2)
14947
+ };
14948
+ }
14949
+ function asRecord(value) {
14950
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
14951
+ }
14952
+ function stringValue(value) {
14953
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
14954
+ }
14955
+ function numberValue(value) {
14956
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
14957
+ }
14958
+ function nullableNumberValue(value) {
14959
+ if (value === null)
14960
+ return null;
14961
+ return numberValue(value);
14962
+ }
14963
+ function booleanValue(value) {
14964
+ return typeof value === "boolean" ? value : undefined;
14965
+ }
14966
+ function firstDefined(...values) {
14967
+ return values.find((value) => value !== undefined);
14968
+ }
14969
+ function isMonitor(value) {
14970
+ const row = asRecord(value);
14971
+ return Boolean(stringValue(row.id) && stringValue(row.name) && stringValue(row.kind));
14972
+ }
14973
+ function sanitizeSnapshot(value) {
14974
+ if (Array.isArray(value))
14975
+ return value.map(sanitizeSnapshot);
14976
+ if (!value || typeof value !== "object")
14977
+ return sanitizeScalar(value);
14978
+ const output = {};
14979
+ for (const [key, entry] of Object.entries(value)) {
14980
+ if (isSecretKey2(key))
14981
+ output[key] = "[redacted]";
14982
+ else
14983
+ output[key] = sanitizeSnapshot(entry);
14984
+ }
14985
+ return output;
14986
+ }
14987
+ function sanitizeScalar(value) {
14988
+ if (typeof value !== "string")
14989
+ return value;
14990
+ return value.replace(/\/(?:home|Users)\/[^\s"'<>]+/g, "[local-path]").replace(/\b(?:localhost|(?:[a-z0-9-]+\.)+(?:local|internal))\b/gi, "[private-host]").replace(/(https?:\/\/)[^/?#\s"'<>]+:[^@/?#\s"'<>]+@/gi, "$1[redacted]@").replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]").replace(/((?:token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)[=:]\s*)[^\s&]+/gi, "$1[redacted]");
14991
+ }
14992
+ function isSecretKey2(value) {
14993
+ return /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i.test(value);
14994
+ }
14995
+ function rejectControlCharacters(value, label) {
14996
+ if (/[\x00-\x1f\x7f-\x9f]/.test(value)) {
14997
+ throw new Error(`${label} must not contain control characters`);
14998
+ }
14999
+ }
15000
+ function boundedInteger(value, label, min, max) {
15001
+ if (!Number.isInteger(value) || value < min || value > max) {
15002
+ throw new Error(`${label} must be an integer from ${min} to ${max}`);
15003
+ }
15004
+ return value;
15005
+ }
15006
+ function redactUrlForDisplay(value) {
15007
+ if (!value)
15008
+ return;
15009
+ try {
15010
+ const parsed = new URL(value);
15011
+ parsed.username = parsed.username ? "[redacted]" : "";
15012
+ parsed.password = "";
15013
+ for (const key of [...parsed.searchParams.keys()]) {
15014
+ if (isSecretKey2(key))
15015
+ parsed.searchParams.set(key, "[redacted]");
15016
+ }
15017
+ if (parsed.hash && isSecretKey2(parsed.hash))
15018
+ parsed.hash = "#[redacted]";
15019
+ return parsed.toString();
15020
+ } catch {
15021
+ return sanitizeScalar(value);
15022
+ }
15023
+ }
15024
+ function sanitizeIdentity(value) {
15025
+ if (!value)
15026
+ return;
15027
+ try {
15028
+ const parsed = new URL(value);
15029
+ parsed.username = parsed.username ? "[redacted]" : "";
15030
+ parsed.password = "";
15031
+ for (const key of [...parsed.searchParams.keys()]) {
15032
+ if (isSecretKey2(key))
15033
+ parsed.searchParams.set(key, "[redacted]");
15034
+ }
15035
+ parsed.hash = "";
15036
+ return parsed.toString();
15037
+ } catch {
15038
+ return sanitizeScalar(value);
15039
+ }
15040
+ }
15041
+ function sanitizeHost(value) {
15042
+ if (!value)
15043
+ return;
15044
+ return sanitizeScalar(value);
15045
+ }
15046
+
15047
+ // src/probes.ts
15048
+ import { createHash, generateKeyPairSync, sign, verify } from "crypto";
15049
+ function generateProbeKeyPair() {
15050
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
15051
+ const publicKeyPem = publicKey.export({ format: "pem", type: "spki" }).toString();
15052
+ const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" }).toString();
15053
+ return {
15054
+ publicKeyPem,
15055
+ privateKeyPem,
15056
+ publicKeyFingerprint: probePublicKeyFingerprint(publicKeyPem)
15057
+ };
15058
+ }
15059
+ function probePublicKeyFingerprint(publicKeyPem) {
15060
+ return createHash("sha256").update(publicKeyPem.trim()).digest("hex");
15061
+ }
15062
+ function verifyProbeResultSignature(input, publicKeyPem) {
15063
+ try {
15064
+ return verify(null, Buffer.from(probeResultSigningPayload(input)), publicKeyPem, Buffer.from(input.signature, "base64url"));
15065
+ } catch {
15066
+ return false;
15067
+ }
15068
+ }
15069
+ function probeResultSigningPayload(input) {
15070
+ return stableJson({
15071
+ version: "open-uptime.probe-result.v1",
15072
+ probeId: input.probeId,
15073
+ jobId: input.jobId,
15074
+ scheduleSlot: input.scheduleSlot,
15075
+ fencingToken: input.fencingToken,
15076
+ monitorId: input.monitorId,
15077
+ nonce: input.nonce,
15078
+ checkedAt: input.checkedAt,
15079
+ status: input.status,
15080
+ latencyMs: input.latencyMs,
15081
+ statusCode: input.statusCode ?? null,
15082
+ error: input.error ?? null,
15083
+ attemptCount: input.attemptCount ?? 1,
15084
+ monitorRevision: input.monitorRevision,
15085
+ evidenceSha256: createHash("sha256").update(stableJson(input.evidence ?? null)).digest("hex")
15086
+ });
15087
+ }
15088
+ function stableJson(value) {
15089
+ if (value === undefined)
15090
+ return "null";
15091
+ if (value === null || typeof value !== "object")
15092
+ return JSON.stringify(value);
15093
+ if (Array.isArray(value))
15094
+ return `[${value.map(stableJson).join(",")}]`;
15095
+ const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== undefined).sort(([left], [right]) => left.localeCompare(right));
15096
+ return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJson(entryValue)}`).join(",")}}`;
15097
+ }
15098
+
15099
+ // src/store.ts
15100
+ import { copyFileSync, existsSync, mkdirSync, statSync } from "fs";
15101
+ import { dirname, join as join2 } from "path";
15102
+ import { randomUUID as randomUUID2 } from "crypto";
14372
15103
  import { Database } from "bun:sqlite";
14373
15104
 
14374
15105
  // src/paths.ts
@@ -14380,17 +15111,16 @@ function uptimeHome() {
14380
15111
  function uptimeDbPath() {
14381
15112
  return process.env.HASNA_UPTIME_DB || join(uptimeHome(), "uptime.db");
14382
15113
  }
14383
-
14384
- // src/limits.ts
14385
- var MIN_INTERVAL_SECONDS = 1;
14386
- var MAX_INTERVAL_SECONDS = 86400;
14387
- var MIN_TIMEOUT_MS = 1;
14388
- var MAX_TIMEOUT_MS = 60000;
14389
- var MIN_RETRY_COUNT = 0;
14390
- var MAX_RETRY_COUNT = 10;
14391
- var MAX_RESULT_LIMIT = 1000;
15114
+ function uptimeHostedFallbackDbPath() {
15115
+ return process.env.HASNA_UPTIME_HOSTED_FALLBACK_DB || join(uptimeHome(), "hosted-fallback", "uptime.db");
15116
+ }
14392
15117
 
14393
15118
  // src/store.ts
15119
+ var SECRET_URL_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
15120
+ var REQUIRED_TABLES = ["schema_migrations", "monitors", "check_results", "incidents", "check_leases", "monitor_provenance", "import_batches", "probe_identities", "probe_check_jobs", "probe_submissions"];
15121
+ var PROBE_TABLES = new Set(["probe_identities", "probe_check_jobs", "probe_submissions"]);
15122
+ var CURRENT_SCHEMA_VERSION = "2";
15123
+
14394
15124
  class StaleCheckResultError extends Error {
14395
15125
  constructor(message) {
14396
15126
  super(message);
@@ -14400,9 +15130,20 @@ class StaleCheckResultError extends Error {
14400
15130
 
14401
15131
  class UptimeStore {
14402
15132
  dbPath;
15133
+ mode;
15134
+ dataMode;
14403
15135
  db;
14404
15136
  constructor(options = {}) {
14405
- this.dbPath = options.dbPath ?? uptimeDbPath();
15137
+ this.mode = resolveRuntimeMode(options.mode ?? "local");
15138
+ const cloudDatabaseUrl = options.cloudDatabaseUrl ?? process.env.HASNA_UPTIME_DATABASE_URL;
15139
+ if (this.mode === "hosted" && cloudDatabaseUrl) {
15140
+ throw new Error("hosted cloud database adapter is not implemented yet");
15141
+ }
15142
+ if (this.mode === "hosted" && !allowHostedLocalStore(options.allowHostedLocalStore)) {
15143
+ throw new Error("hosted mode requires a cloud data layer; set HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE=1 only for explicit local fallback testing");
15144
+ }
15145
+ this.dataMode = this.mode === "hosted" ? "hosted-local-sqlite" : "local-sqlite";
15146
+ this.dbPath = options.dbPath ?? (this.mode === "hosted" ? uptimeHostedFallbackDbPath() : uptimeDbPath());
14406
15147
  if (this.dbPath !== ":memory:") {
14407
15148
  mkdirSync(dirname(this.dbPath), { recursive: true });
14408
15149
  }
@@ -14419,7 +15160,7 @@ class UptimeStore {
14419
15160
  CREATE TABLE IF NOT EXISTS monitors (
14420
15161
  id TEXT PRIMARY KEY,
14421
15162
  name TEXT NOT NULL UNIQUE,
14422
- kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp')),
15163
+ kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
14423
15164
  url TEXT,
14424
15165
  host TEXT,
14425
15166
  port INTEGER,
@@ -14437,6 +15178,7 @@ class UptimeStore {
14437
15178
  )
14438
15179
  `);
14439
15180
  this.ensureColumn("monitors", "revision", "INTEGER NOT NULL DEFAULT 1");
15181
+ this.ensureMonitorKindAllowsBrowserPage();
14440
15182
  this.db.run(`
14441
15183
  CREATE TABLE IF NOT EXISTS check_results (
14442
15184
  id TEXT PRIMARY KEY,
@@ -14446,9 +15188,11 @@ class UptimeStore {
14446
15188
  latency_ms REAL,
14447
15189
  status_code INTEGER,
14448
15190
  error TEXT,
14449
- attempt_count INTEGER NOT NULL DEFAULT 1
15191
+ attempt_count INTEGER NOT NULL DEFAULT 1,
15192
+ evidence_json TEXT
14450
15193
  )
14451
15194
  `);
15195
+ this.ensureColumn("check_results", "evidence_json", "TEXT");
14452
15196
  this.db.run(`
14453
15197
  CREATE TABLE IF NOT EXISTS incidents (
14454
15198
  id TEXT PRIMARY KEY,
@@ -14462,6 +15206,71 @@ class UptimeStore {
14462
15206
  reason TEXT
14463
15207
  )
14464
15208
  `);
15209
+ this.db.run(`
15210
+ CREATE TABLE IF NOT EXISTS monitor_provenance (
15211
+ monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
15212
+ source TEXT NOT NULL,
15213
+ source_id TEXT NOT NULL,
15214
+ source_label TEXT,
15215
+ imported_at TEXT NOT NULL,
15216
+ snapshot_json TEXT NOT NULL,
15217
+ PRIMARY KEY (source, source_id)
15218
+ )
15219
+ `);
15220
+ this.db.run(`
15221
+ CREATE TABLE IF NOT EXISTS import_batches (
15222
+ id TEXT PRIMARY KEY,
15223
+ source TEXT NOT NULL,
15224
+ status TEXT NOT NULL CHECK (status IN ('applied', 'rolled_back')),
15225
+ created_at TEXT NOT NULL,
15226
+ rolled_back_at TEXT,
15227
+ records_json TEXT NOT NULL
15228
+ )
15229
+ `);
15230
+ this.db.run(`
15231
+ CREATE TABLE IF NOT EXISTS probe_identities (
15232
+ id TEXT PRIMARY KEY,
15233
+ name TEXT NOT NULL UNIQUE,
15234
+ public_key_pem TEXT NOT NULL,
15235
+ public_key_fingerprint TEXT NOT NULL UNIQUE,
15236
+ enabled INTEGER NOT NULL DEFAULT 1,
15237
+ created_at TEXT NOT NULL,
15238
+ last_seen_at TEXT
15239
+ )
15240
+ `);
15241
+ this.db.run(`
15242
+ CREATE TABLE IF NOT EXISTS probe_submissions (
15243
+ id TEXT PRIMARY KEY,
15244
+ probe_id TEXT NOT NULL REFERENCES probe_identities(id) ON DELETE CASCADE,
15245
+ job_id TEXT NOT NULL,
15246
+ monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
15247
+ check_result_id TEXT NOT NULL REFERENCES check_results(id) ON DELETE CASCADE,
15248
+ nonce TEXT NOT NULL,
15249
+ checked_at TEXT NOT NULL,
15250
+ submitted_at TEXT NOT NULL,
15251
+ UNIQUE (probe_id, nonce)
15252
+ )
15253
+ `);
15254
+ this.ensureColumn("probe_submissions", "job_id", "TEXT");
15255
+ this.db.run(`
15256
+ CREATE TABLE IF NOT EXISTS probe_check_jobs (
15257
+ id TEXT PRIMARY KEY,
15258
+ monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
15259
+ monitor_revision INTEGER NOT NULL DEFAULT 1,
15260
+ schedule_slot TEXT NOT NULL,
15261
+ status TEXT NOT NULL CHECK (status IN ('pending', 'claimed', 'submitted', 'expired', 'cancelled')),
15262
+ claimed_by_probe_id TEXT REFERENCES probe_identities(id) ON DELETE SET NULL,
15263
+ fencing_token TEXT,
15264
+ due_at TEXT NOT NULL,
15265
+ claimed_at TEXT,
15266
+ lease_expires_at TEXT,
15267
+ submitted_result_id TEXT REFERENCES check_results(id) ON DELETE SET NULL,
15268
+ created_at TEXT NOT NULL,
15269
+ updated_at TEXT NOT NULL,
15270
+ UNIQUE (monitor_id, schedule_slot)
15271
+ )
15272
+ `);
15273
+ this.ensureColumn("probe_check_jobs", "monitor_revision", "INTEGER NOT NULL DEFAULT 1");
14465
15274
  this.db.run(`
14466
15275
  CREATE TABLE IF NOT EXISTS check_leases (
14467
15276
  monitor_id TEXT PRIMARY KEY REFERENCES monitors(id) ON DELETE CASCADE,
@@ -14470,12 +15279,71 @@ class UptimeStore {
14470
15279
  acquired_at TEXT NOT NULL
14471
15280
  )
14472
15281
  `);
15282
+ this.db.run(`
15283
+ CREATE TABLE IF NOT EXISTS schema_migrations (
15284
+ key TEXT PRIMARY KEY,
15285
+ value TEXT NOT NULL,
15286
+ updated_at TEXT NOT NULL
15287
+ )
15288
+ `);
15289
+ this.db.query("INSERT OR REPLACE INTO schema_migrations (key, value, updated_at) VALUES ('schema_version', ?, ?)").run(CURRENT_SCHEMA_VERSION, new Date().toISOString());
14473
15290
  this.db.run("CREATE INDEX IF NOT EXISTS idx_results_monitor_time ON check_results(monitor_id, checked_at DESC)");
14474
15291
  this.db.run("CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents(monitor_id, status)");
14475
15292
  this.db.run("CREATE INDEX IF NOT EXISTS idx_check_leases_until ON check_leases(leased_until)");
15293
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_monitor_provenance_monitor ON monitor_provenance(monitor_id)");
15294
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_status_due ON probe_check_jobs(status, due_at)");
15295
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_probe_status ON probe_check_jobs(claimed_by_probe_id, status)");
15296
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_probe_time ON probe_submissions(probe_id, submitted_at DESC)");
15297
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_monitor_time ON probe_submissions(monitor_id, checked_at DESC)");
15298
+ 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 != ''");
15299
+ }
15300
+ backup(destinationPath) {
15301
+ if (this.dbPath === ":memory:" && !destinationPath) {
15302
+ throw new Error("backup path is required for in-memory stores");
15303
+ }
15304
+ const createdAt = new Date().toISOString();
15305
+ const backupPath = destinationPath ?? join2(dirname(this.dbPath), "backups", `uptime-${createdAt.replace(/[:.]/g, "-")}.db`);
15306
+ mkdirSync(dirname(backupPath), { recursive: true });
15307
+ if (this.dbPath === ":memory:") {
15308
+ this.vacuumInto(backupPath);
15309
+ } else {
15310
+ this.db.run("PRAGMA wal_checkpoint(TRUNCATE)");
15311
+ copyFileSync(this.dbPath, backupPath);
15312
+ }
15313
+ const bytes = statSync(backupPath).size;
15314
+ return { sourcePath: this.dbPath, backupPath, bytes, createdAt };
15315
+ }
15316
+ verifyBackup(backupPath) {
15317
+ return verifyBackupFile(backupPath);
15318
+ }
15319
+ static verifyBackup(backupPath) {
15320
+ return verifyBackupFile(backupPath);
15321
+ }
15322
+ static restoreBackup(backupPath, destinationPath = uptimeDbPath()) {
15323
+ const check2 = verifyBackupFile(backupPath);
15324
+ if (!check2.ok)
15325
+ throw new Error(`backup integrity check failed: ${check2.integrity}`);
15326
+ if (destinationPath === ":memory:")
15327
+ throw new Error("cannot restore a backup to an in-memory store");
15328
+ if (existsSync(destinationPath) || existsSync(`${destinationPath}-wal`) || existsSync(`${destinationPath}-shm`)) {
15329
+ throw new Error("restore destination already exists or has SQLite sidecar files");
15330
+ }
15331
+ mkdirSync(dirname(destinationPath), { recursive: true });
15332
+ copyFileSync(backupPath, destinationPath);
15333
+ const bytes = statSync(destinationPath).size;
15334
+ return {
15335
+ sourcePath: backupPath,
15336
+ backupPath: destinationPath,
15337
+ bytes,
15338
+ createdAt: new Date().toISOString()
15339
+ };
14476
15340
  }
14477
- createMonitor(input) {
14478
- const normalized = normalizeCreateMonitor(input);
15341
+ createMonitor(input, options = {}) {
15342
+ if (this.mode === "hosted")
15343
+ assertHostedTargetAllowed(input);
15344
+ const normalized = normalizeCreateMonitor(input, options.allowBrowserPage === true);
15345
+ if (this.mode === "hosted")
15346
+ assertHostedTargetAllowed(normalized);
14479
15347
  const now = new Date().toISOString();
14480
15348
  const monitor = {
14481
15349
  id: newId("mon"),
@@ -14511,12 +15379,22 @@ class UptimeStore {
14511
15379
  const row = this.db.query("SELECT * FROM monitors WHERE id = ? OR name = ?").get(idOrName, idOrName);
14512
15380
  return row ? monitorFromRow(row) : null;
14513
15381
  }
14514
- updateMonitor(idOrName, input) {
15382
+ updateMonitor(idOrName, input, options = {}) {
14515
15383
  const current = this.getMonitor(idOrName);
14516
15384
  if (!current)
14517
15385
  throw new Error(`Monitor not found: ${idOrName}`);
15386
+ if (this.mode === "hosted") {
15387
+ assertHostedTargetAllowed({
15388
+ kind: input.kind ?? current.kind,
15389
+ url: input.url ?? current.url ?? undefined,
15390
+ host: input.host ?? current.host ?? undefined,
15391
+ port: input.port ?? current.port ?? undefined
15392
+ });
15393
+ }
14518
15394
  const updatedAt = new Date().toISOString();
14519
- const next = normalizeUpdateMonitor(current, input, updatedAt);
15395
+ const next = normalizeUpdateMonitor(current, input, updatedAt, options.allowBrowserPage === true);
15396
+ if (this.mode === "hosted")
15397
+ assertHostedTargetAllowed(next);
14520
15398
  this.db.query(`UPDATE monitors SET
14521
15399
  name = ?, kind = ?, url = ?, host = ?, port = ?, method = ?,
14522
15400
  expected_status = ?, interval_seconds = ?, timeout_ms = ?,
@@ -14535,6 +15413,185 @@ class UptimeStore {
14535
15413
  this.db.query("DELETE FROM monitors WHERE id = ?").run(current.id);
14536
15414
  return true;
14537
15415
  }
15416
+ createProbeIdentity(input) {
15417
+ const name = input.name.trim();
15418
+ if (!name)
15419
+ throw new Error("Probe name is required");
15420
+ rejectControlCharacters2(name, "Probe name");
15421
+ const now = new Date().toISOString();
15422
+ const probe = {
15423
+ id: newId("prb"),
15424
+ name,
15425
+ publicKeyPem: input.publicKeyPem.trim(),
15426
+ publicKeyFingerprint: input.publicKeyFingerprint,
15427
+ enabled: input.enabled ?? true,
15428
+ createdAt: now,
15429
+ lastSeenAt: null
15430
+ };
15431
+ if (!probe.publicKeyPem)
15432
+ throw new Error("Probe public key is required");
15433
+ this.db.query(`INSERT INTO probe_identities (
15434
+ id, name, public_key_pem, public_key_fingerprint, enabled, created_at, last_seen_at
15435
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(probe.id, probe.name, probe.publicKeyPem, probe.publicKeyFingerprint, probe.enabled ? 1 : 0, probe.createdAt, probe.lastSeenAt);
15436
+ return probe;
15437
+ }
15438
+ listProbeIdentities(options = {}) {
15439
+ 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();
15440
+ return rows.map(probeIdentityFromRow);
15441
+ }
15442
+ getProbeIdentity(idOrName) {
15443
+ const row = this.db.query("SELECT * FROM probe_identities WHERE id = ? OR name = ?").get(idOrName, idOrName);
15444
+ return row ? probeIdentityFromRow(row) : null;
15445
+ }
15446
+ updateProbeIdentity(idOrName, input) {
15447
+ const current = this.getProbeIdentity(idOrName);
15448
+ if (!current)
15449
+ throw new Error(`Probe not found: ${idOrName}`);
15450
+ const name = input.name === undefined ? current.name : input.name.trim();
15451
+ if (!name)
15452
+ throw new Error("Probe name is required");
15453
+ rejectControlCharacters2(name, "Probe name");
15454
+ const enabled = input.enabled ?? current.enabled;
15455
+ this.db.query("UPDATE probe_identities SET name = ?, enabled = ? WHERE id = ?").run(name, enabled ? 1 : 0, current.id);
15456
+ return this.getProbeIdentity(current.id);
15457
+ }
15458
+ touchProbeIdentity(idOrName, seenAt = new Date().toISOString()) {
15459
+ const probe = this.getProbeIdentity(idOrName);
15460
+ if (!probe)
15461
+ throw new Error(`Probe not found: ${idOrName}`);
15462
+ this.db.query("UPDATE probe_identities SET last_seen_at = ? WHERE id = ?").run(seenAt, probe.id);
15463
+ }
15464
+ createProbeCheckJob(input) {
15465
+ const monitor = this.getMonitor(input.monitorId);
15466
+ if (!monitor)
15467
+ throw new Error(`Monitor not found: ${input.monitorId}`);
15468
+ if (!monitor.enabled)
15469
+ throw new Error(`Monitor is disabled: ${monitor.name}`);
15470
+ const scheduleSlot = normalizeScheduleSlot(input.scheduleSlot);
15471
+ const dueAt = input.dueAt ?? new Date().toISOString();
15472
+ assertIsoTimestamp(dueAt, "Probe job dueAt");
15473
+ const now = new Date().toISOString();
15474
+ const existing = this.db.query("SELECT * FROM probe_check_jobs WHERE monitor_id = ? AND schedule_slot = ?").get(monitor.id, scheduleSlot);
15475
+ if (existing)
15476
+ return probeCheckJobFromRow(existing);
15477
+ const job = {
15478
+ id: newId("job"),
15479
+ monitorId: monitor.id,
15480
+ monitorRevision: monitor.revision,
15481
+ scheduleSlot,
15482
+ status: "pending",
15483
+ claimedByProbeId: null,
15484
+ fencingToken: null,
15485
+ dueAt,
15486
+ claimedAt: null,
15487
+ leaseExpiresAt: null,
15488
+ submittedResultId: null,
15489
+ createdAt: now,
15490
+ updatedAt: now
15491
+ };
15492
+ this.db.query(`INSERT INTO probe_check_jobs (
15493
+ id, monitor_id, monitor_revision, schedule_slot, status, claimed_by_probe_id, fencing_token,
15494
+ due_at, claimed_at, lease_expires_at, submitted_result_id, created_at, updated_at
15495
+ ) 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);
15496
+ return job;
15497
+ }
15498
+ getProbeCheckJob(id) {
15499
+ const row = this.db.query("SELECT * FROM probe_check_jobs WHERE id = ?").get(id);
15500
+ return row ? probeCheckJobFromRow(row) : null;
15501
+ }
15502
+ claimProbeCheckJob(input) {
15503
+ const tx = this.db.transaction(() => {
15504
+ const probe = this.getProbeIdentity(input.probeId);
15505
+ if (!probe)
15506
+ throw new Error(`Probe not found: ${input.probeId}`);
15507
+ if (!probe.enabled)
15508
+ throw new Error(`Probe is disabled: ${probe.name}`);
15509
+ const current = this.getProbeCheckJob(input.jobId);
15510
+ if (!current)
15511
+ throw new Error(`Probe job not found: ${input.jobId}`);
15512
+ const now = new Date;
15513
+ const nowIso = now.toISOString();
15514
+ if (current.status === "submitted")
15515
+ throw new Error("Probe job already submitted");
15516
+ if (current.status === "cancelled")
15517
+ throw new Error("Probe job is cancelled");
15518
+ if (current.dueAt > nowIso)
15519
+ throw new Error("Probe job is not due yet");
15520
+ const leaseExpired = Boolean(current.leaseExpiresAt && current.leaseExpiresAt <= nowIso);
15521
+ if (current.status === "claimed" && !leaseExpired && current.claimedByProbeId !== probe.id) {
15522
+ throw new Error("Probe job already claimed by another probe");
15523
+ }
15524
+ if (current.status !== "pending" && current.status !== "claimed" && current.status !== "expired") {
15525
+ throw new Error(`Probe job is not claimable: ${current.status}`);
15526
+ }
15527
+ const leaseExpiresAt = new Date(now.getTime() + Math.max(1000, input.leaseTtlMs ?? 120000)).toISOString();
15528
+ const fencingToken = newId("fence");
15529
+ const update = this.db.query(`UPDATE probe_check_jobs
15530
+ SET status = 'claimed', claimed_by_probe_id = ?, fencing_token = ?, claimed_at = ?, lease_expires_at = ?, updated_at = ?
15531
+ WHERE id = ?
15532
+ AND submitted_result_id IS NULL
15533
+ AND (
15534
+ status IN ('pending', 'expired')
15535
+ OR (status = 'claimed' AND (claimed_by_probe_id = ? OR lease_expires_at <= ?))
15536
+ )`).run(probe.id, fencingToken, nowIso, leaseExpiresAt, nowIso, current.id, probe.id, nowIso);
15537
+ if (statementChanges(update) !== 1)
15538
+ throw new Error("Probe job claim raced; retry");
15539
+ this.touchProbeIdentity(probe.id, nowIso);
15540
+ return this.getProbeCheckJob(current.id);
15541
+ });
15542
+ return tx();
15543
+ }
15544
+ completeProbeCheckJob(input) {
15545
+ const job = this.getProbeCheckJob(input.jobId);
15546
+ if (!job)
15547
+ throw new Error(`Probe job not found: ${input.jobId}`);
15548
+ const submittedAt = input.submittedAt ?? new Date().toISOString();
15549
+ if (job.status !== "claimed")
15550
+ throw new Error(`Probe job is not claimable for submission: ${job.status}`);
15551
+ if (job.claimedByProbeId !== input.probeId)
15552
+ throw new Error("Probe job was claimed by another probe");
15553
+ if (job.fencingToken !== input.fencingToken)
15554
+ throw new Error("Probe job fencing token is invalid");
15555
+ if (!job.leaseExpiresAt || job.leaseExpiresAt <= submittedAt) {
15556
+ this.expireProbeCheckJob(job.id, submittedAt);
15557
+ throw new Error("Probe job lease expired");
15558
+ }
15559
+ const update = this.db.query(`UPDATE probe_check_jobs
15560
+ SET status = 'submitted', submitted_result_id = ?, updated_at = ?
15561
+ WHERE id = ?
15562
+ AND status = 'claimed'
15563
+ AND claimed_by_probe_id = ?
15564
+ AND fencing_token = ?
15565
+ AND lease_expires_at > ?
15566
+ AND submitted_result_id IS NULL`).run(input.checkResultId, submittedAt, job.id, input.probeId, input.fencingToken, submittedAt);
15567
+ if (statementChanges(update) !== 1)
15568
+ throw new Error("Probe job submission raced; retry");
15569
+ return this.getProbeCheckJob(job.id);
15570
+ }
15571
+ expireProbeCheckJob(jobId, updatedAt = new Date().toISOString()) {
15572
+ this.db.query("UPDATE probe_check_jobs SET status = 'expired', updated_at = ? WHERE id = ? AND status != 'submitted'").run(updatedAt, jobId);
15573
+ }
15574
+ getProbeSubmission(probeId, nonce) {
15575
+ const row = this.db.query("SELECT * FROM probe_submissions WHERE probe_id = ? AND nonce = ?").get(probeId, nonce);
15576
+ return row ? probeSubmissionFromRow(row) : null;
15577
+ }
15578
+ recordProbeSubmission(input) {
15579
+ const submittedAt = input.submittedAt ?? new Date().toISOString();
15580
+ const receipt = {
15581
+ id: newId("psb"),
15582
+ probeId: input.probeId,
15583
+ jobId: input.jobId,
15584
+ monitorId: input.monitorId,
15585
+ checkResultId: input.checkResultId,
15586
+ nonce: input.nonce,
15587
+ checkedAt: input.checkedAt,
15588
+ submittedAt
15589
+ };
15590
+ this.db.query(`INSERT INTO probe_submissions (
15591
+ id, probe_id, job_id, monitor_id, check_result_id, nonce, checked_at, submitted_at
15592
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(receipt.id, receipt.probeId, receipt.jobId, receipt.monitorId, receipt.checkResultId, receipt.nonce, receipt.checkedAt, receipt.submittedAt);
15593
+ return receipt;
15594
+ }
14538
15595
  acquireCheckLease(monitorId, owner, ttlMs) {
14539
15596
  const now = new Date;
14540
15597
  const nowIso = now.toISOString();
@@ -14569,7 +15626,8 @@ class UptimeStore {
14569
15626
  latencyMs: input.latencyMs,
14570
15627
  statusCode: input.statusCode,
14571
15628
  error: input.error,
14572
- attemptCount: Math.max(1, input.attemptCount)
15629
+ attemptCount: Math.max(1, input.attemptCount),
15630
+ evidence: input.evidence ?? null
14573
15631
  };
14574
15632
  const tx = this.db.transaction(() => {
14575
15633
  const current = this.db.query("SELECT * FROM monitors WHERE id = ?").get(result.monitorId);
@@ -14582,19 +15640,59 @@ class UptimeStore {
14582
15640
  throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${current.name}`);
14583
15641
  }
14584
15642
  this.db.query(`INSERT INTO check_results (
14585
- id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count
14586
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(result.id, result.monitorId, result.checkedAt, result.status, result.latencyMs, result.statusCode, result.error, result.attemptCount);
15643
+ id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count, evidence_json
15644
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(result.id, result.monitorId, result.checkedAt, result.status, result.latencyMs, result.statusCode, result.error, result.attemptCount, result.evidence ? JSON.stringify(result.evidence) : null);
14587
15645
  this.db.query("UPDATE monitors SET status = ?, last_checked_at = ?, updated_at = ? WHERE id = ?").run(result.status, result.checkedAt, result.checkedAt, result.monitorId);
14588
15646
  this.reconcileIncidentInTransaction(result);
14589
15647
  });
14590
15648
  tx();
14591
15649
  return result;
14592
15650
  }
15651
+ getCheckResult(id) {
15652
+ const row = this.db.query("SELECT * FROM check_results WHERE id = ?").get(id);
15653
+ return row ? checkResultFromRow(row) : null;
15654
+ }
14593
15655
  listResults(options = {}) {
14594
15656
  const limit = clampLimit(options.limit ?? 50);
14595
15657
  const rows = options.monitorId ? this.db.query("SELECT * FROM check_results WHERE monitor_id = ? ORDER BY checked_at DESC LIMIT ?").all(options.monitorId, limit) : this.db.query("SELECT * FROM check_results ORDER BY checked_at DESC LIMIT ?").all(limit);
14596
15658
  return rows.map(checkResultFromRow);
14597
15659
  }
15660
+ getProvenance(source, sourceId) {
15661
+ const row = this.db.query("SELECT * FROM monitor_provenance WHERE source = ? AND source_id = ?").get(source, sourceId);
15662
+ return row ? provenanceFromRow(row) : null;
15663
+ }
15664
+ upsertMonitorProvenance(input) {
15665
+ const importedAt = new Date().toISOString();
15666
+ this.db.query(`INSERT INTO monitor_provenance (
15667
+ monitor_id, source, source_id, source_label, imported_at, snapshot_json
15668
+ ) VALUES (?, ?, ?, ?, ?, ?)
15669
+ ON CONFLICT(source, source_id) DO UPDATE SET
15670
+ monitor_id = excluded.monitor_id,
15671
+ source_label = excluded.source_label,
15672
+ imported_at = excluded.imported_at,
15673
+ snapshot_json = excluded.snapshot_json`).run(input.monitorId, input.source, input.sourceId, input.sourceLabel ?? null, importedAt, JSON.stringify(input.snapshot));
15674
+ return this.getProvenance(input.source, input.sourceId);
15675
+ }
15676
+ saveImportBatch(input) {
15677
+ const createdAt = new Date().toISOString();
15678
+ 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));
15679
+ return this.getImportBatch(input.id);
15680
+ }
15681
+ getImportBatch(batchId) {
15682
+ const row = this.db.query("SELECT * FROM import_batches WHERE id = ?").get(batchId);
15683
+ return row ? importBatchFromRow(row) : null;
15684
+ }
15685
+ markImportBatchRolledBack(batchId) {
15686
+ const rolledBackAt = new Date().toISOString();
15687
+ this.db.query("UPDATE import_batches SET status = 'rolled_back', rolled_back_at = ? WHERE id = ?").run(rolledBackAt, batchId);
15688
+ const batch = this.getImportBatch(batchId);
15689
+ if (!batch)
15690
+ throw new Error(`Import batch not found: ${batchId}`);
15691
+ return batch;
15692
+ }
15693
+ runInTransaction(fn) {
15694
+ return this.db.transaction(fn)();
15695
+ }
14598
15696
  listIncidents(options = {}) {
14599
15697
  const clauses = [];
14600
15698
  const args = [];
@@ -14682,16 +15780,115 @@ class UptimeStore {
14682
15780
  this.db.run(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
14683
15781
  }
14684
15782
  }
15783
+ ensureMonitorKindAllowsBrowserPage() {
15784
+ const row = this.db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'monitors'").get();
15785
+ if (!row?.sql || row.sql.includes("browser_page"))
15786
+ return;
15787
+ this.db.run("PRAGMA foreign_keys = OFF");
15788
+ this.db.run("PRAGMA legacy_alter_table = ON");
15789
+ try {
15790
+ const migrate = this.db.transaction(() => {
15791
+ this.db.run("ALTER TABLE monitors RENAME TO monitors_old_kind");
15792
+ this.db.run(`
15793
+ CREATE TABLE monitors (
15794
+ id TEXT PRIMARY KEY,
15795
+ name TEXT NOT NULL UNIQUE,
15796
+ kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
15797
+ url TEXT,
15798
+ host TEXT,
15799
+ port INTEGER,
15800
+ method TEXT NOT NULL DEFAULT 'GET',
15801
+ expected_status INTEGER,
15802
+ interval_seconds INTEGER NOT NULL DEFAULT 60,
15803
+ timeout_ms INTEGER NOT NULL DEFAULT 5000,
15804
+ retry_count INTEGER NOT NULL DEFAULT 0,
15805
+ enabled INTEGER NOT NULL DEFAULT 1,
15806
+ status TEXT NOT NULL DEFAULT 'unknown',
15807
+ last_checked_at TEXT,
15808
+ revision INTEGER NOT NULL DEFAULT 1,
15809
+ created_at TEXT NOT NULL,
15810
+ updated_at TEXT NOT NULL
15811
+ )
15812
+ `);
15813
+ this.db.run(`
15814
+ INSERT INTO monitors (
15815
+ id, name, kind, url, host, port, method, expected_status,
15816
+ interval_seconds, timeout_ms, retry_count, enabled, status,
15817
+ last_checked_at, revision, created_at, updated_at
15818
+ )
15819
+ SELECT
15820
+ id, name, kind, url, host, port, method, expected_status,
15821
+ interval_seconds, timeout_ms, retry_count, enabled, status,
15822
+ last_checked_at, revision, created_at, updated_at
15823
+ FROM monitors_old_kind
15824
+ `);
15825
+ this.db.run("DROP TABLE monitors_old_kind");
15826
+ });
15827
+ migrate();
15828
+ } finally {
15829
+ this.db.run("PRAGMA legacy_alter_table = OFF");
15830
+ this.db.run("PRAGMA foreign_keys = ON");
15831
+ }
15832
+ }
15833
+ vacuumInto(backupPath) {
15834
+ const quoted = backupPath.replace(/'/g, "''");
15835
+ this.db.run(`VACUUM INTO '${quoted}'`);
15836
+ }
15837
+ }
15838
+ function resolveRuntimeMode(mode) {
15839
+ const value = mode ?? process.env.HASNA_UPTIME_MODE ?? "local";
15840
+ if (value === "local" || value === "hosted")
15841
+ return value;
15842
+ throw new Error("HASNA_UPTIME_MODE must be local or hosted");
15843
+ }
15844
+ function allowHostedLocalStore(value) {
15845
+ return value === true || process.env.HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE === "1";
15846
+ }
15847
+ function verifyBackupFile(backupPath) {
15848
+ const db = new Database(backupPath, { readonly: true });
15849
+ try {
15850
+ const integrityRow = db.query("PRAGMA integrity_check").get();
15851
+ const integrity = String(integrityRow?.integrity_check ?? "unknown");
15852
+ const missingTables = REQUIRED_TABLES.filter((table) => !tableExists(db, table));
15853
+ const schemaVersion = missingTables.includes("schema_migrations") ? null : db.query("SELECT value FROM schema_migrations WHERE key = 'schema_version'").get()?.value ?? null;
15854
+ const currentOk = missingTables.length === 0 && schemaVersion === CURRENT_SCHEMA_VERSION;
15855
+ const restorableV1 = schemaVersion === "1" && missingTables.every((table) => PROBE_TABLES.has(table));
15856
+ return {
15857
+ ok: integrity === "ok" && (currentOk || restorableV1),
15858
+ backupPath,
15859
+ integrity,
15860
+ schemaVersion,
15861
+ missingTables,
15862
+ monitors: tableCount(db, "monitors"),
15863
+ results: tableCount(db, "check_results"),
15864
+ incidents: tableCount(db, "incidents")
15865
+ };
15866
+ } finally {
15867
+ db.close();
15868
+ }
15869
+ }
15870
+ function tableCount(db, table) {
15871
+ if (!tableExists(db, table))
15872
+ return 0;
15873
+ const row = db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
15874
+ return Number(row?.count ?? 0);
14685
15875
  }
14686
- function normalizeCreateMonitor(input) {
15876
+ function tableExists(db, table) {
15877
+ const row = db.query("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
15878
+ return Number(row?.count ?? 0) > 0;
15879
+ }
15880
+ function normalizeCreateMonitor(input, allowBrowserPage = false) {
14687
15881
  const name = input.name?.trim();
14688
15882
  if (!name)
14689
15883
  throw new Error("Monitor name is required");
14690
- rejectControlCharacters(name, "Monitor name");
15884
+ rejectControlCharacters2(name, "Monitor name");
14691
15885
  const method = normalizeMethod(input.method ?? "GET");
14692
15886
  const expectedStatus = normalizeExpectedStatus(input.expectedStatus);
14693
15887
  const enabled = normalizeEnabled(input.enabled);
14694
- if (input.kind === "http") {
15888
+ if (input.kind === "http" || input.kind === "browser_page") {
15889
+ if (input.kind === "browser_page" && !allowBrowserPage) {
15890
+ throw new Error("browser_page monitors must be imported with explicit browser evidence support");
15891
+ }
14695
15892
  const url2 = normalizeHttpUrl(input.url);
14696
15893
  return {
14697
15894
  name,
@@ -14699,16 +15896,16 @@ function normalizeCreateMonitor(input) {
14699
15896
  url: url2,
14700
15897
  method,
14701
15898
  expectedStatus,
14702
- intervalSeconds: boundedInteger(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
14703
- timeoutMs: boundedInteger(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
14704
- retryCount: boundedInteger(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
15899
+ intervalSeconds: boundedInteger2(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
15900
+ timeoutMs: boundedInteger2(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
15901
+ retryCount: boundedInteger2(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
14705
15902
  enabled
14706
15903
  };
14707
15904
  } else if (input.kind === "tcp") {
14708
15905
  const host = input.host?.trim();
14709
15906
  if (!host)
14710
15907
  throw new Error("TCP monitors require host");
14711
- rejectControlCharacters(host, "TCP host");
15908
+ rejectControlCharacters2(host, "TCP host");
14712
15909
  if (!Number.isInteger(input.port) || input.port <= 0 || input.port > 65535) {
14713
15910
  throw new Error("TCP monitors require a port from 1 to 65535");
14714
15911
  }
@@ -14719,19 +15916,19 @@ function normalizeCreateMonitor(input) {
14719
15916
  port: input.port,
14720
15917
  method,
14721
15918
  expectedStatus: null,
14722
- intervalSeconds: boundedInteger(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
14723
- timeoutMs: boundedInteger(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
14724
- retryCount: boundedInteger(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
15919
+ intervalSeconds: boundedInteger2(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
15920
+ timeoutMs: boundedInteger2(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
15921
+ retryCount: boundedInteger2(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
14725
15922
  enabled
14726
15923
  };
14727
15924
  } else {
14728
- throw new Error("Monitor kind must be http or tcp");
15925
+ throw new Error("Monitor kind must be http, tcp, or browser_page");
14729
15926
  }
14730
15927
  }
14731
15928
  function definitionChanged(current, next) {
14732
15929
  return next.kind !== current.kind || next.url !== current.url || next.host !== current.host || next.port !== current.port || next.method !== current.method || next.expectedStatus !== current.expectedStatus;
14733
15930
  }
14734
- function normalizeUpdateMonitor(current, input, updatedAt) {
15931
+ function normalizeUpdateMonitor(current, input, updatedAt, allowBrowserPage = false) {
14735
15932
  const merged = {
14736
15933
  ...current,
14737
15934
  ...input,
@@ -14750,7 +15947,7 @@ function normalizeUpdateMonitor(current, input, updatedAt) {
14750
15947
  timeoutMs: merged.timeoutMs,
14751
15948
  retryCount: merged.retryCount,
14752
15949
  enabled: merged.enabled
14753
- });
15950
+ }, allowBrowserPage || current.kind === "browser_page");
14754
15951
  const checkDefinitionChanged = normalized.kind !== current.kind || (normalized.url ?? null) !== current.url || (normalized.host ?? null) !== current.host || (normalized.port ?? null) !== current.port || normalized.method !== current.method || normalized.expectedStatus !== current.expectedStatus;
14755
15952
  const status = normalized.enabled ? checkDefinitionChanged || !current.enabled ? "unknown" : current.status : "paused";
14756
15953
  return {
@@ -14779,6 +15976,11 @@ function normalizeHttpUrl(value) {
14779
15976
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
14780
15977
  throw new Error("HTTP monitor url must use http or https");
14781
15978
  }
15979
+ for (const key of [...parsed.searchParams.keys()]) {
15980
+ if (SECRET_URL_PARAM_PATTERN.test(key))
15981
+ parsed.searchParams.set(key, "[redacted]");
15982
+ }
15983
+ parsed.hash = "";
14782
15984
  return parsed.toString();
14783
15985
  }
14784
15986
  function normalizeMethod(value) {
@@ -14802,11 +16004,25 @@ function normalizeEnabled(value) {
14802
16004
  throw new Error("enabled must be a boolean");
14803
16005
  return value;
14804
16006
  }
14805
- function rejectControlCharacters(value, label) {
16007
+ function rejectControlCharacters2(value, label) {
14806
16008
  if (/[\x00-\x1f\x7f-\x9f]/.test(value)) {
14807
16009
  throw new Error(`${label} must not contain control characters`);
14808
16010
  }
14809
16011
  }
16012
+ function normalizeScheduleSlot(value) {
16013
+ const slot = value.trim();
16014
+ if (!slot)
16015
+ throw new Error("Probe job scheduleSlot is required");
16016
+ if (slot.length > 128)
16017
+ throw new Error("Probe job scheduleSlot is too long");
16018
+ rejectControlCharacters2(slot, "Probe job scheduleSlot");
16019
+ return slot;
16020
+ }
16021
+ function assertIsoTimestamp(value, label) {
16022
+ if (!Number.isFinite(Date.parse(value))) {
16023
+ throw new Error(`${label} must be an ISO timestamp`);
16024
+ }
16025
+ }
14810
16026
  function monitorFromRow(row) {
14811
16027
  return {
14812
16028
  id: row.id,
@@ -14837,9 +16053,83 @@ function checkResultFromRow(row) {
14837
16053
  latencyMs: row.latency_ms,
14838
16054
  statusCode: row.status_code,
14839
16055
  error: row.error,
14840
- attemptCount: row.attempt_count
16056
+ attemptCount: row.attempt_count,
16057
+ evidence: parseEvidence(row.evidence_json)
16058
+ };
16059
+ }
16060
+ function provenanceFromRow(row) {
16061
+ return {
16062
+ monitorId: row.monitor_id,
16063
+ source: row.source,
16064
+ sourceId: row.source_id,
16065
+ sourceLabel: row.source_label,
16066
+ importedAt: row.imported_at,
16067
+ snapshot: parseJson(row.snapshot_json)
16068
+ };
16069
+ }
16070
+ function importBatchFromRow(row) {
16071
+ return {
16072
+ id: row.id,
16073
+ source: row.source,
16074
+ status: row.status,
16075
+ createdAt: row.created_at,
16076
+ rolledBackAt: row.rolled_back_at,
16077
+ records: Array.isArray(parseJson(row.records_json)) ? parseJson(row.records_json) : []
16078
+ };
16079
+ }
16080
+ function probeIdentityFromRow(row) {
16081
+ return {
16082
+ id: row.id,
16083
+ name: row.name,
16084
+ publicKeyPem: row.public_key_pem,
16085
+ publicKeyFingerprint: row.public_key_fingerprint,
16086
+ enabled: Boolean(row.enabled),
16087
+ createdAt: row.created_at,
16088
+ lastSeenAt: row.last_seen_at
14841
16089
  };
14842
16090
  }
16091
+ function probeSubmissionFromRow(row) {
16092
+ return {
16093
+ id: row.id,
16094
+ probeId: row.probe_id,
16095
+ jobId: row.job_id ?? "",
16096
+ monitorId: row.monitor_id,
16097
+ checkResultId: row.check_result_id,
16098
+ nonce: row.nonce,
16099
+ checkedAt: row.checked_at,
16100
+ submittedAt: row.submitted_at
16101
+ };
16102
+ }
16103
+ function probeCheckJobFromRow(row) {
16104
+ return {
16105
+ id: row.id,
16106
+ monitorId: row.monitor_id,
16107
+ monitorRevision: row.monitor_revision ?? 1,
16108
+ scheduleSlot: row.schedule_slot,
16109
+ status: row.status,
16110
+ claimedByProbeId: row.claimed_by_probe_id,
16111
+ fencingToken: row.fencing_token,
16112
+ dueAt: row.due_at,
16113
+ claimedAt: row.claimed_at,
16114
+ leaseExpiresAt: row.lease_expires_at,
16115
+ submittedResultId: row.submitted_result_id,
16116
+ createdAt: row.created_at,
16117
+ updatedAt: row.updated_at
16118
+ };
16119
+ }
16120
+ function parseEvidence(value) {
16121
+ if (!value)
16122
+ return null;
16123
+ const parsed = parseJson(value);
16124
+ return parsed && typeof parsed === "object" ? parsed : null;
16125
+ }
16126
+ function parseJson(value) {
16127
+ try {
16128
+ return JSON.parse(value);
16129
+ } catch {
16130
+ return null;
16131
+ }
16132
+ }
14843
16133
  function incidentFromRow(row) {
14844
16134
  return {
14845
16135
  id: row.id,
@@ -14854,9 +16144,9 @@ function incidentFromRow(row) {
14854
16144
  };
14855
16145
  }
14856
16146
  function newId(prefix) {
14857
- return `${prefix}_${randomUUID().replace(/-/g, "").slice(0, 18)}`;
16147
+ return `${prefix}_${randomUUID2().replace(/-/g, "").slice(0, 18)}`;
14858
16148
  }
14859
- function boundedInteger(value, label, min, max) {
16149
+ function boundedInteger2(value, label, min, max) {
14860
16150
  if (!Number.isInteger(value) || value < min || value > max) {
14861
16151
  throw new Error(`${label} must be an integer from ${min} to ${max}`);
14862
16152
  }
@@ -14867,6 +16157,9 @@ function clampLimit(value) {
14867
16157
  return 50;
14868
16158
  return Math.max(1, Math.min(Math.floor(value), MAX_RESULT_LIMIT));
14869
16159
  }
16160
+ function statementChanges(result) {
16161
+ return Number(result?.changes ?? 0);
16162
+ }
14870
16163
  function round(value, places) {
14871
16164
  const factor = 10 ** places;
14872
16165
  return Math.round(value * factor) / factor;
@@ -14941,7 +16234,7 @@ function renderMonitorLine(item) {
14941
16234
  return `- ${item.monitor.status.toUpperCase()} ${item.monitor.name} (${targetLabel(item)}): uptime ${uptime}, latency ${latency}${incident}`;
14942
16235
  }
14943
16236
  function targetLabel(item) {
14944
- return item.monitor.kind === "http" ? item.monitor.url ?? "" : `${item.monitor.host}:${item.monitor.port}`;
16237
+ return item.monitor.kind === "tcp" ? `${item.monitor.host}:${item.monitor.port}` : item.monitor.url ?? "";
14945
16238
  }
14946
16239
  function resolveEmailTarget(value) {
14947
16240
  const target = typeof value === "boolean" ? {} : value;
@@ -15143,13 +16436,16 @@ function redactOptional(value, secrets) {
15143
16436
  }
15144
16437
 
15145
16438
  // src/service.ts
16439
+ var MAX_PROBE_RESULT_AGE_MS = 15 * 60000;
16440
+ var MAX_PROBE_RESULT_FUTURE_MS = 5 * 60000;
16441
+
15146
16442
  class UptimeService {
15147
16443
  store;
15148
16444
  checkRunner;
15149
- leaseOwner = `svc_${randomUUID2().replace(/-/g, "").slice(0, 18)}`;
16445
+ leaseOwner = `svc_${randomUUID3().replace(/-/g, "").slice(0, 18)}`;
15150
16446
  inFlightChecks = new Set;
15151
16447
  constructor(options = {}) {
15152
- this.store = options.store ?? new UptimeStore(options);
16448
+ this.store = options.store ?? new UptimeStore({ mode: "local", ...options });
15153
16449
  this.checkRunner = options.checkRunner ?? runMonitorCheck;
15154
16450
  }
15155
16451
  close() {
@@ -15179,13 +16475,71 @@ class UptimeService {
15179
16475
  summary() {
15180
16476
  return this.store.summary();
15181
16477
  }
16478
+ createProbe(input) {
16479
+ const store = this.probeStore();
16480
+ const publicKeyPem = input.publicKeyPem ? normalizeProbePublicKeyPem(input.publicKeyPem) : undefined;
16481
+ const keyPair = publicKeyPem ? {
16482
+ publicKeyPem,
16483
+ privateKeyPem: undefined,
16484
+ publicKeyFingerprint: probePublicKeyFingerprint(publicKeyPem)
16485
+ } : generateProbeKeyPair();
16486
+ const probe = store.createProbeIdentity({
16487
+ name: input.name,
16488
+ publicKeyPem: keyPair.publicKeyPem,
16489
+ publicKeyFingerprint: keyPair.publicKeyFingerprint,
16490
+ enabled: input.enabled
16491
+ });
16492
+ return { ...probe, privateKeyPem: keyPair.privateKeyPem };
16493
+ }
16494
+ listProbes(options = {}) {
16495
+ return this.probeStore().listProbeIdentities(options);
16496
+ }
16497
+ getProbe(idOrName) {
16498
+ return this.probeStore().getProbeIdentity(idOrName);
16499
+ }
16500
+ updateProbe(idOrName, input) {
16501
+ return this.probeStore().updateProbeIdentity(idOrName, input);
16502
+ }
16503
+ createProbeCheckJob(input) {
16504
+ return this.probeStore().createProbeCheckJob(input);
16505
+ }
16506
+ getProbeCheckJob(id) {
16507
+ return this.probeStore().getProbeCheckJob(id);
16508
+ }
16509
+ claimProbeCheckJob(input) {
16510
+ return this.probeStore().claimProbeCheckJob(input);
16511
+ }
16512
+ submitProbeResult(input) {
16513
+ const execute = () => this.submitProbeResultInTransaction(input);
16514
+ return this.store.runInTransaction ? this.store.runInTransaction(execute) : execute();
16515
+ }
16516
+ previewImport(request) {
16517
+ return previewImport(this.store, request);
16518
+ }
16519
+ applyImport(request) {
16520
+ return applyImport(this.store, request);
16521
+ }
16522
+ rollbackImport(batchId) {
16523
+ return rollbackImport(this.store, batchId);
16524
+ }
16525
+ backup(destinationPath) {
16526
+ return this.store.backup(destinationPath);
16527
+ }
16528
+ verifyBackup(backupPath) {
16529
+ return this.store.verifyBackup(backupPath);
16530
+ }
15182
16531
  buildReport(options = {}) {
15183
16532
  return buildUptimeReport(this.summary(), options);
15184
16533
  }
15185
16534
  async sendReport(options = {}) {
16535
+ if (this.store.mode === "hosted" && (options.email || options.sms || options.logs)) {
16536
+ throw new Error("hosted report delivery requires configured channel refs");
16537
+ }
15186
16538
  return sendUptimeReport(this.summary(), options);
15187
16539
  }
15188
16540
  async checkMonitor(idOrName) {
16541
+ if (this.store.mode === "hosted")
16542
+ throw new Error("hosted checks require check_jobs and probes");
15189
16543
  const monitor = this.store.getMonitor(idOrName);
15190
16544
  if (!monitor)
15191
16545
  throw new Error(`Monitor not found: ${idOrName}`);
@@ -15214,6 +16568,7 @@ class UptimeService {
15214
16568
  latencyMs: last.latencyMs,
15215
16569
  statusCode: last.statusCode ?? null,
15216
16570
  error: last.error ?? null,
16571
+ evidence: last.evidence ?? null,
15217
16572
  attemptCount,
15218
16573
  expectedMonitorRevision: monitor.revision
15219
16574
  });
@@ -15223,6 +16578,8 @@ class UptimeService {
15223
16578
  }
15224
16579
  }
15225
16580
  async checkAll() {
16581
+ if (this.store.mode === "hosted")
16582
+ throw new Error("hosted checks require check_jobs and probes");
15226
16583
  const monitors = this.store.listMonitors();
15227
16584
  const results = [];
15228
16585
  for (const monitor of monitors) {
@@ -15231,6 +16588,8 @@ class UptimeService {
15231
16588
  return results;
15232
16589
  }
15233
16590
  startScheduler(options = {}) {
16591
+ if (this.store.mode === "hosted")
16592
+ throw new Error("hosted scheduler requires check_jobs and probes");
15234
16593
  const tickMs = options.tickMs ?? 1000;
15235
16594
  const timer = setInterval(() => {
15236
16595
  this.runDueChecks().catch((error51) => {
@@ -15242,6 +16601,8 @@ class UptimeService {
15242
16601
  };
15243
16602
  }
15244
16603
  async runDueChecks(now = new Date) {
16604
+ if (this.store.mode === "hosted")
16605
+ throw new Error("hosted checks require check_jobs and probes");
15245
16606
  const due = this.store.listMonitors().filter((monitor) => this.isDue(monitor, now));
15246
16607
  const results = [];
15247
16608
  for (const monitor of due) {
@@ -15268,6 +16629,113 @@ class UptimeService {
15268
16629
  const last = new Date(monitor.lastCheckedAt).getTime();
15269
16630
  return now.getTime() - last >= monitor.intervalSeconds * 1000;
15270
16631
  }
16632
+ probeStore() {
16633
+ if (this.store.mode === "hosted") {
16634
+ throw new Error("hosted probe APIs require cloud check_jobs, workspace stores, and audit logging");
16635
+ }
16636
+ const store = this.store;
16637
+ const required2 = [
16638
+ "createProbeIdentity",
16639
+ "listProbeIdentities",
16640
+ "getProbeIdentity",
16641
+ "updateProbeIdentity",
16642
+ "touchProbeIdentity",
16643
+ "createProbeCheckJob",
16644
+ "getProbeCheckJob",
16645
+ "claimProbeCheckJob",
16646
+ "completeProbeCheckJob",
16647
+ "getProbeSubmission",
16648
+ "recordProbeSubmission"
16649
+ ];
16650
+ for (const method of required2) {
16651
+ if (typeof store[method] !== "function") {
16652
+ throw new Error("probe support requires a probe-capable store");
16653
+ }
16654
+ }
16655
+ return store;
16656
+ }
16657
+ submitProbeResultInTransaction(input) {
16658
+ const store = this.probeStore();
16659
+ const probe = store.getProbeIdentity(input.probeId);
16660
+ if (!probe)
16661
+ throw new Error(`Probe not found: ${input.probeId}`);
16662
+ if (!probe.enabled)
16663
+ throw new Error(`Probe is disabled: ${probe.name}`);
16664
+ const monitor = this.store.getMonitor(input.monitorId);
16665
+ if (!monitor)
16666
+ throw new Error(`Monitor not found: ${input.monitorId}`);
16667
+ if (!monitor.enabled)
16668
+ throw new Error(`Monitor is disabled: ${monitor.name}`);
16669
+ if (probe.id !== input.probeId)
16670
+ throw new Error("Probe result must use canonical probe id");
16671
+ if (monitor.id !== input.monitorId)
16672
+ throw new Error("Probe result must use canonical monitor id");
16673
+ validateProbeSubmission(input);
16674
+ const job = store.getProbeCheckJob(input.jobId);
16675
+ if (!job)
16676
+ throw new Error(`Probe job not found: ${input.jobId}`);
16677
+ if (job.monitorId !== monitor.id)
16678
+ throw new Error("Probe job does not match monitor");
16679
+ if (job.scheduleSlot !== input.scheduleSlot)
16680
+ throw new Error("Probe job scheduleSlot does not match submission");
16681
+ if (!verifyProbeResultSignature({ ...input, probeId: probe.id, monitorId: monitor.id }, probe.publicKeyPem)) {
16682
+ throw new Error("Probe result signature is invalid");
16683
+ }
16684
+ const existingReceipt = store.getProbeSubmission(probe.id, input.nonce);
16685
+ if (existingReceipt) {
16686
+ if (existingReceipt.jobId !== input.jobId || existingReceipt.monitorId !== monitor.id || existingReceipt.checkedAt !== input.checkedAt) {
16687
+ throw new Error("Probe nonce already submitted");
16688
+ }
16689
+ const existingResult = this.store.getCheckResult?.(existingReceipt.checkResultId);
16690
+ if (!existingResult)
16691
+ throw new Error("Probe nonce already submitted");
16692
+ return { result: existingResult, receipt: existingReceipt };
16693
+ }
16694
+ if (job.monitorRevision !== input.monitorRevision)
16695
+ throw new Error("Probe job monitorRevision does not match submission");
16696
+ if (job.monitorRevision !== monitor.revision)
16697
+ throw new StaleCheckResultError(`Monitor changed since probe job was created: ${monitor.name}`);
16698
+ if (job.status === "submitted")
16699
+ throw new Error("Probe job already submitted");
16700
+ if (job.status === "cancelled")
16701
+ throw new Error("Probe job is cancelled");
16702
+ if (job.status !== "claimed")
16703
+ throw new Error(`Probe job is not claimable for submission: ${job.status}`);
16704
+ if (job.claimedByProbeId !== probe.id)
16705
+ throw new Error("Probe job was claimed by another probe");
16706
+ if (job.fencingToken !== input.fencingToken)
16707
+ throw new Error("Probe job fencing token is invalid");
16708
+ if (!job.leaseExpiresAt || job.leaseExpiresAt <= new Date().toISOString())
16709
+ throw new Error("Probe job lease expired");
16710
+ const result = this.store.recordCheckResult({
16711
+ monitorId: monitor.id,
16712
+ checkedAt: input.checkedAt,
16713
+ status: input.status,
16714
+ latencyMs: input.latencyMs,
16715
+ statusCode: input.statusCode ?? null,
16716
+ error: input.error ?? null,
16717
+ evidence: input.evidence ?? null,
16718
+ attemptCount: input.attemptCount ?? 1,
16719
+ expectedMonitorRevision: input.monitorRevision
16720
+ });
16721
+ const receipt = store.recordProbeSubmission({
16722
+ probeId: probe.id,
16723
+ jobId: job.id,
16724
+ monitorId: monitor.id,
16725
+ checkResultId: result.id,
16726
+ nonce: input.nonce,
16727
+ checkedAt: input.checkedAt
16728
+ });
16729
+ store.completeProbeCheckJob({
16730
+ jobId: job.id,
16731
+ probeId: probe.id,
16732
+ fencingToken: input.fencingToken,
16733
+ checkResultId: result.id,
16734
+ submittedAt: receipt.submittedAt
16735
+ });
16736
+ store.touchProbeIdentity(probe.id, receipt.submittedAt);
16737
+ return { result, receipt };
16738
+ }
15271
16739
  }
15272
16740
  class MonitorCheckBusyError extends Error {
15273
16741
  constructor(message) {
@@ -15275,16 +16743,68 @@ class MonitorCheckBusyError extends Error {
15275
16743
  this.name = "MonitorCheckBusyError";
15276
16744
  }
15277
16745
  }
16746
+ function validateProbeSubmission(input) {
16747
+ if (!input.jobId.trim())
16748
+ throw new Error("Probe submission jobId is required");
16749
+ if (!input.scheduleSlot.trim())
16750
+ throw new Error("Probe submission scheduleSlot is required");
16751
+ if (!input.fencingToken.trim())
16752
+ throw new Error("Probe submission fencingToken is required");
16753
+ if (!input.nonce.trim())
16754
+ throw new Error("Probe submission nonce is required");
16755
+ if (input.nonce.length > 128)
16756
+ throw new Error("Probe submission nonce is too long");
16757
+ if (/[\x00-\x1f\x7f-\x9f]/.test(input.nonce))
16758
+ throw new Error("Probe submission nonce must not contain control characters");
16759
+ if (input.status !== "up" && input.status !== "down")
16760
+ throw new Error("Probe result status must be up or down");
16761
+ if (input.latencyMs !== null && (!Number.isFinite(input.latencyMs) || input.latencyMs < 0)) {
16762
+ throw new Error("Probe result latencyMs must be null or a non-negative number");
16763
+ }
16764
+ if (input.statusCode !== undefined && input.statusCode !== null && (!Number.isInteger(input.statusCode) || input.statusCode < 100 || input.statusCode > 599)) {
16765
+ throw new Error("Probe result statusCode must be an HTTP status from 100 to 599");
16766
+ }
16767
+ if (input.attemptCount !== undefined && (!Number.isInteger(input.attemptCount) || input.attemptCount < 1 || input.attemptCount > 20)) {
16768
+ throw new Error("Probe result attemptCount must be an integer from 1 to 20");
16769
+ }
16770
+ const monitorRevision = input.monitorRevision;
16771
+ if (!Number.isInteger(monitorRevision) || monitorRevision < 1) {
16772
+ throw new Error("Probe result monitorRevision is required");
16773
+ }
16774
+ const checkedAtMs = Date.parse(input.checkedAt);
16775
+ if (!Number.isFinite(checkedAtMs))
16776
+ throw new Error("Probe result checkedAt must be an ISO timestamp");
16777
+ const now = Date.now();
16778
+ if (checkedAtMs > now + MAX_PROBE_RESULT_FUTURE_MS)
16779
+ throw new Error("Probe result checkedAt is too far in the future");
16780
+ if (checkedAtMs < now - MAX_PROBE_RESULT_AGE_MS)
16781
+ throw new Error("Probe result checkedAt is too old");
16782
+ if (!input.signature.trim())
16783
+ throw new Error("Probe result signature is required");
16784
+ }
16785
+ function normalizeProbePublicKeyPem(publicKeyPem) {
16786
+ try {
16787
+ const key = createPublicKey(publicKeyPem);
16788
+ if (key.asymmetricKeyType !== "ed25519") {
16789
+ throw new Error("Probe public key must be an Ed25519 public key");
16790
+ }
16791
+ return key.export({ format: "pem", type: "spki" }).toString();
16792
+ } catch (error51) {
16793
+ if (error51 instanceof Error && error51.message.includes("Ed25519"))
16794
+ throw error51;
16795
+ throw new Error("Probe public key must be a valid PEM Ed25519 public key");
16796
+ }
16797
+ }
15278
16798
 
15279
16799
  // src/version.ts
15280
16800
  import { readFileSync } from "fs";
15281
- import { dirname as dirname2, join as join2 } from "path";
16801
+ import { dirname as dirname2, join as join3 } from "path";
15282
16802
  import { fileURLToPath } from "url";
15283
16803
  function packageVersion() {
15284
16804
  const here = dirname2(fileURLToPath(import.meta.url));
15285
16805
  const candidates = [
15286
- join2(here, "..", "package.json"),
15287
- join2(here, "..", "..", "package.json")
16806
+ join3(here, "..", "package.json"),
16807
+ join3(here, "..", "..", "package.json")
15288
16808
  ];
15289
16809
  for (const candidate of candidates) {
15290
16810
  try {
@@ -15297,7 +16817,7 @@ function packageVersion() {
15297
16817
  // src/mcp/index.ts
15298
16818
  function createMcpServer(options = {}) {
15299
16819
  const server = new McpServer({ name: "uptime", version: packageVersion() });
15300
- const service = options.service ?? new UptimeService;
16820
+ const service = options.service ?? new UptimeService({ mode: "local" });
15301
16821
  server.registerResource("uptime_summary", "uptime://summary", {
15302
16822
  title: "Open Uptime Summary",
15303
16823
  description: "Current monitor status, uptime percentages, latency, and incident totals.",
@@ -15419,6 +16939,84 @@ function createMcpServer(options = {}) {
15419
16939
  logs: args.logs,
15420
16940
  timeoutMs: args.timeoutMs
15421
16941
  })));
16942
+ server.registerTool("uptime_import_preview", {
16943
+ title: "Preview an uptime inventory import",
16944
+ description: "Preview monitor candidates from manual, projects, servers, domains, or deployment records without writing.",
16945
+ inputSchema: {
16946
+ source: exports_external.enum(["manual", "projects", "servers", "domains", "deployment"]),
16947
+ records: exports_external.array(exports_external.unknown())
16948
+ }
16949
+ }, async (args) => jsonResult(service.previewImport({ source: args.source, records: args.records })));
16950
+ server.registerTool("uptime_create_probe", {
16951
+ title: "Create a private probe identity",
16952
+ description: "Create a local private probe identity and keypair, or register an externally managed public key.",
16953
+ inputSchema: {
16954
+ name: exports_external.string(),
16955
+ publicKeyPem: exports_external.string(),
16956
+ enabled: exports_external.boolean().optional()
16957
+ }
16958
+ }, async (args) => jsonResult(service.createProbe(args)));
16959
+ server.registerTool("uptime_list_probes", {
16960
+ title: "List private probe identities",
16961
+ description: "List local private probe identities.",
16962
+ inputSchema: {
16963
+ includeDisabled: exports_external.boolean().optional()
16964
+ }
16965
+ }, async (args) => jsonResult(service.listProbes({ includeDisabled: args.includeDisabled })));
16966
+ server.registerTool("uptime_create_probe_job", {
16967
+ title: "Create a private probe check job",
16968
+ description: "Create a local check job that a private probe can claim before submitting a signed result.",
16969
+ inputSchema: {
16970
+ monitorId: exports_external.string(),
16971
+ scheduleSlot: exports_external.string(),
16972
+ dueAt: exports_external.string().optional()
16973
+ }
16974
+ }, async (args) => jsonResult(service.createProbeCheckJob(args)));
16975
+ server.registerTool("uptime_claim_probe_job", {
16976
+ title: "Claim a private probe check job",
16977
+ description: "Claim a local private probe check job and receive a fencing token for signed result submission.",
16978
+ inputSchema: {
16979
+ jobId: exports_external.string(),
16980
+ probeId: exports_external.string(),
16981
+ leaseTtlMs: exports_external.number().int().min(1000).optional()
16982
+ }
16983
+ }, async (args) => jsonResult(service.claimProbeCheckJob(args)));
16984
+ server.registerTool("uptime_submit_probe_result", {
16985
+ title: "Submit a signed private probe result",
16986
+ description: "Submit a signed local private probe result for a claimed check job.",
16987
+ inputSchema: {
16988
+ probeId: exports_external.string(),
16989
+ jobId: exports_external.string(),
16990
+ scheduleSlot: exports_external.string(),
16991
+ fencingToken: exports_external.string(),
16992
+ monitorId: exports_external.string(),
16993
+ nonce: exports_external.string(),
16994
+ checkedAt: exports_external.string(),
16995
+ status: exports_external.enum(["up", "down"]),
16996
+ latencyMs: exports_external.number().nonnegative().nullable(),
16997
+ statusCode: exports_external.number().int().min(100).max(599).nullable().optional(),
16998
+ error: exports_external.string().nullable().optional(),
16999
+ attemptCount: exports_external.number().int().min(1).max(20).optional(),
17000
+ monitorRevision: exports_external.number().int().min(1),
17001
+ evidence: exports_external.unknown().nullable().optional(),
17002
+ signature: exports_external.string()
17003
+ }
17004
+ }, async (args) => jsonResult(service.submitProbeResult({ ...args, evidence: args.evidence })));
17005
+ server.registerTool("uptime_import_apply", {
17006
+ title: "Apply an uptime inventory import",
17007
+ description: "Apply monitor candidates from manual, projects, servers, domains, or deployment records idempotently.",
17008
+ inputSchema: {
17009
+ source: exports_external.enum(["manual", "projects", "servers", "domains", "deployment"]),
17010
+ records: exports_external.array(exports_external.unknown())
17011
+ }
17012
+ }, async (args) => jsonResult(service.applyImport({ source: args.source, records: args.records })));
17013
+ server.registerTool("uptime_import_rollback", {
17014
+ title: "Rollback an uptime import batch",
17015
+ description: "Rollback config changes from an import batch while preserving check history.",
17016
+ inputSchema: {
17017
+ batchId: exports_external.string()
17018
+ }
17019
+ }, async (args) => jsonResult(service.rollbackImport(args.batchId)));
15422
17020
  server.registerTool("uptime_results", {
15423
17021
  title: "List uptime check results",
15424
17022
  description: "List recent check results.",