@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/index.d.ts CHANGED
@@ -2,6 +2,8 @@ export { createUptimeClient, UptimeService } from "./service.js";
2
2
  export { UptimeStore } from "./store.js";
3
3
  export { runMonitorCheck, runHttpCheck, runTcpCheck } from "./checks.js";
4
4
  export { createApiHandler, serveUptime } from "./api.js";
5
+ export { buildUptimeReport, sendUptimeReport } from "./report.js";
5
6
  export { uptimeHome, uptimeDbPath, ensureUptimeHome } from "./paths.js";
6
7
  export type { CheckAttemptResult, CheckResult, CheckStatus, CreateMonitorInput, Incident, IncidentStatus, ListResultsOptions, Monitor, MonitorKind, MonitorStatus, MonitorSummary, SchedulerHandle, UpdateMonitorInput, UptimeSummary, } from "./types.js";
8
+ export type { BuildUptimeReportOptions, SendUptimeReportOptions, UptimeEmailReportTarget, UptimeLogsReportTarget, UptimeReport, UptimeReportDelivery, UptimeSmsReportTarget, } from "./report.js";
7
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACjE,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AACxE,YAAY,EACV,kBAAkB,EAClB,WAAW,EACX,WAAW,EACX,kBAAkB,EAClB,QAAQ,EACR,cAAc,EACd,kBAAkB,EAClB,OAAO,EACP,WAAW,EACX,aAAa,EACb,cAAc,EACd,eAAe,EACf,kBAAkB,EAClB,aAAa,GACd,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACjE,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACzD,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAClE,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AACxE,YAAY,EACV,kBAAkB,EAClB,WAAW,EACX,WAAW,EACX,kBAAkB,EAClB,QAAQ,EACR,cAAc,EACd,kBAAkB,EAClB,OAAO,EACP,WAAW,EACX,aAAa,EACb,cAAc,EACd,eAAe,EACf,kBAAkB,EAClB,aAAa,GACd,MAAM,YAAY,CAAC;AACpB,YAAY,EACV,wBAAwB,EACxB,uBAAuB,EACvB,uBAAuB,EACvB,sBAAsB,EACtB,YAAY,EACZ,oBAAoB,EACpB,qBAAqB,GACtB,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -98,6 +98,13 @@ var MAX_RETRY_COUNT = 10;
98
98
  var MAX_RESULT_LIMIT = 1000;
99
99
 
100
100
  // src/store.ts
101
+ class StaleCheckResultError extends Error {
102
+ constructor(message) {
103
+ super(message);
104
+ this.name = "StaleCheckResultError";
105
+ }
106
+ }
107
+
101
108
  class UptimeStore {
102
109
  dbPath;
103
110
  db;
@@ -131,10 +138,12 @@ class UptimeStore {
131
138
  enabled INTEGER NOT NULL DEFAULT 1,
132
139
  status TEXT NOT NULL DEFAULT 'unknown',
133
140
  last_checked_at TEXT,
141
+ revision INTEGER NOT NULL DEFAULT 1,
134
142
  created_at TEXT NOT NULL,
135
143
  updated_at TEXT NOT NULL
136
144
  )
137
145
  `);
146
+ this.ensureColumn("monitors", "revision", "INTEGER NOT NULL DEFAULT 1");
138
147
  this.db.run(`
139
148
  CREATE TABLE IF NOT EXISTS check_results (
140
149
  id TEXT PRIMARY KEY,
@@ -160,8 +169,17 @@ class UptimeStore {
160
169
  reason TEXT
161
170
  )
162
171
  `);
172
+ this.db.run(`
173
+ CREATE TABLE IF NOT EXISTS check_leases (
174
+ monitor_id TEXT PRIMARY KEY REFERENCES monitors(id) ON DELETE CASCADE,
175
+ owner TEXT NOT NULL,
176
+ leased_until TEXT NOT NULL,
177
+ acquired_at TEXT NOT NULL
178
+ )
179
+ `);
163
180
  this.db.run("CREATE INDEX IF NOT EXISTS idx_results_monitor_time ON check_results(monitor_id, checked_at DESC)");
164
181
  this.db.run("CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents(monitor_id, status)");
182
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_check_leases_until ON check_leases(leased_until)");
165
183
  }
166
184
  createMonitor(input) {
167
185
  const normalized = normalizeCreateMonitor(input);
@@ -181,14 +199,15 @@ class UptimeStore {
181
199
  enabled: normalized.enabled ?? true,
182
200
  status: normalized.enabled === false ? "paused" : "unknown",
183
201
  lastCheckedAt: null,
202
+ revision: 1,
184
203
  createdAt: now,
185
204
  updatedAt: now
186
205
  };
187
206
  this.db.query(`INSERT INTO monitors (
188
207
  id, name, kind, url, host, port, method, expected_status,
189
208
  interval_seconds, timeout_ms, retry_count, enabled, status,
190
- last_checked_at, created_at, updated_at
191
- ) 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);
209
+ last_checked_at, revision, created_at, updated_at
210
+ ) 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);
192
211
  return monitor;
