@hasna/uptime 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -2642,6 +2642,9 @@ async function runTcpCheck(monitor) {
2642
2642
  });
2643
2643
  }
2644
2644
 
2645
+ // src/service.ts
2646
+ import { randomUUID as randomUUID2 } from "crypto";
2647
+
2645
2648
  // src/store.ts
2646
2649
  import { mkdirSync as mkdirSync2 } from "fs";
2647
2650
  import { dirname } from "path";
@@ -2674,6 +2677,13 @@ var MAX_RETRY_COUNT = 10;
2674
2677
  var MAX_RESULT_LIMIT = 1000;
2675
2678
 
2676
2679
  // src/store.ts
2680
+ class StaleCheckResultError extends Error {
2681
+ constructor(message) {
2682
+ super(message);
2683
+ this.name = "StaleCheckResultError";
2684
+ }
2685
+ }
2686
+
2677
2687
  class UptimeStore {
2678
2688
  dbPath;
2679
2689
  db;
@@ -2707,10 +2717,12 @@ class UptimeStore {
2707
2717
  enabled INTEGER NOT NULL DEFAULT 1,
2708
2718
  status TEXT NOT NULL DEFAULT 'unknown',
2709
2719
  last_checked_at TEXT,
2720
+ revision INTEGER NOT NULL DEFAULT 1,
2710
2721
  created_at TEXT NOT NULL,
2711
2722
  updated_at TEXT NOT NULL
2712
2723
  )
2713
2724
  `);
2725
+ this.ensureColumn("monitors", "revision", "INTEGER NOT NULL DEFAULT 1");
2714
2726
  this.db.run(`
2715
2727
  CREATE TABLE IF NOT EXISTS check_results (
2716
2728
  id TEXT PRIMARY KEY,
@@ -2736,8 +2748,17 @@ class UptimeStore {
2736
2748
  reason TEXT
2737
2749
  )
2738
2750
  `);
2751
+ this.db.run(`
2752
+ CREATE TABLE IF NOT EXISTS check_leases (
2753
+ monitor_id TEXT PRIMARY KEY REFERENCES monitors(id) ON DELETE CASCADE,
2754
+ owner TEXT NOT NULL,
2755
+ leased_until TEXT NOT NULL,
2756
+ acquired_at TEXT NOT NULL
2757
+ )
2758
+ `);
2739
2759
  this.db.run("CREATE INDEX IF NOT EXISTS idx_results_monitor_time ON check_results(monitor_id, checked_at DESC)");
2740
2760
  this.db.run("CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents(monitor_id, status)");
2761
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_check_leases_until ON check_leases(leased_until)");
2741
2762
  }
2742
2763
  createMonitor(input) {
2743
2764
  const normalized = normalizeCreateMonitor(input);
@@ -2757,14 +2778,15 @@ class UptimeStore {
2757
2778
  enabled: normalized.enabled ?? true,
2758
2779
  status: normalized.enabled === false ? "paused" : "unknown",
2759
2780
  lastCheckedAt: null,
2781
+ revision: 1,
2760
2782
  createdAt: now,
2761
2783
  updatedAt: now
2762
2784
  };
2763
2785
  this.db.query(`INSERT INTO monitors (
2764
2786
  id, name, kind, url, host, port, method, expected_status,
2765
2787
  interval_seconds, timeout_ms, retry_count, enabled, status,
2766
- last_checked_at, created_at, updated_at
2767
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(monitor.id, monitor.name, monitor.kind, monitor.url, monitor.host, monitor.port, monitor.method, monitor.expectedStatus, monitor.intervalSeconds, monitor.timeoutMs, monitor.retryCount, monitor.enabled ? 1 : 0, monitor.status, monitor.lastCheckedAt, monitor.createdAt, monitor.updatedAt);
2788
+ last_checked_at, revision, created_at, updated_at
2789
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(monitor.id, monitor.name, monitor.kind, monitor.url, monitor.host, monitor.port, monitor.method, monitor.expectedStatus, monitor.intervalSeconds, monitor.timeoutMs, monitor.retryCount, monitor.enabled ? 1 : 0, monitor.status, monitor.lastCheckedAt, monitor.revision, monitor.createdAt, monitor.updatedAt);
2768
2790
  return monitor;
2769
2791
  }
2770
2792
  listMonitors(options = {}) {
@@ -2784,8 +2806,12 @@ class UptimeStore {
2784
2806
  this.db.query(`UPDATE monitors SET
2785
2807
  name = ?, kind = ?, url = ?, host = ?, port = ?, method = ?,
2786
2808
  expected_status = ?, interval_seconds = ?, timeout_ms = ?,
2787
- retry_count = ?, enabled = ?, status = ?, last_checked_at = ?, updated_at = ?
2809
+ retry_count = ?, enabled = ?, status = ?, last_checked_at = ?,
2810
+ revision = revision + 1, updated_at = ?
2788
2811
  WHERE id = ?`).run(next.name, next.kind, next.url, next.host, next.port, next.method, next.expectedStatus, next.intervalSeconds, next.timeoutMs, next.retryCount, next.enabled ? 1 : 0, next.status, next.lastCheckedAt, updatedAt, current.id);
2812
+ if (definitionChanged(current, next)) {
2813
+ this.closeOpenIncident(current.id, updatedAt);
2814
+ }
2789
2815
  return this.getMonitor(current.id);
2790
2816
  }
2791
2817
  deleteMonitor(idOrName) {
@@ -2795,10 +2821,31 @@ class UptimeStore {
2795
2821
  this.db.query("DELETE FROM monitors WHERE id = ?").run(current.id);
2796
2822
  return true;
2797
2823
  }
2824
+ acquireCheckLease(monitorId, owner, ttlMs) {
2825
+ const now = new Date;
2826
+ const nowIso = now.toISOString();
2827
+ const leasedUntil = new Date(now.getTime() + Math.max(1000, ttlMs)).toISOString();
2828
+ const tx = this.db.transaction(() => {
2829
+ this.db.query("DELETE FROM check_leases WHERE monitor_id = ? AND leased_until <= ?").run(monitorId, nowIso);
2830
+ this.db.query("INSERT OR IGNORE INTO check_leases (monitor_id, owner, leased_until, acquired_at) VALUES (?, ?, ?, ?)").run(monitorId, owner, leasedUntil, nowIso);
2831
+ const row = this.db.query("SELECT * FROM check_leases WHERE monitor_id = ?").get(monitorId);
2832
+ return row?.owner === owner;
2833
+ });
2834
+ return tx();
2835
+ }
2836
+ releaseCheckLease(monitorId, owner) {
2837
+ this.db.query("DELETE FROM check_leases WHERE monitor_id = ? AND owner = ?").run(monitorId, owner);
2838
+ }
2798
2839
  recordCheckResult(input) {
2799
2840
  const monitor = this.getMonitor(input.monitorId);
2800
2841
  if (!monitor)
2801
2842
  throw new Error(`Monitor not found: ${input.monitorId}`);
2843
+ if (input.expectedMonitorRevision !== undefined && monitor.revision !== input.expectedMonitorRevision) {
2844
+ throw new StaleCheckResultError(`Monitor changed while check was in progress: ${monitor.name}`);
2845
+ }
2846
+ if (!monitor.enabled) {
2847
+ throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${monitor.name}`);
2848
+ }
2802
2849
  const checkedAt = input.checkedAt ?? new Date().toISOString();
2803
2850
  const result = {
2804
2851
  id: newId("chk"),
@@ -2811,6 +2858,15 @@ class UptimeStore {
2811
2858
  attemptCount: Math.max(1, input.attemptCount)
2812
2859
  };
2813
2860
  const tx = this.db.transaction(() => {
2861
+ const current = this.db.query("SELECT * FROM monitors WHERE id = ?").get(result.monitorId);
2862
+ if (!current)
2863
+ throw new Error(`Monitor not found: ${result.monitorId}`);
2864
+ if (input.expectedMonitorRevision !== undefined && current.revision !== input.expectedMonitorRevision) {
2865
+ throw new StaleCheckResultError(`Monitor changed while check was in progress: ${current.name}`);
2866
+ }
2867
+ if (!current.enabled) {
2868
+ throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${current.name}`);
2869
+ }
2814
2870
  this.db.query(`INSERT INTO check_results (
2815
2871
  id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count
2816
2872
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(result.id, result.monitorId, result.checkedAt, result.status, result.latencyMs, result.statusCode, result.error, result.attemptCount);
@@ -2858,10 +2914,14 @@ class UptimeStore {
2858
2914
  down: monitors.filter((m) => m.status === "down").length,
2859
2915
  paused: monitors.filter((m) => !m.enabled || m.status === "paused").length,
2860
2916
  unknown: monitors.filter((m) => m.status === "unknown").length,
2861
- openIncidents: this.listIncidents({ status: "open", limit: 1000 }).length
2917
+ openIncidents: this.countOpenIncidents()
2862
2918
  }
2863
2919
  };
2864
2920
  }
2921
+ countOpenIncidents() {
2922
+ const row = this.db.query("SELECT COUNT(*) AS count FROM incidents WHERE status = 'open'").get();
2923
+ return Number(row?.count ?? 0);
2924
+ }
2865
2925
  monitorSummary(monitor) {
2866
2926
  const row = this.db.query(`SELECT
2867
2927
  COUNT(*) as total,
@@ -2899,13 +2959,24 @@ class UptimeStore {
2899
2959
  this.db.query("UPDATE incidents SET status = 'closed', closed_at = ?, recovery_check_id = ? WHERE id = ?").run(result.checkedAt, result.id, open.id);
2900
2960
  }
2901
2961
  }
2962
+ closeOpenIncident(monitorId, closedAt) {
2963
+ this.db.query("UPDATE incidents SET status = 'closed', closed_at = ? WHERE monitor_id = ? AND status = 'open'").run(closedAt, monitorId);
2964
+ }
2965
+ ensureColumn(table, name, definition) {
2966
+ const columns = this.db.query(`PRAGMA table_info(${table})`).all();
2967
+ if (!columns.some((column) => column.name === name)) {
2968
+ this.db.run(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
2969
+ }
2970
+ }
2902
2971
  }
2903
2972
  function normalizeCreateMonitor(input) {
2904
2973
  const name = input.name?.trim();
2905
2974
  if (!name)
2906
2975
  throw new Error("Monitor name is required");
2976
+ rejectControlCharacters(name, "Monitor name");
2907
2977
  const method = normalizeMethod(input.method ?? "GET");
2908
2978
  const expectedStatus = normalizeExpectedStatus(input.expectedStatus);
2979
+ const enabled = normalizeEnabled(input.enabled);
2909
2980
  if (input.kind === "http") {
2910
2981
  const url = normalizeHttpUrl(input.url);
2911
2982
  return {
@@ -2917,12 +2988,13 @@ function normalizeCreateMonitor(input) {
2917
2988
  intervalSeconds: boundedInteger(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
2918
2989
  timeoutMs: boundedInteger(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
2919
2990
  retryCount: boundedInteger(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
2920
- enabled: input.enabled ?? true
2991
+ enabled
2921
2992
  };
2922
2993
  } else if (input.kind === "tcp") {
2923
2994
  const host = input.host?.trim();
2924
2995
  if (!host)
2925
2996
  throw new Error("TCP monitors require host");
2997
+ rejectControlCharacters(host, "TCP host");
2926
2998
  if (!Number.isInteger(input.port) || input.port <= 0 || input.port > 65535) {
2927
2999
  throw new Error("TCP monitors require a port from 1 to 65535");
2928
3000
  }
@@ -2936,12 +3008,15 @@ function normalizeCreateMonitor(input) {
2936
3008
  intervalSeconds: boundedInteger(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
2937
3009
  timeoutMs: boundedInteger(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
2938
3010
  retryCount: boundedInteger(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
2939
- enabled: input.enabled ?? true
3011
+ enabled
2940
3012
  };
2941
3013
  } else {
2942
3014
  throw new Error("Monitor kind must be http or tcp");
2943
3015
  }
2944
3016
  }
3017
+ function definitionChanged(current, next) {
3018
+ return next.kind !== current.kind || next.url !== current.url || next.host !== current.host || next.port !== current.port || next.method !== current.method || next.expectedStatus !== current.expectedStatus;
3019
+ }
2945
3020
  function normalizeUpdateMonitor(current, input, updatedAt) {
2946
3021
  const merged = {
2947
3022
  ...current,
@@ -3006,6 +3081,18 @@ function normalizeExpectedStatus(value) {
3006
3081
  }
3007
3082
  return value;
3008
3083
  }
3084
+ function normalizeEnabled(value) {
3085
+ if (value === undefined)
3086
+ return true;
3087
+ if (typeof value !== "boolean")
3088
+ throw new Error("enabled must be a boolean");
3089
+ return value;
3090
+ }
3091
+ function rejectControlCharacters(value, label) {
3092
+ if (/[\x00-\x1f\x7f-\x9f]/.test(value)) {
3093
+ throw new Error(`${label} must not contain control characters`);
3094
+ }
3095
+ }
3009
3096
  function monitorFromRow(row) {
3010
3097
  return {
3011
3098
  id: row.id,
@@ -3022,6 +3109,7 @@ function monitorFromRow(row) {
3022
3109
  enabled: Boolean(row.enabled),
3023
3110
  status: row.status,
3024
3111
  lastCheckedAt: row.last_checked_at,
3112
+ revision: row.revision ?? 1,
3025
3113
  createdAt: row.created_at,
3026
3114
  updatedAt: row.updated_at
3027
3115
  };
@@ -3070,10 +3158,281 @@ function round(value, places) {
3070
3158
  return Math.round(value * factor) / factor;
3071
3159
  }
3072
3160
 
3161
+ // src/report.ts
3162
+ var DEFAULT_MAILERY_API_URL = "http://localhost:3900";
3163
+ var DEFAULT_TELEPHONY_API_URL = "http://localhost:19451";
3164
+ var DEFAULT_LOGS_API_URL = "http://localhost:3460";
3165
+ var DEFAULT_TIMEOUT_MS = 15000;
3166
+ function buildUptimeReport(summary, options = {}) {
3167
+ const subject = options.subject ?? defaultSubject(summary);
3168
+ const lines = [
3169
+ subject,
3170
+ `Generated: ${summary.generatedAt}`,
3171
+ `Monitors: ${summary.totals.monitors} total, ${summary.totals.enabled} enabled, ${summary.totals.up} up, ${summary.totals.down} down, ${summary.totals.openIncidents} open incidents`,
3172
+ "",
3173
+ ...summary.monitors.map(renderMonitorLine)
3174
+ ];
3175
+ const text = lines.join(`
3176
+ `).trimEnd();
3177
+ const json = {
3178
+ kind: "open-uptime.report",
3179
+ generated_at: summary.generatedAt,
3180
+ subject,
3181
+ totals: summary.totals,
3182
+ monitors: summary.monitors
3183
+ };
3184
+ return {
3185
+ subject,
3186
+ generatedAt: summary.generatedAt,
3187
+ summary,
3188
+ text,
3189
+ html: `<pre>${escapeHtml(text)}</pre>`,
3190
+ json
3191
+ };
3192
+ }
3193
+ async function sendUptimeReport(summary, options = {}) {
3194
+ const report = buildUptimeReport(summary, options);
3195
+ const fetchImpl = options.fetchImpl ?? fetch;
3196
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
3197
+ const deliveries = [];
3198
+ if (options.email) {
3199
+ deliveries.push(await sendEmailReport(report, resolveEmailTarget(options.email), fetchImpl, timeoutMs));
3200
+ }
3201
+ if (options.sms) {
3202
+ const smsTarget = resolveSmsTarget(options.sms);
3203
+ const recipients = splitTargets(smsTarget.to);
3204
+ if (recipients.length === 0) {
3205
+ deliveries.push(await sendSmsReport(report, smsTarget, fetchImpl, timeoutMs));
3206
+ } else {
3207
+ for (const target of recipients) {
3208
+ deliveries.push(await sendSmsReport(report, { ...smsTarget, to: target }, fetchImpl, timeoutMs));
3209
+ }
3210
+ }
3211
+ }
3212
+ if (options.logs) {
3213
+ deliveries.push(await sendLogsReport(report, resolveLogsTarget(options.logs), fetchImpl, timeoutMs));
3214
+ }
3215
+ return deliveries;
3216
+ }
3217
+ function defaultSubject(summary) {
3218
+ if (summary.totals.openIncidents > 0 || summary.totals.down > 0) {
3219
+ return `Open Uptime alert: ${summary.totals.down} down, ${summary.totals.openIncidents} open incidents`;
3220
+ }
3221
+ return `Open Uptime report: ${summary.totals.up}/${summary.totals.enabled} enabled monitors up`;
3222
+ }
3223
+ function renderMonitorLine(item) {
3224
+ const uptime = item.uptimePercent == null ? "-" : `${item.uptimePercent.toFixed(2)}%`;
3225
+ const latency = item.averageLatencyMs == null ? "-" : `${item.averageLatencyMs}ms`;
3226
+ const incident = item.openIncident ? ` open incident: ${item.openIncident.reason ?? "down"}` : "";
3227
+ return `- ${item.monitor.status.toUpperCase()} ${item.monitor.name} (${targetLabel(item)}): uptime ${uptime}, latency ${latency}${incident}`;
3228
+ }
3229
+ function targetLabel(item) {
3230
+ return item.monitor.kind === "http" ? item.monitor.url ?? "" : `${item.monitor.host}:${item.monitor.port}`;
3231
+ }
3232
+ function resolveEmailTarget(value) {
3233
+ const target = typeof value === "boolean" ? {} : value;
3234
+ return {
3235
+ apiUrl: target.apiUrl ?? env2("HASNA_MAILERY_API_URL", "MAILERY_API_URL") ?? DEFAULT_MAILERY_API_URL,
3236
+ sendKey: target.sendKey ?? env2("HASNA_MAILERY_SEND_KEY", "MAILERY_SEND_KEY", "ESK"),
3237
+ from: target.from ?? env2("HASNA_UPTIME_REPORT_EMAIL_FROM", "UPTIME_REPORT_EMAIL_FROM"),
3238
+ to: target.to ?? env2("HASNA_UPTIME_REPORT_EMAIL_TO", "UPTIME_REPORT_EMAIL_TO"),
3239
+ subject: target.subject,
3240
+ providerId: target.providerId ?? env2("HASNA_MAILERY_PROVIDER_ID", "MAILERY_PROVIDER_ID")
3241
+ };
3242
+ }
3243
+ function resolveSmsTarget(value) {
3244
+ const target = typeof value === "boolean" ? {} : value;
3245
+ return {
3246
+ apiUrl: target.apiUrl ?? env2("HASNA_TELEPHONY_API_URL", "TELEPHONY_API_URL") ?? DEFAULT_TELEPHONY_API_URL,
3247
+ from: target.from ?? env2("HASNA_UPTIME_REPORT_SMS_FROM", "UPTIME_REPORT_SMS_FROM"),
3248
+ to: target.to ?? env2("HASNA_UPTIME_REPORT_PHONE_TO", "UPTIME_REPORT_PHONE_TO")
3249
+ };
3250
+ }
3251
+ function resolveLogsTarget(value) {
3252
+ const target = typeof value === "boolean" ? {} : value;
3253
+ return {
3254
+ apiUrl: target.apiUrl ?? env2("HASNA_LOGS_URL", "LOGS_URL") ?? DEFAULT_LOGS_API_URL,
3255
+ apiKey: target.apiKey ?? env2("HASNA_LOGS_API_TOKEN", "LOGS_API_TOKEN", "HASNA_LOGS_API_KEY", "LOGS_API_KEY"),
3256
+ projectId: target.projectId ?? env2("HASNA_LOGS_PROJECT_ID", "LOGS_PROJECT_ID") ?? "open-uptime",
3257
+ environment: target.environment ?? env2("HASNA_ENV", "NODE_ENV"),
3258
+ service: target.service ?? "open-uptime"
3259
+ };
3260
+ }
3261
+ async function sendEmailReport(report, target, fetchImpl, timeoutMs) {
3262
+ if (!target.sendKey)
3263
+ return { channel: "email", ok: false, error: "Mailery send key is required" };
3264
+ if (!target.from)
3265
+ return { channel: "email", ok: false, error: "Email from address is required" };
3266
+ if (!hasTargets(target.to))
3267
+ return { channel: "email", ok: false, error: "Email recipient is required" };
3268
+ const body = {
3269
+ from: target.from,
3270
+ to: splitTargets(target.to),
3271
+ subject: target.subject ?? report.subject,
3272
+ text: report.text,
3273
+ html: report.html,
3274
+ provider_id: target.providerId
3275
+ };
3276
+ return requestJson("email", `${normalizeUrl(target.apiUrl ?? DEFAULT_MAILERY_API_URL)}/api/v1/send`, {
3277
+ method: "POST",
3278
+ headers: { authorization: `Bearer ${target.sendKey}` },
3279
+ body
3280
+ }, fetchImpl, timeoutMs, secretsForTarget(target));
3281
+ }
3282
+ async function sendSmsReport(report, target, fetchImpl, timeoutMs) {
3283
+ if (!hasTargets(target.to))
3284
+ return { channel: "sms", ok: false, error: "SMS recipient phone number is required" };
3285
+ return requestJson("sms", `${normalizeUrl(target.apiUrl ?? DEFAULT_TELEPHONY_API_URL)}/api/sms/send`, {
3286
+ method: "POST",
3287
+ body: {
3288
+ to: Array.isArray(target.to) ? target.to[0] : target.to,
3289
+ from: target.from,
3290
+ body: truncateSms(report.text)
3291
+ }
3292
+ }, fetchImpl, timeoutMs, secretsForTarget(target));
3293
+ }
3294
+ async function sendLogsReport(report, target, fetchImpl, timeoutMs) {
3295
+ const params = new URLSearchParams({
3296
+ format: "json",
3297
+ source: "structured",
3298
+ service: target.service ?? "open-uptime",
3299
+ project_id: target.projectId ?? "open-uptime"
3300
+ });
3301
+ if (target.environment)
3302
+ params.set("environment", target.environment);
3303
+ return requestJson("logs", `${normalizeUrl(target.apiUrl ?? DEFAULT_LOGS_API_URL)}/api/logs/structured?${params}`, {
3304
+ method: "POST",
3305
+ headers: target.apiKey ? { authorization: `Bearer ${target.apiKey}` } : undefined,
3306
+ body: {
3307
+ timestamp: report.generatedAt,
3308
+ level: report.summary.totals.down > 0 || report.summary.totals.openIncidents > 0 ? "warn" : "info",
3309
+ message: report.subject,
3310
+ report: report.json
3311
+ }
3312
+ }, fetchImpl, timeoutMs, secretsForTarget(target));
3313
+ }
3314
+ async function requestJson(channel, url, options, fetchImpl, timeoutMs, secrets = []) {
3315
+ const controller = new AbortController;
3316
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
3317
+ try {
3318
+ const response = await fetchImpl(url, {
3319
+ method: options.method,
3320
+ signal: controller.signal,
3321
+ headers: {
3322
+ "content-type": "application/json",
3323
+ accept: "application/json",
3324
+ ...options.headers
3325
+ },
3326
+ body: JSON.stringify(options.body)
3327
+ });
3328
+ const text = await response.text();
3329
+ const data = parseMaybeJson(text);
3330
+ if (!response.ok) {
3331
+ return { channel, ok: false, status: response.status, error: errorFromResponse(data, response.statusText, secrets) };
3332
+ }
3333
+ return { channel, ok: true, status: response.status, id: redactOptional(idFromResponse(data), secrets) };
3334
+ } catch (error) {
3335
+ const message = error instanceof Error && error.name === "AbortError" ? "request timed out" : error instanceof Error ? error.message : String(error);
3336
+ return { channel, ok: false, error: redactSecrets(message, secrets) };
3337
+ } finally {
3338
+ clearTimeout(timer);
3339
+ }
3340
+ }
3341
+ function hasTargets(value) {
3342
+ return splitTargets(value).length > 0;
3343
+ }
3344
+ function splitTargets(value) {
3345
+ if (!value)
3346
+ return [];
3347
+ const values = Array.isArray(value) ? value : value.split(",");
3348
+ return values.map((item) => item.trim()).filter(Boolean);
3349
+ }
3350
+ function normalizeUrl(value) {
3351
+ const parsed = new URL(value.trim());
3352
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
3353
+ throw new Error("Integration API URL must use http or https");
3354
+ }
3355
+ return parsed.toString().replace(/\/$/, "");
3356
+ }
3357
+ function truncateSms(value) {
3358
+ return value.length > 1400 ? `${value.slice(0, 1397)}...` : value;
3359
+ }
3360
+ function parseMaybeJson(text) {
3361
+ if (!text.trim())
3362
+ return {};
3363
+ try {
3364
+ return JSON.parse(text);
3365
+ } catch {
3366
+ return { message: text };
3367
+ }
3368
+ }
3369
+ function idFromResponse(data) {
3370
+ if (!data || typeof data !== "object")
3371
+ return;
3372
+ const record = data;
3373
+ for (const key of ["id", "message_id", "event_id"]) {
3374
+ if (typeof record[key] === "string")
3375
+ return record[key];
3376
+ }
3377
+ return;
3378
+ }
3379
+ function errorFromResponse(data, fallback, secrets = []) {
3380
+ if (data && typeof data === "object") {
3381
+ const record = data;
3382
+ if (typeof record.error === "string")
3383
+ return redactSecrets(record.error, secrets);
3384
+ if (typeof record.message === "string")
3385
+ return redactSecrets(record.message, secrets);
3386
+ }
3387
+ return redactSecrets(fallback, secrets);
3388
+ }
3389
+ function env2(...keys) {
3390
+ for (const key of keys) {
3391
+ const value = process.env[key]?.trim();
3392
+ if (value)
3393
+ return value;
3394
+ }
3395
+ return;
3396
+ }
3397
+ function escapeHtml(value) {
3398
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3399
+ }
3400
+ function secretsForTarget(target) {
3401
+ const values = new Set;
3402
+ for (const key of ["sendKey", "apiKey"]) {
3403
+ const value = target[key];
3404
+ if (typeof value === "string" && value.trim())
3405
+ values.add(value.trim());
3406
+ }
3407
+ const apiUrl = target.apiUrl;
3408
+ if (apiUrl) {
3409
+ try {
3410
+ const parsed = new URL(apiUrl);
3411
+ if (parsed.username)
3412
+ values.add(decodeURIComponent(parsed.username));
3413
+ if (parsed.password)
3414
+ values.add(decodeURIComponent(parsed.password));
3415
+ } catch {}
3416
+ }
3417
+ return [...values];
3418
+ }
3419
+ function redactSecrets(value, secrets = []) {
3420
+ let redacted = value;
3421
+ for (const secret of secrets) {
3422
+ if (secret.length >= 3)
3423
+ redacted = redacted.split(secret).join("[REDACTED]");
3424
+ }
3425
+ return redacted.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]").replace(/\besk_[A-Za-z0-9._~+/=-]+/g, "esk_[REDACTED]");
3426
+ }
3427
+ function redactOptional(value, secrets) {
3428
+ return value === undefined ? undefined : redactSecrets(value, secrets);
3429
+ }
3430
+
3073
3431
  // src/service.ts
3074
3432
  class UptimeService {
3075
3433
  store;
3076
3434
  checkRunner;
3435
+ leaseOwner = `svc_${randomUUID2().replace(/-/g, "").slice(0, 18)}`;
3077
3436
  inFlightChecks = new Set;
3078
3437
  constructor(options = {}) {
3079
3438
  this.store = options.store ?? new UptimeStore(options);
@@ -3106,6 +3465,12 @@ class UptimeService {
3106
3465
  summary() {
3107
3466
  return this.store.summary();
3108
3467
  }
3468
+ buildReport(options = {}) {
3469
+ return buildUptimeReport(this.summary(), options);
3470
+ }
3471
+ async sendReport(options = {}) {
3472
+ return sendUptimeReport(this.summary(), options);
3473
+ }
3109
3474
  async checkMonitor(idOrName) {
3110
3475
  const monitor = this.store.getMonitor(idOrName);
3111
3476
  if (!monitor)
@@ -3114,6 +3479,10 @@ class UptimeService {
3114
3479
  throw new Error(`Monitor is disabled: ${monitor.name}`);
3115
3480
  if (this.inFlightChecks.has(monitor.id))
3116
3481
  throw new Error(`Monitor check already in progress: ${monitor.name}`);
3482
+ const leaseTtlMs = Math.max(60000, (monitor.retryCount + 1) * monitor.timeoutMs + 1e4);
3483
+ if (!this.store.acquireCheckLease(monitor.id, this.leaseOwner, leaseTtlMs)) {
3484
+ throw new MonitorCheckBusyError(`Monitor check already in progress: ${monitor.name}`);
3485
+ }
3117
3486
  this.inFlightChecks.add(monitor.id);
3118
3487
  try {
3119
3488
  let attemptCount = 0;
@@ -3131,10 +3500,12 @@ class UptimeService {
3131
3500
  latencyMs: last.latencyMs,
3132
3501
  statusCode: last.statusCode ?? null,
3133
3502
  error: last.error ?? null,
3134
- attemptCount
3503
+ attemptCount,
3504
+ expectedMonitorRevision: monitor.revision
3135
3505
  });
3136
3506
  } finally {
3137
3507
  this.inFlightChecks.delete(monitor.id);
3508
+ this.store.releaseCheckLease(monitor.id, this.leaseOwner);
3138
3509
  }
3139
3510
  }
3140
3511
  async checkAll() {
@@ -3163,7 +3534,13 @@ class UptimeService {
3163
3534
  const current = this.store.getMonitor(monitor.id);
3164
3535
  if (!current || !this.isDue(current, now))
3165
3536
  continue;
3166
- results.push(await this.checkMonitor(current.id));
3537
+ try {
3538
+ results.push(await this.checkMonitor(current.id));
3539
+ } catch (error) {
3540
+ if (error instanceof MonitorCheckBusyError || error instanceof StaleCheckResultError)
3541
+ continue;
3542
+ throw error;
3543
+ }
3167
3544
  }
3168
3545
  return results;
3169
3546
  }
@@ -3178,6 +3555,12 @@ class UptimeService {
3178
3555
  return now.getTime() - last >= monitor.intervalSeconds * 1000;
3179
3556
  }
3180
3557
  }
3558
+ class MonitorCheckBusyError extends Error {
3559
+ constructor(message) {
3560
+ super(message);
3561
+ this.name = "MonitorCheckBusyError";
3562
+ }
3563
+ }
3181
3564
 
3182
3565
  // src/version.ts
3183
3566
  import { readFileSync } from "fs";
@@ -3548,11 +3931,11 @@ function dashboardHtml() {
3548
3931
  }
3549
3932
 
3550
3933
  // src/api.ts
3551
- function createApiHandler(service) {
3934
+ function createApiHandler(service, options = {}) {
3552
3935
  return async (request) => {
3553
3936
  const url = new URL(request.url);
3554
3937
  try {
3555
- validateLocalMutationRequest(request, url);
3938
+ validateLocalMutationRequest(request, url, options);
3556
3939
  if (request.method === "GET" && url.pathname === "/") {
3557
3940
  return html(dashboardHtml());
3558
3941
  }
@@ -3562,6 +3945,13 @@ function createApiHandler(service) {
3562
3945
  if (request.method === "GET" && url.pathname === "/api/summary") {
3563
3946
  return json(service.summary());
3564
3947
  }
3948
+ if (request.method === "GET" && url.pathname === "/api/report") {
3949
+ return json(service.buildReport());
3950
+ }
3951
+ if (request.method === "POST" && url.pathname === "/api/report") {
3952
+ const input = await jsonBody(request);
3953
+ return json(await service.sendReport({ ...input, fetchImpl: options.fetchImpl }));
3954
+ }
3565
3955
  if (request.method === "GET" && url.pathname === "/api/monitors") {
3566
3956
  return json(service.listMonitors({ includeDisabled: url.searchParams.get("includeDisabled") === "true" }));
3567
3957
  }
@@ -3614,7 +4004,11 @@ function serveUptime(options = {}) {
3614
4004
  const server = Bun.serve({
3615
4005
  hostname: options.host ?? "127.0.0.1",
3616
4006
  port: options.port ?? 3899,
3617
- fetch: createApiHandler(service)
4007
+ fetch: createApiHandler(service, {
4008
+ apiToken: options.apiToken,
4009
+ allowUnsafeRemoteMutations: options.allowUnsafeRemoteMutations,
4010
+ trustedLoopback: isLoopbackHost(options.host ?? "127.0.0.1")
4011
+ })
3618
4012
  });
3619
4013
  return { server, service, scheduler };
3620
4014
  }
@@ -3642,14 +4036,31 @@ function numericParam(url, name, fallback) {
3642
4036
  const parsed = Number(raw);
3643
4037
  return Number.isFinite(parsed) ? parsed : fallback;
3644
4038
  }
3645
- function validateLocalMutationRequest(request, url) {
4039
+ function validateLocalMutationRequest(request, url, options) {
3646
4040
  if (!["POST", "PATCH", "DELETE"].includes(request.method))
3647
4041
  return;
4042
+ const apiToken = options.apiToken ?? process.env.HASNA_UPTIME_API_TOKEN;
4043
+ const hasToken = apiToken ? hasValidApiToken(request, apiToken) : false;
4044
+ const allowUnsafeRemote = options.allowUnsafeRemoteMutations || process.env.HASNA_UPTIME_ALLOW_REMOTE_MUTATIONS === "1";
4045
+ const trustedLoopback = options.trustedLoopback ?? isLoopbackHost(url.hostname);
4046
+ if (!allowUnsafeRemote && !hasToken && (!trustedLoopback || !isLoopbackHost(url.hostname))) {
4047
+ throw new ApiError("non-loopback host rejected for local mutation", 403);
4048
+ }
3648
4049
  const origin = request.headers.get("origin");
3649
4050
  if (origin && origin !== `${url.protocol}//${url.host}`) {
3650
4051
  throw new ApiError("cross-origin mutation rejected", 403);
3651
4052
  }
3652
4053
  }
4054
+ function isLoopbackHost(hostname) {
4055
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
4056
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
4057
+ }
4058
+ function hasValidApiToken(request, token) {
4059
+ const authorization = request.headers.get("authorization") ?? "";
4060
+ const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
4061
+ const headerToken = request.headers.get("x-uptime-token")?.trim();
4062
+ return bearer === token || headerToken === token;
4063
+ }
3653
4064
  async function jsonBody(request) {
3654
4065
  const contentType = request.headers.get("content-type") ?? "";
3655
4066
  const mediaType = contentType.split(";")[0]?.trim().toLowerCase();
@@ -3680,14 +4091,14 @@ function print(value, text, opts) {
3680
4091
  if (wantsJson(opts))
3681
4092
  console.log(JSON.stringify(value, null, 2));
3682
4093
  else
3683
- console.log(text);
4094
+ console.log(sanitizeTerminal(text));
3684
4095
  }
3685
4096
  function fail(error) {
3686
4097
  const message = error instanceof Error ? error.message : String(error);
3687
4098
  if (program2.opts().json)
3688
4099
  console.log(JSON.stringify({ ok: false, error: message }, null, 2));
3689
4100
  else
3690
- console.error(source_default.red(message));
4101
+ console.error(source_default.red(sanitizeTerminal(message)));
3691
4102
  process.exit(1);
3692
4103
  }
3693
4104
  program2.command("init").description("Initialize the local uptime store").option("-j, --json", "print JSON").action((opts) => {
@@ -3848,6 +4259,45 @@ program2.command("summary").description("Show uptime summary").option("-j, --jso
3848
4259
  fail(error);
3849
4260
  }
3850
4261
  });
4262
+ program2.command("report").description("Build or send an uptime report through Mailery, Telephony, or Open Logs").option("--email <to>", "send an email report to one or more comma-separated recipients through Mailery").option("--from <email>", "Mailery from address").option("--mailery-url <url>", "Mailery API URL").option("--send-key <key>", "Mailery scoped send key").option("--sms <phone>", "send an SMS report to one or more comma-separated phone numbers through Telephony").option("--sms-from <phone>", "Telephony from phone number").option("--telephony-url <url>", "Telephony API URL").option("--logs", "write the report to Open Logs structured logs").option("--logs-url <url>", "Open Logs API URL").option("--logs-api-key <key>", "Open Logs API key").option("--logs-project <id>", "Open Logs project id").option("--subject <subject>", "report subject").option("--dry-run", "print the report without sending").option("-j, --json", "print JSON").action(async (opts) => {
4263
+ try {
4264
+ const svc = service();
4265
+ const wantsDelivery = Boolean(opts.email || opts.sms || opts.logs);
4266
+ if (opts.dryRun || !wantsDelivery) {
4267
+ const report = svc.buildReport({ subject: opts.subject });
4268
+ svc.close();
4269
+ print(report, report.text, opts);
4270
+ return;
4271
+ }
4272
+ const input = {
4273
+ subject: opts.subject,
4274
+ email: opts.email ? {
4275
+ apiUrl: opts.maileryUrl,
4276
+ sendKey: opts.sendKey,
4277
+ from: opts.from,
4278
+ to: splitList(opts.email)
4279
+ } : undefined,
4280
+ sms: opts.sms ? {
4281
+ apiUrl: opts.telephonyUrl,
4282
+ from: opts.smsFrom,
4283
+ to: splitList(opts.sms)
4284
+ } : undefined,
4285
+ logs: opts.logs ? {
4286
+ apiUrl: opts.logsUrl,
4287
+ apiKey: opts.logsApiKey,
4288
+ projectId: opts.logsProject
4289
+ } : undefined
4290
+ };
4291
+ const deliveries = await svc.sendReport(input);
4292
+ svc.close();
4293
+ const failed = deliveries.filter((delivery) => !delivery.ok);
4294
+ print(deliveries, renderDeliveries(deliveries), opts);
4295
+ if (failed.length > 0)
4296
+ process.exit(1);
4297
+ } catch (error) {
4298
+ fail(error);
4299
+ }
4300
+ });
3851
4301
  program2.command("results").description("List recent check results").option("--monitor <id>", "filter by monitor id").option("--limit <n>", "max rows", parseInteger, 20).option("-j, --json", "print JSON").action((opts) => {
3852
4302
  try {
3853
4303
  const svc = service();
@@ -3863,15 +4313,21 @@ program2.command("incidents").description("List incidents").addOption(new Option
3863
4313
  const svc = service();
3864
4314
  const incidents = svc.listIncidents({ status: opts.status, monitorId: opts.monitor, limit: opts.limit });
3865
4315
  svc.close();
3866
- print(incidents, incidents.length ? incidents.map((i) => `${i.status.padEnd(6)} ${i.monitorId} ${i.openedAt} ${i.reason ?? ""}`).join(`
4316
+ print(incidents, incidents.length ? incidents.map((i) => `${i.status.padEnd(6)} ${sanitizeField(i.monitorId)} ${i.openedAt} ${sanitizeField(i.reason ?? "")}`).join(`
3867
4317
  `) : "No incidents", opts);
3868
4318
  } catch (error) {
3869
4319
  fail(error);
3870
4320
  }
3871
4321
  });
3872
- program2.command("serve").description("Serve the local API and dashboard").option("--host <host>", "host to bind", "127.0.0.1").option("--port <port>", "port", parseInteger, 3899).option("--check", "run the scheduler while serving").option("-j, --json", "print JSON").action((opts) => {
4322
+ program2.command("serve").description("Serve the local API and dashboard").option("--host <host>", "host to bind", "127.0.0.1").option("--port <port>", "port", parseInteger, 3899).option("--check", "run the scheduler while serving").option("--api-token <token>", "token required for non-loopback mutation hosts").option("--allow-unsafe-remote-mutations", "allow state-changing requests from non-loopback hosts without a token").option("-j, --json", "print JSON").action((opts) => {
3873
4323
  try {
3874
- const { server } = serveUptime({ host: opts.host, port: opts.port, check: opts.check });
4324
+ const { server } = serveUptime({
4325
+ host: opts.host,
4326
+ port: opts.port,
4327
+ check: opts.check,
4328
+ apiToken: opts.apiToken,
4329
+ allowUnsafeRemoteMutations: opts.allowUnsafeRemoteMutations
4330
+ });
3875
4331
  const data = { ok: true, url: `http://${server.hostname}:${server.port}`, scheduler: Boolean(opts.check) };
3876
4332
  if (wantsJson(opts))
3877
4333
  console.log(JSON.stringify(data, null, 2));
@@ -3893,17 +4349,17 @@ function renderMonitors(monitors) {
3893
4349
  return monitors.map((monitor) => {
3894
4350
  const target = monitor.kind === "http" ? monitor.url : `${monitor.host}:${monitor.port}`;
3895
4351
  const status = renderStatus(monitor.status).padEnd(14);
3896
- return `${status} ${monitor.name.padEnd(24)} ${monitor.kind.padEnd(4)} ${target}`;
4352
+ return `${status} ${sanitizeField(monitor.name).padEnd(24)} ${monitor.kind.padEnd(4)} ${sanitizeField(target ?? "")}`;
3897
4353
  }).join(`
3898
4354
  `);
3899
4355
  }
3900
4356
  function renderMonitorDetail(monitor) {
3901
4357
  const target = monitor.kind === "http" ? monitor.url : `${monitor.host}:${monitor.port}`;
3902
4358
  return [
3903
- `${source_default.bold(monitor.name)} ${renderStatus(monitor.status)}`,
4359
+ `${source_default.bold(sanitizeField(monitor.name))} ${renderStatus(monitor.status)}`,
3904
4360
  `id: ${monitor.id}`,
3905
4361
  `kind: ${monitor.kind}`,
3906
- `target: ${target}`,
4362
+ `target: ${sanitizeField(target ?? "")}`,
3907
4363
  `interval: ${monitor.intervalSeconds}s`,
3908
4364
  `timeout: ${monitor.timeoutMs}ms`,
3909
4365
  `retries: ${monitor.retryCount}`,
@@ -3917,7 +4373,7 @@ function renderCheckResults(results) {
3917
4373
  return "No results";
3918
4374
  return results.map((result) => {
3919
4375
  const latency = result.latencyMs == null ? "-" : `${result.latencyMs}ms`;
3920
- return `${renderStatus(result.status).padEnd(12)} ${result.monitorId} ${result.checkedAt} ${latency} ${result.error ?? ""}`;
4376
+ return `${renderStatus(result.status).padEnd(12)} ${sanitizeField(result.monitorId)} ${result.checkedAt} ${latency} ${sanitizeField(result.error ?? "")}`;
3921
4377
  }).join(`
3922
4378
  `);
3923
4379
  }
@@ -3928,11 +4384,21 @@ function renderSummary(summary) {
3928
4384
  for (const item of summary.monitors) {
3929
4385
  const uptime = item.uptimePercent == null ? "-" : `${item.uptimePercent.toFixed(2)}%`;
3930
4386
  const latency = item.averageLatencyMs == null ? "-" : `${item.averageLatencyMs}ms`;
3931
- lines.push(`${renderStatus(item.monitor.status).padEnd(12)} ${item.monitor.name.padEnd(24)} uptime ${uptime.padStart(8)} latency ${latency}`);
4387
+ lines.push(`${renderStatus(item.monitor.status).padEnd(12)} ${sanitizeField(item.monitor.name).padEnd(24)} uptime ${uptime.padStart(8)} latency ${latency}`);
3932
4388
  }
3933
4389
  return lines.join(`
3934
4390
  `);
3935
4391
  }
4392
+ function renderDeliveries(deliveries) {
4393
+ if (deliveries.length === 0)
4394
+ return "No report deliveries requested";
4395
+ return deliveries.map((delivery) => {
4396
+ const status = delivery.ok ? source_default.green("sent") : source_default.red("failed");
4397
+ const detail = delivery.ok ? delivery.id ?? delivery.status ?? "" : delivery.error ?? "";
4398
+ return `${status.padEnd(12)} ${delivery.channel}${detail ? ` ${sanitizeField(String(detail))}` : ""}`;
4399
+ }).join(`
4400
+ `);
4401
+ }
3936
4402
  function renderStatus(status) {
3937
4403
  if (status === "up")
3938
4404
  return source_default.green("up");
@@ -3942,4 +4408,15 @@ function renderStatus(status) {
3942
4408
  return source_default.yellow("paused");
3943
4409
  return source_default.gray(status);
3944
4410
  }
4411
+ function splitList(value) {
4412
+ if (!value)
4413
+ return;
4414
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
4415
+ }
4416
+ function sanitizeTerminal(value) {
4417
+ return value.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
4418
+ }
4419
+ function sanitizeField(value) {
4420
+ return value.replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
4421
+ }
3945
4422
  program2.parseAsync(process.argv);