193
212
  }
194
213
  listMonitors(options = {}) {
@@ -208,8 +227,12 @@ class UptimeStore {
208
227
  this.db.query(`UPDATE monitors SET
209
228
  name = ?, kind = ?, url = ?, host = ?, port = ?, method = ?,
210
229
  expected_status = ?, interval_seconds = ?, timeout_ms = ?,
211
- retry_count = ?, enabled = ?, status = ?, last_checked_at = ?, updated_at = ?
230
+ retry_count = ?, enabled = ?, status = ?, last_checked_at = ?,
231
+ revision = revision + 1, updated_at = ?
212
232
  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);
233
+ if (definitionChanged(current, next)) {
234
+ this.closeOpenIncident(current.id, updatedAt);
235
+ }
213
236
  return this.getMonitor(current.id);
214
237
  }
215
238
  deleteMonitor(idOrName) {
@@ -219,10 +242,31 @@ class UptimeStore {
219
242
  this.db.query("DELETE FROM monitors WHERE id = ?").run(current.id);
220
243
  return true;
221
244
  }
245
+ acquireCheckLease(monitorId, owner, ttlMs) {
246
+ const now = new Date;
247
+ const nowIso = now.toISOString();
248
+ const leasedUntil = new Date(now.getTime() + Math.max(1000, ttlMs)).toISOString();
249
+ const tx = this.db.transaction(() => {
250
+ this.db.query("DELETE FROM check_leases WHERE monitor_id = ? AND leased_until <= ?").run(monitorId, nowIso);
251
+ this.db.query("INSERT OR IGNORE INTO check_leases (monitor_id, owner, leased_until, acquired_at) VALUES (?, ?, ?, ?)").run(monitorId, owner, leasedUntil, nowIso);
252
+ const row = this.db.query("SELECT * FROM check_leases WHERE monitor_id = ?").get(monitorId);
253
+ return row?.owner === owner;
254
+ });
255
+ return tx();
256
+ }
257
+ releaseCheckLease(monitorId, owner) {
258
+ this.db.query("DELETE FROM check_leases WHERE monitor_id = ? AND owner = ?").run(monitorId, owner);
259
+ }
222
260
  recordCheckResult(input) {
223
261
  const monitor = this.getMonitor(input.monitorId);
224
262
  if (!monitor)
225
263
  throw new Error(`Monitor not found: ${input.monitorId}`);
264
+ if (input.expectedMonitorRevision !== undefined && monitor.revision !== input.expectedMonitorRevision) {
265
+ throw new StaleCheckResultError(`Monitor changed while check was in progress: ${monitor.name}`);
266
+ }
267
+ if (!monitor.enabled) {
268
+ throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${monitor.name}`);
269
+ }
226
270
  const checkedAt = input.checkedAt ?? new Date().toISOString();
227
271
  const result = {
228
272
  id: newId("chk"),
@@ -235,6 +279,15 @@ class UptimeStore {
235
279
  attemptCount: Math.max(1, input.attemptCount)
236
280
  };
237
281
  const tx = this.db.transaction(() => {
282
+ const current = this.db.query("SELECT * FROM monitors WHERE id = ?").get(result.monitorId);
283
+ if (!current)
284
+ throw new Error(`Monitor not found: ${result.monitorId}`);
285
+ if (input.expectedMonitorRevision !== undefined && current.revision !== input.expectedMonitorRevision) {
286
+ throw new StaleCheckResultError(`Monitor changed while check was in progress: ${current.name}`);
287
+ }
288
+ if (!current.enabled) {
289
+ throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${current.name}`);
290
+ }
238
291
  this.db.query(`INSERT INTO check_results (
239
292
  id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count
240
293
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(result.id, result.monitorId, result.checkedAt, result.status, result.latencyMs, result.statusCode, result.error, result.attemptCount);
@@ -282,10 +335,14 @@ class UptimeStore {
282
335
  down: monitors.filter((m) => m.status === "down").length,
283
336
  paused: monitors.filter((m) => !m.enabled || m.status === "paused").length,
284
337
  unknown: monitors.filter((m) => m.status === "unknown").length,
285
- openIncidents: this.listIncidents({ status: "open", limit: 1000 }).length
338
+ openIncidents: this.countOpenIncidents()
286
339
  }
287
340
  };
288
341
  }
342
+ countOpenIncidents() {
343
+ const row = this.db.query("SELECT COUNT(*) AS count FROM incidents WHERE status = 'open'").get();
344
+ return Number(row?.count ?? 0);
345
+ }
289
346
  monitorSummary(monitor) {
290
347
  const row = this.db.query(`SELECT
291
348
  COUNT(*) as total,
@@ -323,13 +380,24 @@ class UptimeStore {
323
380
  this.db.query("UPDATE incidents SET status = 'closed', closed_at = ?, recovery_check_id = ? WHERE id = ?").run(result.checkedAt, result.id, open.id);
324
381
  }
325
382
  }
383
+ closeOpenIncident(monitorId, closedAt) {
384
+ this.db.query("UPDATE incidents SET status = 'closed', closed_at = ? WHERE monitor_id = ? AND status = 'open'").run(closedAt, monitorId);
385
+ }
386
+ ensureColumn(table, name, definition) {
387
+ const columns = this.db.query(`PRAGMA table_info(${table})`).all();
388
+ if (!columns.some((column) => column.name === name)) {
389
+ this.db.run(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
390
+ }
391
+ }
326
392
  }
327
393
  function normalizeCreateMonitor(input) {
328
394
  const name = input.name?.trim();
329
395
  if (!name)
330
396
  throw new Error("Monitor name is required");
397
+ rejectControlCharacters(name, "Monitor name");
331
398
  const method = normalizeMethod(input.method ?? "GET");
332
399
  const expectedStatus = normalizeExpectedStatus(input.expectedStatus);
400
+ const enabled = normalizeEnabled(input.enabled);
333
401
  if (input.kind === "http") {
334
402
  const url = normalizeHttpUrl(input.url);
335
403
  return {
@@ -341,12 +409,13 @@ function normalizeCreateMonitor(input) {
341
409
  intervalSeconds: boundedInteger(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
342
410
  timeoutMs: boundedInteger(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
343
411
  retryCount: boundedInteger(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
344
- enabled: input.enabled ?? true
412
+ enabled
345
413
  };
346
414
  } else if (input.kind === "tcp") {
347
415
  const host = input.host?.trim();
348
416
  if (!host)
349
417
  throw new Error("TCP monitors require host");
418
+ rejectControlCharacters(host, "TCP host");
350
419
  if (!Number.isInteger(input.port) || input.port <= 0 || input.port > 65535) {
351
420
  throw new Error("TCP monitors require a port from 1 to 65535");
352
421
  }
@@ -360,12 +429,15 @@ function normalizeCreateMonitor(input) {
360
429
  intervalSeconds: boundedInteger(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
361
430
  timeoutMs: boundedInteger(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
362
431
  retryCount: boundedInteger(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
363
- enabled: input.enabled ?? true
432
+ enabled
364
433
  };
365
434
  } else {
366
435
  throw new Error("Monitor kind must be http or tcp");
367
436
  }
368
437
  }
438
+ function definitionChanged(current, next) {
439
+ 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;
440
+ }
369
441
  function normalizeUpdateMonitor(current, input, updatedAt) {
370
442
  const merged = {
371
443
  ...current,
@@ -430,6 +502,18 @@ function normalizeExpectedStatus(value) {
430
502
  }
431
503
  return value;
432
504
  }
505
+ function normalizeEnabled(value) {
506
+ if (value === undefined)
507
+ return true;
508
+ if (typeof value !== "boolean")
509
+ throw new Error("enabled must be a boolean");
510
+ return value;
511
+ }
512
+ function rejectControlCharacters(value, label) {
513
+ if (/[\x00-\x1f\x7f-\x9f]/.test(value)) {
514
+ throw new Error(`${label} must not contain control characters`);
515
+ }
516
+ }
433
517
  function monitorFromRow(row) {
434
518
  return {
435
519
  id: row.id,
@@ -446,6 +530,7 @@ function monitorFromRow(row) {
446
530
  enabled: Boolean(row.enabled),
447
531
  status: row.status,
448
532
  lastCheckedAt: row.last_checked_at,
533
+ revision: row.revision ?? 1,
449
534
  createdAt: row.created_at,
450
535
  updatedAt: row.updated_at
451
536
  };
@@ -494,10 +579,282 @@ function round(value, places) {
494
579
  return Math.round(value * factor) / factor;
495
580
  }
496
581
 
582
+ // src/report.ts
583
+ var DEFAULT_MAILERY_API_URL = "http://localhost:3900";
584
+ var DEFAULT_TELEPHONY_API_URL = "http://localhost:19451";
585
+ var DEFAULT_LOGS_API_URL = "http://localhost:3460";
586
+ var DEFAULT_TIMEOUT_MS = 15000;
587
+ function buildUptimeReport(summary, options = {}) {
588
+ const subject = options.subject ?? defaultSubject(summary);
589
+ const lines = [
590
+ subject,
591
+ `Generated: ${summary.generatedAt}`,
592
+ `Monitors: ${summary.totals.monitors} total, ${summary.totals.enabled} enabled, ${summary.totals.up} up, ${summary.totals.down} down, ${summary.totals.openIncidents} open incidents`,
593
+ "",
594
+ ...summary.monitors.map(renderMonitorLine)
595
+ ];
596
+ const text = lines.join(`
597
+ `).trimEnd();
598
+ const json = {
599
+ kind: "open-uptime.report",
600
+ generated_at: summary.generatedAt,
601
+ subject,
602
+ totals: summary.totals,
603
+ monitors: summary.monitors
604
+ };
605
+ return {
606
+ subject,
607
+ generatedAt: summary.generatedAt,
608
+ summary,
609
+ text,
610
+ html: `<pre>${escapeHtml(text)}</pre>`,
611
+ json
612
+ };
613
+ }
614
+ async function sendUptimeReport(summary, options = {}) {
615
+ const report = buildUptimeReport(summary, options);
616
+ const fetchImpl = options.fetchImpl ?? fetch;
617
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
618
+ const deliveries = [];
619
+ if (options.email) {
620
+ deliveries.push(await sendEmailReport(report, resolveEmailTarget(options.email), fetchImpl, timeoutMs));
621
+ }
622
+ if (options.sms) {
623
+ const smsTarget = resolveSmsTarget(options.sms);
624
+ const recipients = splitTargets(smsTarget.to);
625
+ if (recipients.length === 0) {
626
+ deliveries.push(await sendSmsReport(report, smsTarget, fetchImpl, timeoutMs));
627
+ } else {
628
+ for (const target of recipients) {
629
+ deliveries.push(await sendSmsReport(report, { ...smsTarget, to: target }, fetchImpl, timeoutMs));
630
+ }
631
+ }
632
+ }
633
+ if (options.logs) {
634
+ deliveries.push(await sendLogsReport(report, resolveLogsTarget(options.logs), fetchImpl, timeoutMs));
635
+ }
636
+ return deliveries;
637
+ }
638
+ function defaultSubject(summary) {
639
+ if (summary.totals.openIncidents > 0 || summary.totals.down > 0) {
640
+ return `Open Uptime alert: ${summary.totals.down} down, ${summary.totals.openIncidents} open incidents`;
641
+ }
642
+ return `Open Uptime report: ${summary.totals.up}/${summary.totals.enabled} enabled monitors up`;
643
+ }
644
+ function renderMonitorLine(item) {
645
+ const uptime = item.uptimePercent == null ? "-" : `${item.uptimePercent.toFixed(2)}%`;
646
+ const latency = item.averageLatencyMs == null ? "-" : `${item.averageLatencyMs}ms`;
647
+ const incident = item.openIncident ? ` open incident: ${item.openIncident.reason ?? "down"}` : "";
648
+ return `- ${item.monitor.status.toUpperCase()} ${item.monitor.name} (${targetLabel(item)}): uptime ${uptime}, latency ${latency}${incident}`;
649
+ }
650
+ function targetLabel(item) {
651
+ return item.monitor.kind === "http" ? item.monitor.url ?? "" : `${item.monitor.host}:${item.monitor.port}`;
652
+ }
653
+ function resolveEmailTarget(value) {
654
+ const target = typeof value === "boolean" ? {} : value;
655
+ return {
656
+ apiUrl: target.apiUrl ?? env("HASNA_MAILERY_API_URL", "MAILERY_API_URL") ?? DEFAULT_MAILERY_API_URL,
657
+ sendKey: target.sendKey ?? env("HASNA_MAILERY_SEND_KEY", "MAILERY_SEND_KEY", "ESK"),
658
+ from: target.from ?? env("HASNA_UPTIME_REPORT_EMAIL_FROM", "UPTIME_REPORT_EMAIL_FROM"),
659
+ to: target.to ?? env("HASNA_UPTIME_REPORT_EMAIL_TO", "UPTIME_REPORT_EMAIL_TO"),
660
+ subject: target.subject,
661
+ providerId: target.providerId ?? env("HASNA_MAILERY_PROVIDER_ID", "MAILERY_PROVIDER_ID")
662
+ };
663
+ }
664
+ function resolveSmsTarget(value) {
665
+ const target = typeof value === "boolean" ? {} : value;
666
+ return {
667
+ apiUrl: target.apiUrl ?? env("HASNA_TELEPHONY_API_URL", "TELEPHONY_API_URL") ?? DEFAULT_TELEPHONY_API_URL,
668
+ from: target.from ?? env("HASNA_UPTIME_REPORT_SMS_FROM", "UPTIME_REPORT_SMS_FROM"),
669
+ to: target.to ?? env("HASNA_UPTIME_REPORT_PHONE_TO", "UPTIME_REPORT_PHONE_TO")
670
+ };
671
+ }
672
+ function resolveLogsTarget(value) {
673
+ const target = typeof value === "boolean" ? {} : value;
674
+ return {
675
+ apiUrl: target.apiUrl ?? env("HASNA_LOGS_URL", "LOGS_URL") ?? DEFAULT_LOGS_API_URL,
676
+ apiKey: target.apiKey ?? env("HASNA_LOGS_API_TOKEN", "LOGS_API_TOKEN", "HASNA_LOGS_API_KEY", "LOGS_API_KEY"),
677
+ projectId: target.projectId ?? env("HASNA_LOGS_PROJECT_ID", "LOGS_PROJECT_ID") ?? "open-uptime",
678
+ environment: target.environment ?? env("HASNA_ENV", "NODE_ENV"),
679
+ service: target.service ?? "open-uptime"
680
+ };
681
+ }
682
+ async function sendEmailReport(report, target, fetchImpl, timeoutMs) {
683
+ if (!target.sendKey)
684
+ return { channel: "email", ok: false, error: "Mailery send key is required" };
685
+ if (!target.from)
686
+ return { channel: "email", ok: false, error: "Email from address is required" };
687
+ if (!hasTargets(target.to))
688
+ return { channel: "email", ok: false, error: "Email recipient is required" };
689
+ const body = {
690
+ from: target.from,
691
+ to: splitTargets(target.to),
692
+ subject: target.subject ?? report.subject,
693
+ text: report.text,
694
+ html: report.html,
695
+ provider_id: target.providerId
696
+ };
697
+ return requestJson("email", `${normalizeUrl(target.apiUrl ?? DEFAULT_MAILERY_API_URL)}/api/v1/send`, {
698
+ method: "POST",
699
+ headers: { authorization: `Bearer ${target.sendKey}` },
700
+ body
701
+ }, fetchImpl, timeoutMs, secretsForTarget(target));
702
+ }
703
+ async function sendSmsReport(report, target, fetchImpl, timeoutMs) {
704
+ if (!hasTargets(target.to))
705
+ return { channel: "sms", ok: false, error: "SMS recipient phone number is required" };
706
+ return requestJson("sms", `${normalizeUrl(target.apiUrl ?? DEFAULT_TELEPHONY_API_URL)}/api/sms/send`, {
707
+ method: "POST",
708
+ body: {
709
+ to: Array.isArray(target.to) ? target.to[0] : target.to,
710
+ from: target.from,
711
+ body: truncateSms(report.text)
712
+ }
713
+ }, fetchImpl, timeoutMs, secretsForTarget(target));
714
+ }
715
+ async function sendLogsReport(report, target, fetchImpl, timeoutMs) {
716
+ const params = new URLSearchParams({
717
+ format: "json",
718
+ source: "structured",
719
+ service: target.service ?? "open-uptime",
720
+ project_id: target.projectId ?? "open-uptime"
721
+ });
722
+ if (target.environment)
723
+ params.set("environment", target.environment);
724
+ return requestJson("logs", `${normalizeUrl(target.apiUrl ?? DEFAULT_LOGS_API_URL)}/api/logs/structured?${params}`, {
725
+ method: "POST",
726
+ headers: target.apiKey ? { authorization: `Bearer ${target.apiKey}` } : undefined,
727
+ body: {
728
+ timestamp: report.generatedAt,
729
+ level: report.summary.totals.down > 0 || report.summary.totals.openIncidents > 0 ? "warn" : "info",
730
+ message: report.subject,
731
+ report: report.json
732
+ }
733
+ }, fetchImpl, timeoutMs, secretsForTarget(target));
734
+ }
735
+ async function requestJson(channel, url, options, fetchImpl, timeoutMs, secrets = []) {
736
+ const controller = new AbortController;
737
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
738
+ try {
739
+ const response = await fetchImpl(url, {
740
+ method: options.method,
741
+ signal: controller.signal,
742
+ headers: {
743
+ "content-type": "application/json",
744
+ accept: "application/json",
745
+ ...options.headers
746
+ },
747
+ body: JSON.stringify(options.body)
748
+ });
749
+ const text = await response.text();
750
+ const data = parseMaybeJson(text);
751
+ if (!response.ok) {
752
+ return { channel, ok: false, status: response.status, error: errorFromResponse(data, response.statusText, secrets) };
753
+ }
754
+ return { channel, ok: true, status: response.status, id: redactOptional(idFromResponse(data), secrets) };
755
+ } catch (error) {
756
+ const message = error instanceof Error && error.name === "AbortError" ? "request timed out" : error instanceof Error ? error.message : String(error);
757
+ return { channel, ok: false, error: redactSecrets(message, secrets) };
758
+ } finally {
759
+ clearTimeout(timer);
760
+ }
761
+ }
762
+ function hasTargets(value) {
763
+ return splitTargets(value).length > 0;
764
+ }
765
+ function splitTargets(value) {
766
+ if (!value)
767
+ return [];
768
+ const values = Array.isArray(value) ? value : value.split(",");
769
+ return values.map((item) => item.trim()).filter(Boolean);
770
+ }
771
+ function normalizeUrl(value) {
772
+ const parsed = new URL(value.trim());
773
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
774
+ throw new Error("Integration API URL must use http or https");
775
+ }
776
+ return parsed.toString().replace(/\/$/, "");
777
+ }
778
+ function truncateSms(value) {
779
+ return value.length > 1400 ? `${value.slice(0, 1397)}...` : value;
780
+ }
781
+ function parseMaybeJson(text) {
782
+ if (!text.trim())
783
+ return {};
784
+ try {
785
+ return JSON.parse(text);
786
+ } catch {
787
+ return { message: text };
788
+ }
789
+ }
790
+ function idFromResponse(data) {
791
+ if (!data || typeof data !== "object")
792
+ return;
793
+ const record = data;
794
+ for (const key of ["id", "message_id", "event_id"]) {
795
+ if (typeof record[key] === "string")
796
+ return record[key];
797
+ }
798
+ return;
799
+ }
800
+ function errorFromResponse(data, fallback, secrets = []) {
801
+ if (data && typeof data === "object") {
802
+ const record = data;
803
+ if (typeof record.error === "string")
804
+ return redactSecrets(record.error, secrets);
805
+ if (typeof record.message === "string")
806
+ return redactSecrets(record.message, secrets);
807
+ }
808
+ return redactSecrets(fallback, secrets);
809
+ }
810
+ function env(...keys) {
811
+ for (const key of keys) {
812
+ const value = process.env[key]?.trim();
813
+ if (value)
814
+ return value;
815
+ }
816
+ return;
817
+ }
818
+ function escapeHtml(value) {
819
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
820
+ }
821
+ function secretsForTarget(target) {
822
+ const values = new Set;
823
+ for (const key of ["sendKey", "apiKey"]) {
824
+ const value = target[key];
825
+ if (typeof value === "string" && value.trim())
826
+ values.add(value.trim());
827
+ }
828
+ const apiUrl = target.apiUrl;
829
+ if (apiUrl) {
830
+ try {
831
+ const parsed = new URL(apiUrl);
832
+ if (parsed.username)
833
+ values.add(decodeURIComponent(parsed.username));
834
+ if (parsed.password)
835
+ values.add(decodeURIComponent(parsed.password));
836
+ } catch {}
837
+ }
838
+ return [...values];
839
+ }
840
+ function redactSecrets(value, secrets = []) {
841
+ let redacted = value;
842
+ for (const secret of secrets) {
843
+ if (secret.length >= 3)
844
+ redacted = redacted.split(secret).join("[REDACTED]");
845
+ }
846
+ return redacted.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]").replace(/\besk_[A-Za-z0-9._~+/=-]+/g, "esk_[REDACTED]");
847
+ }
848
+ function redactOptional(value, secrets) {
849
+ return value === undefined ? undefined : redactSecrets(value, secrets);
850
+ }
851
+
497
852
  // src/service.ts
853
+ import { randomUUID as randomUUID2 } from "crypto";
498
854
  class UptimeService {
499
855
  store;
500
856
  checkRunner;
857
+ leaseOwner = `svc_${randomUUID2().replace(/-/g, "").slice(0, 18)}`;
501
858
  inFlightChecks = new Set;
502
859
  constructor(options = {}) {
503
860
  this.store = options.store ?? new UptimeStore(options);
@@ -530,6 +887,12 @@ class UptimeService {
530
887
  summary() {
531
888
  return this.store.summary();
532
889
  }
890
+ buildReport(options = {}) {
891
+ return buildUptimeReport(this.summary(), options);
892
+ }
893
+ async sendReport(options = {}) {
894
+ return sendUptimeReport(this.summary(), options);
895
+ }
533
896
  async checkMonitor(idOrName) {
534
897
  const monitor = this.store.getMonitor(idOrName);
535
898
  if (!monitor)
@@ -538,6 +901,10 @@ class UptimeService {
538
901
  throw new Error(`Monitor is disabled: ${monitor.name}`);
539
902
  if (this.inFlightChecks.has(monitor.id))
540
903
  throw new Error(`Monitor check already in progress: ${monitor.name}`);
904
+ const leaseTtlMs = Math.max(60000, (monitor.retryCount + 1) * monitor.timeoutMs + 1e4);
905
+ if (!this.store.acquireCheckLease(monitor.id, this.leaseOwner, leaseTtlMs)) {
906
+ throw new MonitorCheckBusyError(`Monitor check already in progress: ${monitor.name}`);
907
+ }
541
908
  this.inFlightChecks.add(monitor.id);
542
909
  try {
543
910
  let attemptCount = 0;
@@ -555,10 +922,12 @@ class UptimeService {
555
922
  latencyMs: last.latencyMs,
556
923
  statusCode: last.statusCode ?? null,
557
924
  error: last.error ?? null,
558
- attemptCount
925
+ attemptCount,
926
+ expectedMonitorRevision: monitor.revision
559
927
  });
560
928
  } finally {
561
929
  this.inFlightChecks.delete(monitor.id);
930
+ this.store.releaseCheckLease(monitor.id, this.leaseOwner);
562
931
  }
563
932
  }
564
933
  async checkAll() {
@@ -587,7 +956,13 @@ class UptimeService {
587
956
  const current = this.store.getMonitor(monitor.id);
588
957
  if (!current || !this.isDue(current, now))
589
958
  continue;
590
- results.push(await this.checkMonitor(current.id));
959
+ try {
960
+ results.push(await this.checkMonitor(current.id));
961
+ } catch (error) {
962
+ if (error instanceof MonitorCheckBusyError || error instanceof StaleCheckResultError)
963
+ continue;
964
+ throw error;
965
+ }
591
966
  }
592
967
  return results;
593
968
  }
@@ -606,6 +981,13 @@ function createUptimeClient(options = {}) {
606
981
  return new UptimeService(options);
607
982
  }
608
983
 
984
+ class MonitorCheckBusyError extends Error {
985
+ constructor(message) {
986
+ super(message);
987
+ this.name = "MonitorCheckBusyError";
988
+ }
989
+ }
990
+
609
991
  // src/dashboard.ts
610
992
  function dashboardHtml() {
611
993
  return `<!doctype html>
@@ -957,11 +1339,11 @@ function dashboardHtml() {
957
1339
  }
958
1340
 
959
1341
  // src/api.ts
960
- function createApiHandler(service) {
1342
+ function createApiHandler(service, options = {}) {
961
1343
  return async (request) => {
962
1344
  const url = new URL(request.url);
963
1345
  try {
964
- validateLocalMutationRequest(request, url);
1346
+ validateLocalMutationRequest(request, url, options);
965
1347
  if (request.method === "GET" && url.pathname === "/") {
966
1348
  return html(dashboardHtml());
967
1349
  }
@@ -971,6 +1353,13 @@ function createApiHandler(service) {
971
1353
  if (request.method === "GET" && url.pathname === "/api/summary") {
972
1354
  return json(service.summary());
973
1355
  }
1356
+ if (request.method === "GET" && url.pathname === "/api/report") {
1357
+ return json(service.buildReport());
1358
+ }
1359
+ if (request.method === "POST" && url.pathname === "/api/report") {
1360
+ const input = await jsonBody(request);
1361
+ return json(await service.sendReport({ ...input, fetchImpl: options.fetchImpl }));
1362
+ }
974
1363
  if (request.method === "GET" && url.pathname === "/api/monitors") {
975
1364
  return json(service.listMonitors({ includeDisabled: url.searchParams.get("includeDisabled") === "true" }));
976
1365
  }
@@ -1023,7 +1412,11 @@ function serveUptime(options = {}) {
1023
1412
  const server = Bun.serve({
1024
1413
  hostname: options.host ?? "127.0.0.1",
1025
1414
  port: options.port ?? 3899,
1026
- fetch: createApiHandler(service)
1415
+ fetch: createApiHandler(service, {
1416
+ apiToken: options.apiToken,
1417
+ allowUnsafeRemoteMutations: options.allowUnsafeRemoteMutations,
1418
+ trustedLoopback: isLoopbackHost(options.host ?? "127.0.0.1")
1419
+ })
1027
1420
  });
1028
1421
  return { server, service, scheduler };
1029
1422
  }
@@ -1051,14 +1444,31 @@ function numericParam(url, name, fallback) {
1051
1444
  const parsed = Number(raw);
1052
1445
  return Number.isFinite(parsed) ? parsed : fallback;
1053
1446
  }
1054
- function validateLocalMutationRequest(request, url) {
1447
+ function validateLocalMutationRequest(request, url, options) {
1055
1448
  if (!["POST", "PATCH", "DELETE"].includes(request.method))
1056
1449
  return;
1450
+ const apiToken = options.apiToken ?? process.env.HASNA_UPTIME_API_TOKEN;
1451
+ const hasToken = apiToken ? hasValidApiToken(request, apiToken) : false;
1452
+ const allowUnsafeRemote = options.allowUnsafeRemoteMutations || process.env.HASNA_UPTIME_ALLOW_REMOTE_MUTATIONS === "1";
1453
+ const trustedLoopback = options.trustedLoopback ?? isLoopbackHost(url.hostname);
1454
+ if (!allowUnsafeRemote && !hasToken && (!trustedLoopback || !isLoopbackHost(url.hostname))) {
1455
+ throw new ApiError("non-loopback host rejected for local mutation", 403);
1456
+ }
1057
1457
  const origin = request.headers.get("origin");
1058
1458
  if (origin && origin !== `${url.protocol}//${url.host}`) {
1059
1459
  throw new ApiError("cross-origin mutation rejected", 403);
1060
1460
  }
1061
1461
  }
1462
+ function isLoopbackHost(hostname) {
1463
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
1464
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
1465
+ }
1466
+ function hasValidApiToken(request, token) {
1467
+ const authorization = request.headers.get("authorization") ?? "";
1468
+ const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
1469
+ const headerToken = request.headers.get("x-uptime-token")?.trim();
1470
+ return bearer === token || headerToken === token;
1471
+ }
1062
1472
  async function jsonBody(request) {
1063
1473
  const contentType = request.headers.get("content-type") ?? "";
1064
1474
  const mediaType = contentType.split(";")[0]?.trim().toLowerCase();
@@ -1079,12 +1489,14 @@ export {
1079
1489
  uptimeHome,
1080
1490
  uptimeDbPath,
1081
1491
  serveUptime,
1492
+ sendUptimeReport,
1082
1493
  runTcpCheck,
1083
1494
  runMonitorCheck,
1084
1495
  runHttpCheck,
1085
1496
  ensureUptimeHome,
1086
1497
  createUptimeClient,
1087
1498
  createApiHandler,
1499
+ buildUptimeReport,
1088
1500
  UptimeStore,
1089
1501
  UptimeService
1090
1502
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/mcp/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGpE,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAY9C,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,wBAAgB,eAAe,CAAC,OAAO,GAAE,sBAA2B,GAAG,SAAS,CAkL/E"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/mcp/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGpE,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAY9C,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,wBAAgB,eAAe,CAAC,OAAO,GAAE,sBAA2B,GAAG,SAAS,CAwN/E"}