@hasna/uptime 0.1.2 → 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/service.js CHANGED
@@ -7,7 +7,11 @@ async function runMonitorCheck(monitor, options = {}) {
7
7
  }
8
8
  if (monitor.kind === "http")
9
9
  return runHttpCheck(monitor, options.fetch ?? fetch);
10
- return runTcpCheck(monitor);
10
+ if (monitor.kind === "browser_page")
11
+ return runBrowserPageCheck(monitor, { fetch: options.fetch, runner: options.browserPage });
12
+ if (monitor.kind === "tcp")
13
+ return runTcpCheck(monitor);
14
+ return { status: "down", latencyMs: null, error: `unsupported monitor kind: ${monitor.kind ?? "unknown"}` };
11
15
  }
12
16
  async function runHttpCheck(monitor, fetchImpl = fetch) {
13
17
  if (!monitor.url)
@@ -65,6 +69,736 @@ async function runTcpCheck(monitor) {
65
69
  });
66
70
  });
67
71
  }
72
+ async function runBrowserPageCheck(monitor, options = {}) {
73
+ if (!monitor.url)
74
+ return { status: "down", latencyMs: null, error: "missing url" };
75
+ validateBrowserPageUrl(monitor.url);
76
+ if (!options.runner) {
77
+ const evidence = normalizeBrowserEvidence(monitor.url, {
78
+ finalUrl: monitor.url,
79
+ navigationStatus: null,
80
+ pageErrors: ["browser_page checks require a configured browser runner"]
81
+ });
82
+ return {
83
+ status: "down",
84
+ latencyMs: null,
85
+ statusCode: null,
86
+ error: "browser_page checks require a configured browser runner",
87
+ evidence
88
+ };
89
+ }
90
+ const started = performance.now();
91
+ try {
92
+ const raw = await options.runner(monitor);
93
+ const latencyMs = raw.latencyMs ?? Math.round((performance.now() - started) * 100) / 100;
94
+ const evidence = normalizeBrowserEvidence(monitor.url, raw);
95
+ const statusCode = raw.navigationStatus ?? evidence.navigationStatus;
96
+ const statusOk = statusCode == null ? false : monitor.expectedStatus == null ? statusCode >= 200 && statusCode < 400 : statusCode === monitor.expectedStatus;
97
+ const browserFailures = evidence.consoleErrors.length + evidence.pageErrors.length + evidence.failedRequests.length;
98
+ return {
99
+ status: statusOk && browserFailures === 0 ? "up" : "down",
100
+ latencyMs,
101
+ statusCode,
102
+ error: statusOk ? browserFailures === 0 ? null : `browser page captured ${browserFailures} error signal${browserFailures === 1 ? "" : "s"}` : `unexpected navigation status ${statusCode ?? "unknown"}`,
103
+ evidence
104
+ };
105
+ } catch (error) {
106
+ const safeError = redactText(error instanceof Error ? error.message : String(error));
107
+ const evidence = normalizeBrowserEvidence(monitor.url, {
108
+ finalUrl: monitor.url,
109
+ navigationStatus: null,
110
+ pageErrors: [safeError]
111
+ });
112
+ return {
113
+ status: "down",
114
+ latencyMs: Math.round((performance.now() - started) * 100) / 100,
115
+ statusCode: null,
116
+ error: safeError,
117
+ evidence
118
+ };
119
+ }
120
+ }
121
+ function normalizeBrowserEvidence(sourceUrl, raw) {
122
+ return {
123
+ kind: "browser_page",
124
+ finalUrl: raw.finalUrl ? redactUrl(raw.finalUrl) : redactUrl(sourceUrl),
125
+ navigationStatus: raw.navigationStatus ?? null,
126
+ consoleErrors: sanitizeStrings(raw.consoleErrors ?? []),
127
+ pageErrors: sanitizeStrings(raw.pageErrors ?? []),
128
+ failedRequests: (raw.failedRequests ?? []).slice(0, 50).map((request) => ({
129
+ url: redactUrl(request.url),
130
+ statusCode: request.statusCode ?? null,
131
+ error: request.error ? redactText(request.error) : null
132
+ })),
133
+ screenshot: raw.screenshot ? sanitizeArtifact(raw.screenshot) : null,
134
+ artifacts: (raw.artifacts ?? []).slice(0, 20).map(sanitizeArtifact),
135
+ redacted: true,
136
+ redactionStatus: "redacted",
137
+ retentionClass: "short"
138
+ };
139
+ }
140
+ function validateBrowserPageUrl(value) {
141
+ const parsed = new URL(value);
142
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
143
+ throw new Error("browser_page monitors require an http or https URL");
144
+ }
145
+ if (parsed.username || parsed.password) {
146
+ throw new Error("browser_page URLs must not contain userinfo");
147
+ }
148
+ }
149
+ function sanitizeStrings(values) {
150
+ return values.slice(0, 50).map(redactText).filter(Boolean);
151
+ }
152
+ function sanitizeArtifact(artifact) {
153
+ const ref = artifact.ref.trim();
154
+ if (artifact.path || ref.startsWith("/") || ref.toLowerCase().startsWith("file:")) {
155
+ throw new Error("browser evidence artifacts must use redacted artifact refs, not local paths");
156
+ }
157
+ if (!artifact.sha256 || !/^[a-f0-9]{64}$/i.test(artifact.sha256)) {
158
+ throw new Error("browser evidence artifacts require a sha256 checksum");
159
+ }
160
+ const bytes = artifact.bytes;
161
+ if (!Number.isInteger(bytes) || bytes == null || bytes < 0) {
162
+ throw new Error("browser evidence artifacts require a byte size");
163
+ }
164
+ return {
165
+ ref: redactText(ref),
166
+ sha256: artifact.sha256,
167
+ bytes,
168
+ contentType: redactText(artifact.contentType ?? "application/octet-stream") || "application/octet-stream",
169
+ retentionClass: "short"
170
+ };
171
+ }
172
+ function redactUrl(value) {
173
+ try {
174
+ const parsed = new URL(value);
175
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
176
+ return "[blocked-url]";
177
+ }
178
+ parsed.username = "";
179
+ parsed.password = "";
180
+ parsed.hash = "";
181
+ for (const key of parsed.searchParams.keys()) {
182
+ if (isSecretKey(key))
183
+ parsed.searchParams.set(key, "[redacted]");
184
+ }
185
+ return parsed.toString();
186
+ } catch {
187
+ return redactText(value);
188
+ }
189
+ }
190
+ function redactText(value) {
191
+ 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]");
192
+ }
193
+ function isSecretKey(value) {
194
+ return /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i.test(value);
195
+ }
196
+
197
+ // src/imports.ts
198
+ import { randomUUID } from "crypto";
199
+
200
+ // src/limits.ts
201
+ var MIN_INTERVAL_SECONDS = 1;
202
+ var MAX_INTERVAL_SECONDS = 86400;
203
+ var MIN_TIMEOUT_MS = 1;
204
+ var MAX_TIMEOUT_MS = 60000;
205
+ var MIN_RETRY_COUNT = 0;
206
+ var MAX_RETRY_COUNT = 10;
207
+ var MAX_RESULT_LIMIT = 1000;
208
+
209
+ // src/target-policy.ts
210
+ import net2 from "net";
211
+ var SECRET_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
212
+ function assertHostedTargetAllowed(target) {
213
+ if (target.kind === "http" || target.kind === "browser_page") {
214
+ if (!target.url)
215
+ throw new Error("HTTP monitors require url");
216
+ assertHostedHttpUrlAllowed(target.url);
217
+ return;
218
+ }
219
+ if (target.kind === "tcp") {
220
+ if (!target.host)
221
+ throw new Error("TCP monitors require host");
222
+ assertHostedHostAllowed(target.host, "TCP host");
223
+ if (!Number.isInteger(target.port) || target.port <= 0 || target.port > 65535) {
224
+ throw new Error("TCP monitors require a port from 1 to 65535");
225
+ }
226
+ return;
227
+ }
228
+ throw new Error("Monitor kind must be http, tcp, or browser_page");
229
+ }
230
+ function assertHostedHttpUrlAllowed(value) {
231
+ const parsed = new URL(value);
232
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
233
+ throw new Error("HTTP monitor url must use http or https");
234
+ }
235
+ if (parsed.username || parsed.password) {
236
+ throw new Error("hosted target URLs must not contain userinfo");
237
+ }
238
+ for (const key of parsed.searchParams.keys()) {
239
+ if (SECRET_PARAM_PATTERN.test(key)) {
240
+ throw new Error(`hosted target URL query parameter is not allowed: ${key}`);
241
+ }
242
+ }
243
+ if (parsed.hash && SECRET_PARAM_PATTERN.test(parsed.hash)) {
244
+ throw new Error("hosted target URL fragment contains secret-like data");
245
+ }
246
+ assertHostedHostAllowed(parsed.hostname, "HTTP host");
247
+ }
248
+ function assertHostedHostAllowed(hostname, label = "host") {
249
+ const host = normalizeHost(hostname);
250
+ if (!host)
251
+ throw new Error(`${label} is required`);
252
+ if (host === "localhost" || host.endsWith(".localhost")) {
253
+ throw new Error(`${label} is not allowed in hosted mode: localhost`);
254
+ }
255
+ if (host.endsWith(".local") || host.endsWith(".internal")) {
256
+ throw new Error(`${label} is not allowed in hosted mode: private DNS name`);
257
+ }
258
+ const ipVersion = net2.isIP(host);
259
+ if (ipVersion === 4 && isDeniedIpv4(host)) {
260
+ throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv4`);
261
+ }
262
+ if (ipVersion === 6 && isDeniedIpv6(host)) {
263
+ throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv6`);
264
+ }
265
+ }
266
+ function normalizeHost(hostname) {
267
+ return hostname.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
268
+ }
269
+ function isDeniedIpv4(ip) {
270
+ const parts = ip.split(".").map((part) => Number(part));
271
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
272
+ return true;
273
+ }
274
+ const [a, b] = parts;
275
+ 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;
276
+ }
277
+ function isDeniedIpv6(ip) {
278
+ const normalized = ip.toLowerCase();
279
+ 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.");
280
+ }
281
+
282
+ // src/imports.ts
283
+ function previewImport(store, request) {
284
+ const source = normalizeSource(request.source);
285
+ const items = dedupePreviewItems(request.records.map((record) => previewRecord(store, source, record, request.defaults ?? {})));
286
+ return {
287
+ source,
288
+ generatedAt: new Date().toISOString(),
289
+ dryRun: true,
290
+ items,
291
+ totals: countActions(items)
292
+ };
293
+ }
294
+ function dedupePreviewItems(items) {
295
+ const seenSources = new Set;
296
+ const seenNames = new Set;
297
+ return items.map((item) => {
298
+ if (item.action === "blocked")
299
+ return item;
300
+ const sourceKey = `${item.candidate.source}:${item.candidate.sourceId}`;
301
+ const nameKey = item.candidate.name.toLowerCase();
302
+ if (seenSources.has(sourceKey) || seenNames.has(nameKey)) {
303
+ return {
304
+ ...item,
305
+ action: "conflict",
306
+ monitor: item.monitor,
307
+ warnings: [...item.warnings, "duplicate import candidate in request"],
308
+ reason: "duplicate import candidate in request"
309
+ };
310
+ }
311
+ seenSources.add(sourceKey);
312
+ seenNames.add(nameKey);
313
+ return item;
314
+ });
315
+ }
316
+ function applyImport(store, request) {
317
+ if (store.mode === "hosted") {
318
+ throw new Error("hosted import apply requires cloud import_batches and audit");
319
+ }
320
+ const execute = () => {
321
+ const preview = previewImport(store, request);
322
+ const appliedAt = new Date().toISOString();
323
+ const items = preview.items.map((item) => applyPreviewItem(store, item));
324
+ const batchId = `imp_${randomUUID().replace(/-/g, "").slice(0, 18)}`;
325
+ store.saveImportBatch({
326
+ id: batchId,
327
+ source: preview.source,
328
+ records: items.map((item) => ({
329
+ action: item.action,
330
+ sourceId: item.candidate.sourceId,
331
+ monitorId: item.after?.id ?? item.monitor?.id ?? item.before?.id ?? null,
332
+ before: item.before,
333
+ after: item.after,
334
+ candidate: item.candidate
335
+ }))
336
+ });
337
+ return { batchId, source: preview.source, appliedAt, items, totals: countActions(items) };
338
+ };
339
+ return store.runInTransaction ? store.runInTransaction(execute) : execute();
340
+ }
341
+ function rollbackImport(store, batchId) {
342
+ if (store.mode === "hosted") {
343
+ throw new Error("hosted import rollback requires cloud import_batches and audit");
344
+ }
345
+ const batch = store.getImportBatch(batchId);
346
+ if (!batch)
347
+ throw new Error(`Import batch not found: ${batchId}`);
348
+ if (batch.status === "rolled_back")
349
+ throw new Error(`Import batch already rolled back: ${batchId}`);
350
+ const items = [...batch.records].reverse().map((record) => rollbackRecord(store, record));
351
+ const rolledBack = store.markImportBatchRolledBack(batchId);
352
+ return {
353
+ batchId,
354
+ source: rolledBack.source,
355
+ rolledBackAt: rolledBack.rolledBackAt ?? new Date().toISOString(),
356
+ items
357
+ };
358
+ }
359
+ function previewRecord(store, source, record, defaults) {
360
+ const warnings = [];
361
+ let candidate;
362
+ try {
363
+ if (store.mode === "hosted")
364
+ assertHostedTargetAllowed(rawTargetForHostedPolicy(source, record, defaults));
365
+ candidate = normalizeCandidate(source, record, defaults);
366
+ validateCandidate(candidate);
367
+ if (store.mode === "hosted")
368
+ assertHostedTargetAllowed(candidate);
369
+ } catch (error) {
370
+ return {
371
+ candidate: fallbackCandidate(source, record),
372
+ action: "blocked",
373
+ monitor: null,
374
+ provenance: null,
375
+ warnings,
376
+ reason: error instanceof Error ? error.message : String(error)
377
+ };
378
+ }
379
+ const provenance = store.getProvenance(candidate.source, candidate.sourceId);
380
+ const monitor = provenance ? store.getMonitor(provenance.monitorId) : store.getMonitor(candidate.name);
381
+ if (provenance && !monitor) {
382
+ return { candidate, action: "create", monitor: null, provenance, warnings: ["source provenance points to a missing monitor"], reason: null };
383
+ }
384
+ if (provenance && monitor) {
385
+ const nameOwner = store.getMonitor(candidate.name);
386
+ if (nameOwner && nameOwner.id !== monitor.id) {
387
+ return {
388
+ candidate,
389
+ action: "conflict",
390
+ monitor,
391
+ provenance,
392
+ warnings,
393
+ reason: "monitor name already exists on another monitor"
394
+ };
395
+ }
396
+ return {
397
+ candidate,
398
+ action: sameTarget(monitor, candidate) ? "unchanged" : "update",
399
+ monitor,
400
+ provenance,
401
+ warnings,
402
+ reason: null
403
+ };
404
+ }
405
+ if (monitor) {
406
+ return {
407
+ candidate,
408
+ action: "conflict",
409
+ monitor,
410
+ provenance: null,
411
+ warnings,
412
+ reason: "monitor name already exists without matching source provenance"
413
+ };
414
+ }
415
+ return { candidate, action: "create", monitor: null, provenance: null, warnings, reason: null };
416
+ }
417
+ function applyPreviewItem(store, item) {
418
+ if (item.action === "blocked" || item.action === "conflict") {
419
+ return { ...item, before: item.monitor, after: item.monitor };
420
+ }
421
+ const input = candidateToMonitorInput(item.candidate);
422
+ const before = item.monitor;
423
+ const after = item.action === "create" ? store.createMonitor(input, { allowBrowserPage: true }) : item.action === "update" ? store.updateMonitor(item.monitor.id, input, { allowBrowserPage: true }) : item.monitor;
424
+ if (after) {
425
+ store.upsertMonitorProvenance({
426
+ monitorId: after.id,
427
+ source: item.candidate.source,
428
+ sourceId: item.candidate.sourceId,
429
+ sourceLabel: item.candidate.sourceLabel,
430
+ snapshot: item.candidate.snapshot
431
+ });
432
+ }
433
+ return { ...item, before, after };
434
+ }
435
+ function rollbackRecord(store, record) {
436
+ const value = asRecord(record);
437
+ const action = stringValue(value.action);
438
+ const monitorId = stringValue(value.monitorId);
439
+ const before = isMonitor(value.before) ? value.before : null;
440
+ const after = isMonitor(value.after) ? value.after : null;
441
+ const targetId = after?.id ?? before?.id ?? monitorId;
442
+ if (!targetId)
443
+ return { monitorId: null, action: "skipped", reason: "batch record has no monitor id" };
444
+ if (action === "create") {
445
+ const hasHistory = store.listResults({ monitorId: targetId, limit: 1 }).length > 0;
446
+ if (hasHistory) {
447
+ store.updateMonitor(targetId, { enabled: false }, { allowBrowserPage: true });
448
+ return { monitorId: targetId, action: "disabled", reason: "created monitor has check history, so rollback preserved history and disabled it" };
449
+ }
450
+ return { monitorId: targetId, action: store.deleteMonitor(targetId) ? "deleted" : "skipped", reason: null };
451
+ }
452
+ if (action === "update" && before) {
453
+ store.updateMonitor(targetId, monitorToUpdateInput(before), { allowBrowserPage: true });
454
+ return { monitorId: targetId, action: "restored", reason: null };
455
+ }
456
+ return { monitorId: targetId, action: "skipped", reason: `no rollback needed for ${action || "unknown"} action` };
457
+ }
458
+ function normalizeCandidate(source, record, defaults) {
459
+ const value = asRecord(record);
460
+ const monitor = asRecord(value.monitor);
461
+ const sourceId = sanitizeIdentity(stringValue(value.sourceId) ?? stringValue(value.id) ?? stringValue(value.slug) ?? stringValue(value.name));
462
+ let url = stringValue(monitor.url) ?? stringValue(value.url) ?? stringValue(value.healthUrl) ?? stringValue(value.homepageUrl) ?? stringValue(value.environmentUrl);
463
+ if (source === "domains" && !url && stringValue(value.domain)) {
464
+ url = `https://${stringValue(value.domain)}`;
465
+ }
466
+ const rawHost = stringValue(monitor.host) ?? stringValue(value.host) ?? stringValue(value.hostname);
467
+ const rawKind = stringValue(monitor.kind) ?? stringValue(value.kind) ?? (url ? "http" : "tcp");
468
+ const kind = normalizeKind(rawKind);
469
+ const normalizedUrl = normalizeCandidateUrl(url ?? defaults.url);
470
+ const normalizedHost = kind === "tcp" ? rawHost ?? defaults.host : undefined;
471
+ const port = numberValue(monitor.port) ?? numberValue(value.port) ?? defaults.port;
472
+ const normalizedTargetKey = sanitizeGeneratedTargetKey(kind, normalizedUrl, normalizedHost, port);
473
+ const normalizedSourceId = sourceId ?? `${source}:${normalizedTargetKey}`;
474
+ 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}`;
475
+ const expectedStatus = firstDefined(nullableNumberValue(monitor.expectedStatus), nullableNumberValue(value.expectedStatus), defaults.expectedStatus);
476
+ const candidate = {
477
+ source,
478
+ sourceId: normalizedSourceId,
479
+ sourceLabel: sanitizeIdentity(stringValue(value.label) ?? stringValue(value.name) ?? stringValue(value.slug)) ?? null,
480
+ name: sanitizeIdentity(name) ?? name,
481
+ kind,
482
+ url: normalizedUrl,
483
+ host: normalizedHost,
484
+ port,
485
+ method: normalizeCandidateMethod(stringValue(monitor.method) ?? stringValue(value.method) ?? defaults.method),
486
+ expectedStatus,
487
+ intervalSeconds: numberValue(monitor.intervalSeconds) ?? numberValue(value.intervalSeconds) ?? defaults.intervalSeconds,
488
+ timeoutMs: numberValue(monitor.timeoutMs) ?? numberValue(value.timeoutMs) ?? defaults.timeoutMs,
489
+ retryCount: numberValue(monitor.retryCount) ?? numberValue(value.retryCount) ?? defaults.retryCount,
490
+ enabled: booleanValue(monitor.enabled) ?? booleanValue(value.enabled) ?? defaults.enabled,
491
+ snapshot: sanitizeSnapshot(record)
492
+ };
493
+ return candidate;
494
+ }
495
+ function rawTargetForHostedPolicy(source, record, defaults) {
496
+ const value = asRecord(record);
497
+ const monitor = asRecord(value.monitor);
498
+ let url = stringValue(monitor.url) ?? stringValue(value.url) ?? stringValue(value.healthUrl) ?? stringValue(value.homepageUrl) ?? stringValue(value.environmentUrl);
499
+ if (source === "domains" && !url && stringValue(value.domain)) {
500
+ url = `https://${stringValue(value.domain)}`;
501
+ }
502
+ const host = stringValue(monitor.host) ?? stringValue(value.host) ?? stringValue(value.hostname) ?? defaults.host;
503
+ const rawKind = stringValue(monitor.kind) ?? stringValue(value.kind) ?? (url ? "http" : "tcp");
504
+ const kind = normalizeKind(rawKind);
505
+ return {
506
+ kind,
507
+ url: url ?? defaults.url,
508
+ host: kind === "tcp" ? host : undefined,
509
+ port: numberValue(monitor.port) ?? numberValue(value.port) ?? defaults.port
510
+ };
511
+ }
512
+ function validateCandidate(candidate) {
513
+ if (!candidate.name.trim())
514
+ throw new Error("import candidate requires name");
515
+ rejectControlCharacters(candidate.name.trim(), "Monitor name");
516
+ if (candidate.method !== undefined && !/^[A-Z]+$/.test(candidate.method)) {
517
+ throw new Error("HTTP method must contain only letters");
518
+ }
519
+ if (candidate.expectedStatus !== undefined && candidate.expectedStatus !== null) {
520
+ if (!Number.isInteger(candidate.expectedStatus) || candidate.expectedStatus < 100 || candidate.expectedStatus > 599) {
521
+ throw new Error("expectedStatus must be an HTTP status from 100 to 599");
522
+ }
523
+ }
524
+ if (candidate.intervalSeconds !== undefined) {
525
+ boundedInteger(candidate.intervalSeconds, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS);
526
+ }
527
+ if (candidate.timeoutMs !== undefined) {
528
+ boundedInteger(candidate.timeoutMs, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS);
529
+ }
530
+ if (candidate.retryCount !== undefined) {
531
+ boundedInteger(candidate.retryCount, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT);
532
+ }
533
+ if (candidate.kind === "http" || candidate.kind === "browser_page") {
534
+ if (!candidate.url)
535
+ throw new Error(`${candidate.kind} import candidate requires url`);
536
+ const parsed = new URL(candidate.url);
537
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
538
+ throw new Error(`${candidate.kind} import candidate URL must use http or https`);
539
+ }
540
+ if (parsed.username || parsed.password)
541
+ throw new Error(`${candidate.kind} import candidate URL must not contain userinfo`);
542
+ return;
543
+ }
544
+ if (candidate.kind === "tcp") {
545
+ if (!candidate.host)
546
+ throw new Error("tcp import candidate requires host");
547
+ rejectControlCharacters(candidate.host, "TCP host");
548
+ if (!Number.isInteger(candidate.port) || candidate.port <= 0 || candidate.port > 65535) {
549
+ throw new Error("tcp import candidate requires a port from 1 to 65535");
550
+ }
551
+ return;
552
+ }
553
+ throw new Error(`unsupported import candidate kind: ${candidate.kind}`);
554
+ }
555
+ function candidateToMonitorInput(candidate) {
556
+ return {
557
+ name: candidate.name,
558
+ kind: candidate.kind,
559
+ url: candidate.url,
560
+ host: candidate.host,
561
+ port: candidate.port,
562
+ method: candidate.method,
563
+ expectedStatus: candidate.expectedStatus,
564
+ intervalSeconds: candidate.intervalSeconds,
565
+ timeoutMs: candidate.timeoutMs,
566
+ retryCount: candidate.retryCount,
567
+ enabled: candidate.enabled
568
+ };
569
+ }
570
+ function monitorToUpdateInput(monitor) {
571
+ return {
572
+ name: monitor.name,
573
+ kind: monitor.kind,
574
+ url: monitor.url ?? undefined,
575
+ host: monitor.host ?? undefined,
576
+ port: monitor.port ?? undefined,
577
+ method: monitor.method,
578
+ expectedStatus: monitor.expectedStatus,
579
+ intervalSeconds: monitor.intervalSeconds,
580
+ timeoutMs: monitor.timeoutMs,
581
+ retryCount: monitor.retryCount,
582
+ enabled: monitor.enabled
583
+ };
584
+ }
585
+ function sameTarget(monitor, candidate) {
586
+ 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);
587
+ }
588
+ function countActions(items) {
589
+ return {
590
+ create: items.filter((item) => item.action === "create").length,
591
+ update: items.filter((item) => item.action === "update").length,
592
+ unchanged: items.filter((item) => item.action === "unchanged").length,
593
+ blocked: items.filter((item) => item.action === "blocked").length,
594
+ conflict: items.filter((item) => item.action === "conflict").length
595
+ };
596
+ }
597
+ function normalizeSource(source) {
598
+ if (["manual", "projects", "servers", "domains", "deployment"].includes(source))
599
+ return source;
600
+ throw new Error(`unsupported import source: ${source}`);
601
+ }
602
+ function normalizeKind(value) {
603
+ if (value === "http" || value === "tcp" || value === "browser_page")
604
+ return value;
605
+ return value === "browser" || value === "page" ? "browser_page" : "http";
606
+ }
607
+ function targetKey(kind, url, host, port) {
608
+ return kind === "tcp" ? `${host ?? "host"}:${port ?? "port"}` : url ?? "url";
609
+ }
610
+ function sanitizeGeneratedTargetKey(kind, url, host, port) {
611
+ const key = targetKey(kind, url, host, port);
612
+ return kind === "tcp" ? key : sanitizeIdentity(key) ?? key;
613
+ }
614
+ function normalizeCandidateUrl(value) {
615
+ if (!value)
616
+ return;
617
+ try {
618
+ const parsed = new URL(value);
619
+ for (const key of [...parsed.searchParams.keys()]) {
620
+ if (isSecretKey2(key))
621
+ parsed.searchParams.set(key, "[redacted]");
622
+ }
623
+ parsed.hash = "";
624
+ return parsed.toString();
625
+ } catch {
626
+ return value;
627
+ }
628
+ }
629
+ function normalizeCandidateMethod(value) {
630
+ return value?.trim().toUpperCase();
631
+ }
632
+ function fallbackCandidate(source, record) {
633
+ const value = asRecord(record);
634
+ const monitor = asRecord(value.monitor);
635
+ const name = stringValue(monitor.name) ?? stringValue(value.name) ?? stringValue(value.domain) ?? "invalid import candidate";
636
+ const rawUrl = stringValue(monitor.url) ?? stringValue(value.url) ?? stringValue(value.domain);
637
+ const kind = normalizeKind(stringValue(monitor.kind) ?? stringValue(value.kind) ?? "http");
638
+ return {
639
+ source,
640
+ sourceId: sanitizeIdentity(stringValue(value.sourceId) ?? stringValue(value.id)) ?? `${source}:invalid`,
641
+ sourceLabel: sanitizeIdentity(stringValue(value.label)) ?? null,
642
+ name: sanitizeIdentity(name) ?? name,
643
+ kind,
644
+ url: redactUrlForDisplay(rawUrl),
645
+ host: kind === "tcp" ? sanitizeHost(stringValue(monitor.host) ?? stringValue(value.host) ?? stringValue(value.hostname)) : undefined,
646
+ port: numberValue(monitor.port) ?? numberValue(value.port),
647
+ snapshot: sanitizeSnapshot(record)
648
+ };
649
+ }
650
+ function asRecord(value) {
651
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
652
+ }
653
+ function stringValue(value) {
654
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
655
+ }
656
+ function numberValue(value) {
657
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
658
+ }
659
+ function nullableNumberValue(value) {
660
+ if (value === null)
661
+ return null;
662
+ return numberValue(value);
663
+ }
664
+ function booleanValue(value) {
665
+ return typeof value === "boolean" ? value : undefined;
666
+ }
667
+ function firstDefined(...values) {
668
+ return values.find((value) => value !== undefined);
669
+ }
670
+ function isMonitor(value) {
671
+ const row = asRecord(value);
672
+ return Boolean(stringValue(row.id) && stringValue(row.name) && stringValue(row.kind));
673
+ }
674
+ function sanitizeSnapshot(value) {
675
+ if (Array.isArray(value))
676
+ return value.map(sanitizeSnapshot);
677
+ if (!value || typeof value !== "object")
678
+ return sanitizeScalar(value);
679
+ const output = {};
680
+ for (const [key, entry] of Object.entries(value)) {
681
+ if (isSecretKey2(key))
682
+ output[key] = "[redacted]";
683
+ else
684
+ output[key] = sanitizeSnapshot(entry);
685
+ }
686
+ return output;
687
+ }
688
+ function sanitizeScalar(value) {
689
+ if (typeof value !== "string")
690
+ return value;
691
+ 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]");
692
+ }
693
+ function isSecretKey2(value) {
694
+ return /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i.test(value);
695
+ }
696
+ function rejectControlCharacters(value, label) {
697
+ if (/[\x00-\x1f\x7f-\x9f]/.test(value)) {
698
+ throw new Error(`${label} must not contain control characters`);
699
+ }
700
+ }
701
+ function boundedInteger(value, label, min, max) {
702
+ if (!Number.isInteger(value) || value < min || value > max) {
703
+ throw new Error(`${label} must be an integer from ${min} to ${max}`);
704
+ }
705
+ return value;
706
+ }
707
+ function redactUrlForDisplay(value) {
708
+ if (!value)
709
+ return;
710
+ try {
711
+ const parsed = new URL(value);
712
+ parsed.username = parsed.username ? "[redacted]" : "";
713
+ parsed.password = "";
714
+ for (const key of [...parsed.searchParams.keys()]) {
715
+ if (isSecretKey2(key))
716
+ parsed.searchParams.set(key, "[redacted]");
717
+ }
718
+ if (parsed.hash && isSecretKey2(parsed.hash))
719
+ parsed.hash = "#[redacted]";
720
+ return parsed.toString();
721
+ } catch {
722
+ return sanitizeScalar(value);
723
+ }
724
+ }
725
+ function sanitizeIdentity(value) {
726
+ if (!value)
727
+ return;
728
+ try {
729
+ const parsed = new URL(value);
730
+ parsed.username = parsed.username ? "[redacted]" : "";
731
+ parsed.password = "";
732
+ for (const key of [...parsed.searchParams.keys()]) {
733
+ if (isSecretKey2(key))
734
+ parsed.searchParams.set(key, "[redacted]");
735
+ }
736
+ parsed.hash = "";
737
+ return parsed.toString();
738
+ } catch {
739
+ return sanitizeScalar(value);
740
+ }
741
+ }
742
+ function sanitizeHost(value) {
743
+ if (!value)
744
+ return;
745
+ return sanitizeScalar(value);
746
+ }
747
+
748
+ // src/probes.ts
749
+ import { createHash, generateKeyPairSync, sign, verify } from "crypto";
750
+ function generateProbeKeyPair() {
751
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
752
+ const publicKeyPem = publicKey.export({ format: "pem", type: "spki" }).toString();
753
+ const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" }).toString();
754
+ return {
755
+ publicKeyPem,
756
+ privateKeyPem,
757
+ publicKeyFingerprint: probePublicKeyFingerprint(publicKeyPem)
758
+ };
759
+ }
760
+ function probePublicKeyFingerprint(publicKeyPem) {
761
+ return createHash("sha256").update(publicKeyPem.trim()).digest("hex");
762
+ }
763
+ function signProbeResult(input, privateKeyPem) {
764
+ return sign(null, Buffer.from(probeResultSigningPayload(input)), privateKeyPem).toString("base64url");
765
+ }
766
+ function verifyProbeResultSignature(input, publicKeyPem) {
767
+ try {
768
+ return verify(null, Buffer.from(probeResultSigningPayload(input)), publicKeyPem, Buffer.from(input.signature, "base64url"));
769
+ } catch {
770
+ return false;
771
+ }
772
+ }
773
+ function probeResultSigningPayload(input) {
774
+ return stableJson({
775
+ version: "open-uptime.probe-result.v1",
776
+ probeId: input.probeId,
777
+ jobId: input.jobId,
778
+ scheduleSlot: input.scheduleSlot,
779
+ fencingToken: input.fencingToken,
780
+ monitorId: input.monitorId,
781
+ nonce: input.nonce,
782
+ checkedAt: input.checkedAt,
783
+ status: input.status,
784
+ latencyMs: input.latencyMs,
785
+ statusCode: input.statusCode ?? null,
786
+ error: input.error ?? null,
787
+ attemptCount: input.attemptCount ?? 1,
788
+ monitorRevision: input.monitorRevision,
789
+ evidenceSha256: createHash("sha256").update(stableJson(input.evidence ?? null)).digest("hex")
790
+ });
791
+ }
792
+ function stableJson(value) {
793
+ if (value === undefined)
794
+ return "null";
795
+ if (value === null || typeof value !== "object")
796
+ return JSON.stringify(value);
797
+ if (Array.isArray(value))
798
+ return `[${value.map(stableJson).join(",")}]`;
799
+ const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== undefined).sort(([left], [right]) => left.localeCompare(right));
800
+ return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJson(entryValue)}`).join(",")}}`;
801
+ }
68
802
 
69
803
  // src/paths.ts
70
804
  import { mkdirSync } from "fs";
@@ -76,6 +810,9 @@ function uptimeHome() {
76
810
  function uptimeDbPath() {
77
811
  return process.env.HASNA_UPTIME_DB || join(uptimeHome(), "uptime.db");
78
812
  }
813
+ function uptimeHostedFallbackDbPath() {
814
+ return process.env.HASNA_UPTIME_HOSTED_FALLBACK_DB || join(uptimeHome(), "hosted-fallback", "uptime.db");
815
+ }
79
816
  function ensureUptimeHome() {
80
817
  const home = uptimeHome();
81
818
  mkdirSync(home, { recursive: true });
@@ -83,21 +820,15 @@ function ensureUptimeHome() {
83
820
  }
84
821
 
85
822
  // src/store.ts
86
- import { mkdirSync as mkdirSync2 } from "fs";
87
- import { dirname } from "path";
88
- import { randomUUID } from "crypto";
823
+ import { copyFileSync, existsSync, mkdirSync as mkdirSync2, statSync } from "fs";
824
+ import { dirname, join as join2 } from "path";
825
+ import { randomUUID as randomUUID2 } from "crypto";
89
826
  import { Database } from "bun:sqlite";
827
+ var SECRET_URL_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
828
+ var REQUIRED_TABLES = ["schema_migrations", "monitors", "check_results", "incidents", "check_leases", "monitor_provenance", "import_batches", "probe_identities", "probe_check_jobs", "probe_submissions"];
829
+ var PROBE_TABLES = new Set(["probe_identities", "probe_check_jobs", "probe_submissions"]);
830
+ var CURRENT_SCHEMA_VERSION = "2";
90
831
 
91
- // src/limits.ts
92
- var MIN_INTERVAL_SECONDS = 1;
93
- var MAX_INTERVAL_SECONDS = 86400;
94
- var MIN_TIMEOUT_MS = 1;
95
- var MAX_TIMEOUT_MS = 60000;
96
- var MIN_RETRY_COUNT = 0;
97
- var MAX_RETRY_COUNT = 10;
98
- var MAX_RESULT_LIMIT = 1000;
99
-
100
- // src/store.ts
101
832
  class StaleCheckResultError extends Error {
102
833
  constructor(message) {
103
834
  super(message);
@@ -107,9 +838,20 @@ class StaleCheckResultError extends Error {
107
838
 
108
839
  class UptimeStore {
109
840
  dbPath;
841
+ mode;
842
+ dataMode;
110
843
  db;
111
844
  constructor(options = {}) {
112
- this.dbPath = options.dbPath ?? uptimeDbPath();
845
+ this.mode = resolveRuntimeMode(options.mode ?? "local");
846
+ const cloudDatabaseUrl = options.cloudDatabaseUrl ?? process.env.HASNA_UPTIME_DATABASE_URL;
847
+ if (this.mode === "hosted" && cloudDatabaseUrl) {
848
+ throw new Error("hosted cloud database adapter is not implemented yet");
849
+ }
850
+ if (this.mode === "hosted" && !allowHostedLocalStore(options.allowHostedLocalStore)) {
851
+ throw new Error("hosted mode requires a cloud data layer; set HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE=1 only for explicit local fallback testing");
852
+ }
853
+ this.dataMode = this.mode === "hosted" ? "hosted-local-sqlite" : "local-sqlite";
854
+ this.dbPath = options.dbPath ?? (this.mode === "hosted" ? uptimeHostedFallbackDbPath() : uptimeDbPath());
113
855
  if (this.dbPath !== ":memory:") {
114
856
  mkdirSync2(dirname(this.dbPath), { recursive: true });
115
857
  }
@@ -126,7 +868,7 @@ class UptimeStore {
126
868
  CREATE TABLE IF NOT EXISTS monitors (
127
869
  id TEXT PRIMARY KEY,
128
870
  name TEXT NOT NULL UNIQUE,
129
- kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp')),
871
+ kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
130
872
  url TEXT,
131
873
  host TEXT,
132
874
  port INTEGER,
@@ -144,6 +886,7 @@ class UptimeStore {
144
886
  )
145
887
  `);
146
888
  this.ensureColumn("monitors", "revision", "INTEGER NOT NULL DEFAULT 1");
889
+ this.ensureMonitorKindAllowsBrowserPage();
147
890
  this.db.run(`
148
891
  CREATE TABLE IF NOT EXISTS check_results (
149
892
  id TEXT PRIMARY KEY,
@@ -153,9 +896,11 @@ class UptimeStore {
153
896
  latency_ms REAL,
154
897
  status_code INTEGER,
155
898
  error TEXT,
156
- attempt_count INTEGER NOT NULL DEFAULT 1
899
+ attempt_count INTEGER NOT NULL DEFAULT 1,
900
+ evidence_json TEXT
157
901
  )
158
902
  `);
903
+ this.ensureColumn("check_results", "evidence_json", "TEXT");
159
904
  this.db.run(`
160
905
  CREATE TABLE IF NOT EXISTS incidents (
161
906
  id TEXT PRIMARY KEY,
@@ -169,6 +914,71 @@ class UptimeStore {
169
914
  reason TEXT
170
915
  )
171
916
  `);
917
+ this.db.run(`
918
+ CREATE TABLE IF NOT EXISTS monitor_provenance (
919
+ monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
920
+ source TEXT NOT NULL,
921
+ source_id TEXT NOT NULL,
922
+ source_label TEXT,
923
+ imported_at TEXT NOT NULL,
924
+ snapshot_json TEXT NOT NULL,
925
+ PRIMARY KEY (source, source_id)
926
+ )
927
+ `);
928
+ this.db.run(`
929
+ CREATE TABLE IF NOT EXISTS import_batches (
930
+ id TEXT PRIMARY KEY,
931
+ source TEXT NOT NULL,
932
+ status TEXT NOT NULL CHECK (status IN ('applied', 'rolled_back')),
933
+ created_at TEXT NOT NULL,
934
+ rolled_back_at TEXT,
935
+ records_json TEXT NOT NULL
936
+ )
937
+ `);
938
+ this.db.run(`
939
+ CREATE TABLE IF NOT EXISTS probe_identities (
940
+ id TEXT PRIMARY KEY,
941
+ name TEXT NOT NULL UNIQUE,
942
+ public_key_pem TEXT NOT NULL,
943
+ public_key_fingerprint TEXT NOT NULL UNIQUE,
944
+ enabled INTEGER NOT NULL DEFAULT 1,
945
+ created_at TEXT NOT NULL,
946
+ last_seen_at TEXT
947
+ )
948
+ `);
949
+ this.db.run(`
950
+ CREATE TABLE IF NOT EXISTS probe_submissions (
951
+ id TEXT PRIMARY KEY,
952
+ probe_id TEXT NOT NULL REFERENCES probe_identities(id) ON DELETE CASCADE,
953
+ job_id TEXT NOT NULL,
954
+ monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
955
+ check_result_id TEXT NOT NULL REFERENCES check_results(id) ON DELETE CASCADE,
956
+ nonce TEXT NOT NULL,
957
+ checked_at TEXT NOT NULL,
958
+ submitted_at TEXT NOT NULL,
959
+ UNIQUE (probe_id, nonce)
960
+ )
961
+ `);
962
+ this.ensureColumn("probe_submissions", "job_id", "TEXT");
963
+ this.db.run(`
964
+ CREATE TABLE IF NOT EXISTS probe_check_jobs (
965
+ id TEXT PRIMARY KEY,
966
+ monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
967
+ monitor_revision INTEGER NOT NULL DEFAULT 1,
968
+ schedule_slot TEXT NOT NULL,
969
+ status TEXT NOT NULL CHECK (status IN ('pending', 'claimed', 'submitted', 'expired', 'cancelled')),
970
+ claimed_by_probe_id TEXT REFERENCES probe_identities(id) ON DELETE SET NULL,
971
+ fencing_token TEXT,
972
+ due_at TEXT NOT NULL,
973
+ claimed_at TEXT,
974
+ lease_expires_at TEXT,
975
+ submitted_result_id TEXT REFERENCES check_results(id) ON DELETE SET NULL,
976
+ created_at TEXT NOT NULL,
977
+ updated_at TEXT NOT NULL,
978
+ UNIQUE (monitor_id, schedule_slot)
979
+ )
980
+ `);
981
+ this.ensureColumn("probe_check_jobs", "monitor_revision", "INTEGER NOT NULL DEFAULT 1");
172
982
  this.db.run(`
173
983
  CREATE TABLE IF NOT EXISTS check_leases (
174
984
  monitor_id TEXT PRIMARY KEY REFERENCES monitors(id) ON DELETE CASCADE,
@@ -177,12 +987,71 @@ class UptimeStore {
177
987
  acquired_at TEXT NOT NULL
178
988
  )
179
989
  `);
990
+ this.db.run(`
991
+ CREATE TABLE IF NOT EXISTS schema_migrations (
992
+ key TEXT PRIMARY KEY,
993
+ value TEXT NOT NULL,
994
+ updated_at TEXT NOT NULL
995
+ )
996
+ `);
997
+ this.db.query("INSERT OR REPLACE INTO schema_migrations (key, value, updated_at) VALUES ('schema_version', ?, ?)").run(CURRENT_SCHEMA_VERSION, new Date().toISOString());
180
998
  this.db.run("CREATE INDEX IF NOT EXISTS idx_results_monitor_time ON check_results(monitor_id, checked_at DESC)");
181
999
  this.db.run("CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents(monitor_id, status)");
182
1000
  this.db.run("CREATE INDEX IF NOT EXISTS idx_check_leases_until ON check_leases(leased_until)");
1001
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_monitor_provenance_monitor ON monitor_provenance(monitor_id)");
1002
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_status_due ON probe_check_jobs(status, due_at)");
1003
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_probe_status ON probe_check_jobs(claimed_by_probe_id, status)");
1004
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_probe_time ON probe_submissions(probe_id, submitted_at DESC)");
1005
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_monitor_time ON probe_submissions(monitor_id, checked_at DESC)");
1006
+ 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 != ''");
183
1007
  }
184
- createMonitor(input) {
185
- const normalized = normalizeCreateMonitor(input);
1008
+ backup(destinationPath) {
1009
+ if (this.dbPath === ":memory:" && !destinationPath) {
1010
+ throw new Error("backup path is required for in-memory stores");
1011
+ }
1012
+ const createdAt = new Date().toISOString();
1013
+ const backupPath = destinationPath ?? join2(dirname(this.dbPath), "backups", `uptime-${createdAt.replace(/[:.]/g, "-")}.db`);
1014
+ mkdirSync2(dirname(backupPath), { recursive: true });
1015
+ if (this.dbPath === ":memory:") {
1016
+ this.vacuumInto(backupPath);
1017
+ } else {
1018
+ this.db.run("PRAGMA wal_checkpoint(TRUNCATE)");
1019
+ copyFileSync(this.dbPath, backupPath);
1020
+ }
1021
+ const bytes = statSync(backupPath).size;
1022
+ return { sourcePath: this.dbPath, backupPath, bytes, createdAt };
1023
+ }
1024
+ verifyBackup(backupPath) {
1025
+ return verifyBackupFile(backupPath);
1026
+ }
1027
+ static verifyBackup(backupPath) {
1028
+ return verifyBackupFile(backupPath);
1029
+ }
1030
+ static restoreBackup(backupPath, destinationPath = uptimeDbPath()) {
1031
+ const check = verifyBackupFile(backupPath);
1032
+ if (!check.ok)
1033
+ throw new Error(`backup integrity check failed: ${check.integrity}`);
1034
+ if (destinationPath === ":memory:")
1035
+ throw new Error("cannot restore a backup to an in-memory store");
1036
+ if (existsSync(destinationPath) || existsSync(`${destinationPath}-wal`) || existsSync(`${destinationPath}-shm`)) {
1037
+ throw new Error("restore destination already exists or has SQLite sidecar files");
1038
+ }
1039
+ mkdirSync2(dirname(destinationPath), { recursive: true });
1040
+ copyFileSync(backupPath, destinationPath);
1041
+ const bytes = statSync(destinationPath).size;
1042
+ return {
1043
+ sourcePath: backupPath,
1044
+ backupPath: destinationPath,
1045
+ bytes,
1046
+ createdAt: new Date().toISOString()
1047
+ };
1048
+ }
1049
+ createMonitor(input, options = {}) {
1050
+ if (this.mode === "hosted")
1051
+ assertHostedTargetAllowed(input);
1052
+ const normalized = normalizeCreateMonitor(input, options.allowBrowserPage === true);
1053
+ if (this.mode === "hosted")
1054
+ assertHostedTargetAllowed(normalized);
186
1055
  const now = new Date().toISOString();
187
1056
  const monitor = {
188
1057
  id: newId("mon"),
@@ -218,12 +1087,22 @@ class UptimeStore {
218
1087
  const row = this.db.query("SELECT * FROM monitors WHERE id = ? OR name = ?").get(idOrName, idOrName);
219
1088
  return row ? monitorFromRow(row) : null;
220
1089
  }
221
- updateMonitor(idOrName, input) {
1090
+ updateMonitor(idOrName, input, options = {}) {
222
1091
  const current = this.getMonitor(idOrName);
223
1092
  if (!current)
224
1093
  throw new Error(`Monitor not found: ${idOrName}`);
1094
+ if (this.mode === "hosted") {
1095
+ assertHostedTargetAllowed({
1096
+ kind: input.kind ?? current.kind,
1097
+ url: input.url ?? current.url ?? undefined,
1098
+ host: input.host ?? current.host ?? undefined,
1099
+ port: input.port ?? current.port ?? undefined
1100
+ });
1101
+ }
225
1102
  const updatedAt = new Date().toISOString();
226
- const next = normalizeUpdateMonitor(current, input, updatedAt);
1103
+ const next = normalizeUpdateMonitor(current, input, updatedAt, options.allowBrowserPage === true);
1104
+ if (this.mode === "hosted")
1105
+ assertHostedTargetAllowed(next);
227
1106
  this.db.query(`UPDATE monitors SET
228
1107
  name = ?, kind = ?, url = ?, host = ?, port = ?, method = ?,
229
1108
  expected_status = ?, interval_seconds = ?, timeout_ms = ?,
@@ -242,6 +1121,185 @@ class UptimeStore {
242
1121
  this.db.query("DELETE FROM monitors WHERE id = ?").run(current.id);
243
1122
  return true;
244
1123
  }
1124
+ createProbeIdentity(input) {
1125
+ const name = input.name.trim();
1126
+ if (!name)
1127
+ throw new Error("Probe name is required");
1128
+ rejectControlCharacters2(name, "Probe name");
1129
+ const now = new Date().toISOString();
1130
+ const probe = {
1131
+ id: newId("prb"),
1132
+ name,
1133
+ publicKeyPem: input.publicKeyPem.trim(),
1134
+ publicKeyFingerprint: input.publicKeyFingerprint,
1135
+ enabled: input.enabled ?? true,
1136
+ createdAt: now,
1137
+ lastSeenAt: null
1138
+ };
1139
+ if (!probe.publicKeyPem)
1140
+ throw new Error("Probe public key is required");
1141
+ this.db.query(`INSERT INTO probe_identities (
1142
+ id, name, public_key_pem, public_key_fingerprint, enabled, created_at, last_seen_at
1143
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(probe.id, probe.name, probe.publicKeyPem, probe.publicKeyFingerprint, probe.enabled ? 1 : 0, probe.createdAt, probe.lastSeenAt);
1144
+ return probe;
1145
+ }
1146
+ listProbeIdentities(options = {}) {
1147
+ 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();
1148
+ return rows.map(probeIdentityFromRow);
1149
+ }
1150
+ getProbeIdentity(idOrName) {
1151
+ const row = this.db.query("SELECT * FROM probe_identities WHERE id = ? OR name = ?").get(idOrName, idOrName);
1152
+ return row ? probeIdentityFromRow(row) : null;
1153
+ }
1154
+ updateProbeIdentity(idOrName, input) {
1155
+ const current = this.getProbeIdentity(idOrName);
1156
+ if (!current)
1157
+ throw new Error(`Probe not found: ${idOrName}`);
1158
+ const name = input.name === undefined ? current.name : input.name.trim();
1159
+ if (!name)
1160
+ throw new Error("Probe name is required");
1161
+ rejectControlCharacters2(name, "Probe name");
1162
+ const enabled = input.enabled ?? current.enabled;
1163
+ this.db.query("UPDATE probe_identities SET name = ?, enabled = ? WHERE id = ?").run(name, enabled ? 1 : 0, current.id);
1164
+ return this.getProbeIdentity(current.id);
1165
+ }
1166
+ touchProbeIdentity(idOrName, seenAt = new Date().toISOString()) {
1167
+ const probe = this.getProbeIdentity(idOrName);
1168
+ if (!probe)
1169
+ throw new Error(`Probe not found: ${idOrName}`);
1170
+ this.db.query("UPDATE probe_identities SET last_seen_at = ? WHERE id = ?").run(seenAt, probe.id);
1171
+ }
1172
+ createProbeCheckJob(input) {
1173
+ const monitor = this.getMonitor(input.monitorId);
1174
+ if (!monitor)
1175
+ throw new Error(`Monitor not found: ${input.monitorId}`);
1176
+ if (!monitor.enabled)
1177
+ throw new Error(`Monitor is disabled: ${monitor.name}`);
1178
+ const scheduleSlot = normalizeScheduleSlot(input.scheduleSlot);
1179
+ const dueAt = input.dueAt ?? new Date().toISOString();
1180
+ assertIsoTimestamp(dueAt, "Probe job dueAt");
1181
+ const now = new Date().toISOString();
1182
+ const existing = this.db.query("SELECT * FROM probe_check_jobs WHERE monitor_id = ? AND schedule_slot = ?").get(monitor.id, scheduleSlot);
1183
+ if (existing)
1184
+ return probeCheckJobFromRow(existing);
1185
+ const job = {
1186
+ id: newId("job"),
1187
+ monitorId: monitor.id,
1188
+ monitorRevision: monitor.revision,
1189
+ scheduleSlot,
1190
+ status: "pending",
1191
+ claimedByProbeId: null,
1192
+ fencingToken: null,
1193
+ dueAt,
1194
+ claimedAt: null,
1195
+ leaseExpiresAt: null,
1196
+ submittedResultId: null,
1197
+ createdAt: now,
1198
+ updatedAt: now
1199
+ };
1200
+ this.db.query(`INSERT INTO probe_check_jobs (
1201
+ id, monitor_id, monitor_revision, schedule_slot, status, claimed_by_probe_id, fencing_token,
1202
+ due_at, claimed_at, lease_expires_at, submitted_result_id, created_at, updated_at
1203
+ ) 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);
1204
+ return job;
1205
+ }
1206
+ getProbeCheckJob(id) {
1207
+ const row = this.db.query("SELECT * FROM probe_check_jobs WHERE id = ?").get(id);
1208
+ return row ? probeCheckJobFromRow(row) : null;
1209
+ }
1210
+ claimProbeCheckJob(input) {
1211
+ const tx = this.db.transaction(() => {
1212
+ const probe = this.getProbeIdentity(input.probeId);
1213
+ if (!probe)
1214
+ throw new Error(`Probe not found: ${input.probeId}`);
1215
+ if (!probe.enabled)
1216
+ throw new Error(`Probe is disabled: ${probe.name}`);
1217
+ const current = this.getProbeCheckJob(input.jobId);
1218
+ if (!current)
1219
+ throw new Error(`Probe job not found: ${input.jobId}`);
1220
+ const now = new Date;
1221
+ const nowIso = now.toISOString();
1222
+ if (current.status === "submitted")
1223
+ throw new Error("Probe job already submitted");
1224
+ if (current.status === "cancelled")
1225
+ throw new Error("Probe job is cancelled");
1226
+ if (current.dueAt > nowIso)
1227
+ throw new Error("Probe job is not due yet");
1228
+ const leaseExpired = Boolean(current.leaseExpiresAt && current.leaseExpiresAt <= nowIso);
1229
+ if (current.status === "claimed" && !leaseExpired && current.claimedByProbeId !== probe.id) {
1230
+ throw new Error("Probe job already claimed by another probe");
1231
+ }
1232
+ if (current.status !== "pending" && current.status !== "claimed" && current.status !== "expired") {
1233
+ throw new Error(`Probe job is not claimable: ${current.status}`);
1234
+ }
1235
+ const leaseExpiresAt = new Date(now.getTime() + Math.max(1000, input.leaseTtlMs ?? 120000)).toISOString();
1236
+ const fencingToken = newId("fence");
1237
+ const update = this.db.query(`UPDATE probe_check_jobs
1238
+ SET status = 'claimed', claimed_by_probe_id = ?, fencing_token = ?, claimed_at = ?, lease_expires_at = ?, updated_at = ?
1239
+ WHERE id = ?
1240
+ AND submitted_result_id IS NULL
1241
+ AND (
1242
+ status IN ('pending', 'expired')
1243
+ OR (status = 'claimed' AND (claimed_by_probe_id = ? OR lease_expires_at <= ?))
1244
+ )`).run(probe.id, fencingToken, nowIso, leaseExpiresAt, nowIso, current.id, probe.id, nowIso);
1245
+ if (statementChanges(update) !== 1)
1246
+ throw new Error("Probe job claim raced; retry");
1247
+ this.touchProbeIdentity(probe.id, nowIso);
1248
+ return this.getProbeCheckJob(current.id);
1249
+ });
1250
+ return tx();
1251
+ }
1252
+ completeProbeCheckJob(input) {
1253
+ const job = this.getProbeCheckJob(input.jobId);
1254
+ if (!job)
1255
+ throw new Error(`Probe job not found: ${input.jobId}`);
1256
+ const submittedAt = input.submittedAt ?? new Date().toISOString();
1257
+ if (job.status !== "claimed")
1258
+ throw new Error(`Probe job is not claimable for submission: ${job.status}`);
1259
+ if (job.claimedByProbeId !== input.probeId)
1260
+ throw new Error("Probe job was claimed by another probe");
1261
+ if (job.fencingToken !== input.fencingToken)
1262
+ throw new Error("Probe job fencing token is invalid");
1263
+ if (!job.leaseExpiresAt || job.leaseExpiresAt <= submittedAt) {
1264
+ this.expireProbeCheckJob(job.id, submittedAt);
1265
+ throw new Error("Probe job lease expired");
1266
+ }
1267
+ const update = this.db.query(`UPDATE probe_check_jobs
1268
+ SET status = 'submitted', submitted_result_id = ?, updated_at = ?
1269
+ WHERE id = ?
1270
+ AND status = 'claimed'
1271
+ AND claimed_by_probe_id = ?
1272
+ AND fencing_token = ?
1273
+ AND lease_expires_at > ?
1274
+ AND submitted_result_id IS NULL`).run(input.checkResultId, submittedAt, job.id, input.probeId, input.fencingToken, submittedAt);
1275
+ if (statementChanges(update) !== 1)
1276
+ throw new Error("Probe job submission raced; retry");
1277
+ return this.getProbeCheckJob(job.id);
1278
+ }
1279
+ expireProbeCheckJob(jobId, updatedAt = new Date().toISOString()) {
1280
+ this.db.query("UPDATE probe_check_jobs SET status = 'expired', updated_at = ? WHERE id = ? AND status != 'submitted'").run(updatedAt, jobId);
1281
+ }
1282
+ getProbeSubmission(probeId, nonce) {
1283
+ const row = this.db.query("SELECT * FROM probe_submissions WHERE probe_id = ? AND nonce = ?").get(probeId, nonce);
1284
+ return row ? probeSubmissionFromRow(row) : null;
1285
+ }
1286
+ recordProbeSubmission(input) {
1287
+ const submittedAt = input.submittedAt ?? new Date().toISOString();
1288
+ const receipt = {
1289
+ id: newId("psb"),
1290
+ probeId: input.probeId,
1291
+ jobId: input.jobId,
1292
+ monitorId: input.monitorId,
1293
+ checkResultId: input.checkResultId,
1294
+ nonce: input.nonce,
1295
+ checkedAt: input.checkedAt,
1296
+ submittedAt
1297
+ };
1298
+ this.db.query(`INSERT INTO probe_submissions (
1299
+ id, probe_id, job_id, monitor_id, check_result_id, nonce, checked_at, submitted_at
1300
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(receipt.id, receipt.probeId, receipt.jobId, receipt.monitorId, receipt.checkResultId, receipt.nonce, receipt.checkedAt, receipt.submittedAt);
1301
+ return receipt;
1302
+ }
245
1303
  acquireCheckLease(monitorId, owner, ttlMs) {
246
1304
  const now = new Date;
247
1305
  const nowIso = now.toISOString();
@@ -276,7 +1334,8 @@ class UptimeStore {
276
1334
  latencyMs: input.latencyMs,
277
1335
  statusCode: input.statusCode,
278
1336
  error: input.error,
279
- attemptCount: Math.max(1, input.attemptCount)
1337
+ attemptCount: Math.max(1, input.attemptCount),
1338
+ evidence: input.evidence ?? null
280
1339
  };
281
1340
  const tx = this.db.transaction(() => {
282
1341
  const current = this.db.query("SELECT * FROM monitors WHERE id = ?").get(result.monitorId);
@@ -289,19 +1348,59 @@ class UptimeStore {
289
1348
  throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${current.name}`);
290
1349
  }
291
1350
  this.db.query(`INSERT INTO check_results (
292
- id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count
293
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(result.id, result.monitorId, result.checkedAt, result.status, result.latencyMs, result.statusCode, result.error, result.attemptCount);
1351
+ id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count, evidence_json
1352
+ ) 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);
294
1353
  this.db.query("UPDATE monitors SET status = ?, last_checked_at = ?, updated_at = ? WHERE id = ?").run(result.status, result.checkedAt, result.checkedAt, result.monitorId);
295
1354
  this.reconcileIncidentInTransaction(result);
296
1355
  });
297
1356
  tx();
298
1357
  return result;
299
1358
  }
1359
+ getCheckResult(id) {
1360
+ const row = this.db.query("SELECT * FROM check_results WHERE id = ?").get(id);
1361
+ return row ? checkResultFromRow(row) : null;
1362
+ }
300
1363
  listResults(options = {}) {
301
1364
  const limit = clampLimit(options.limit ?? 50);
302
1365
  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);
303
1366
  return rows.map(checkResultFromRow);
304
1367
  }
1368
+ getProvenance(source, sourceId) {
1369
+ const row = this.db.query("SELECT * FROM monitor_provenance WHERE source = ? AND source_id = ?").get(source, sourceId);
1370
+ return row ? provenanceFromRow(row) : null;
1371
+ }
1372
+ upsertMonitorProvenance(input) {
1373
+ const importedAt = new Date().toISOString();
1374
+ this.db.query(`INSERT INTO monitor_provenance (
1375
+ monitor_id, source, source_id, source_label, imported_at, snapshot_json
1376
+ ) VALUES (?, ?, ?, ?, ?, ?)
1377
+ ON CONFLICT(source, source_id) DO UPDATE SET
1378
+ monitor_id = excluded.monitor_id,
1379
+ source_label = excluded.source_label,
1380
+ imported_at = excluded.imported_at,
1381
+ snapshot_json = excluded.snapshot_json`).run(input.monitorId, input.source, input.sourceId, input.sourceLabel ?? null, importedAt, JSON.stringify(input.snapshot));
1382
+ return this.getProvenance(input.source, input.sourceId);
1383
+ }
1384
+ saveImportBatch(input) {
1385
+ const createdAt = new Date().toISOString();
1386
+ 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));
1387
+ return this.getImportBatch(input.id);
1388
+ }
1389
+ getImportBatch(batchId) {
1390
+ const row = this.db.query("SELECT * FROM import_batches WHERE id = ?").get(batchId);
1391
+ return row ? importBatchFromRow(row) : null;
1392
+ }
1393
+ markImportBatchRolledBack(batchId) {
1394
+ const rolledBackAt = new Date().toISOString();
1395
+ this.db.query("UPDATE import_batches SET status = 'rolled_back', rolled_back_at = ? WHERE id = ?").run(rolledBackAt, batchId);
1396
+ const batch = this.getImportBatch(batchId);
1397
+ if (!batch)
1398
+ throw new Error(`Import batch not found: ${batchId}`);
1399
+ return batch;
1400
+ }
1401
+ runInTransaction(fn) {
1402
+ return this.db.transaction(fn)();
1403
+ }
305
1404
  listIncidents(options = {}) {
306
1405
  const clauses = [];
307
1406
  const args = [];
@@ -389,16 +1488,115 @@ class UptimeStore {
389
1488
  this.db.run(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
390
1489
  }
391
1490
  }
1491
+ ensureMonitorKindAllowsBrowserPage() {
1492
+ const row = this.db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'monitors'").get();
1493
+ if (!row?.sql || row.sql.includes("browser_page"))
1494
+ return;
1495
+ this.db.run("PRAGMA foreign_keys = OFF");
1496
+ this.db.run("PRAGMA legacy_alter_table = ON");
1497
+ try {
1498
+ const migrate = this.db.transaction(() => {
1499
+ this.db.run("ALTER TABLE monitors RENAME TO monitors_old_kind");
1500
+ this.db.run(`
1501
+ CREATE TABLE monitors (
1502
+ id TEXT PRIMARY KEY,
1503
+ name TEXT NOT NULL UNIQUE,
1504
+ kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
1505
+ url TEXT,
1506
+ host TEXT,
1507
+ port INTEGER,
1508
+ method TEXT NOT NULL DEFAULT 'GET',
1509
+ expected_status INTEGER,
1510
+ interval_seconds INTEGER NOT NULL DEFAULT 60,
1511
+ timeout_ms INTEGER NOT NULL DEFAULT 5000,
1512
+ retry_count INTEGER NOT NULL DEFAULT 0,
1513
+ enabled INTEGER NOT NULL DEFAULT 1,
1514
+ status TEXT NOT NULL DEFAULT 'unknown',
1515
+ last_checked_at TEXT,
1516
+ revision INTEGER NOT NULL DEFAULT 1,
1517
+ created_at TEXT NOT NULL,
1518
+ updated_at TEXT NOT NULL
1519
+ )
1520
+ `);
1521
+ this.db.run(`
1522
+ INSERT INTO monitors (
1523
+ id, name, kind, url, host, port, method, expected_status,
1524
+ interval_seconds, timeout_ms, retry_count, enabled, status,
1525
+ last_checked_at, revision, created_at, updated_at
1526
+ )
1527
+ SELECT
1528
+ id, name, kind, url, host, port, method, expected_status,
1529
+ interval_seconds, timeout_ms, retry_count, enabled, status,
1530
+ last_checked_at, revision, created_at, updated_at
1531
+ FROM monitors_old_kind
1532
+ `);
1533
+ this.db.run("DROP TABLE monitors_old_kind");
1534
+ });
1535
+ migrate();
1536
+ } finally {
1537
+ this.db.run("PRAGMA legacy_alter_table = OFF");
1538
+ this.db.run("PRAGMA foreign_keys = ON");
1539
+ }
1540
+ }
1541
+ vacuumInto(backupPath) {
1542
+ const quoted = backupPath.replace(/'/g, "''");
1543
+ this.db.run(`VACUUM INTO '${quoted}'`);
1544
+ }
1545
+ }
1546
+ function resolveRuntimeMode(mode) {
1547
+ const value = mode ?? process.env.HASNA_UPTIME_MODE ?? "local";
1548
+ if (value === "local" || value === "hosted")
1549
+ return value;
1550
+ throw new Error("HASNA_UPTIME_MODE must be local or hosted");
1551
+ }
1552
+ function allowHostedLocalStore(value) {
1553
+ return value === true || process.env.HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE === "1";
1554
+ }
1555
+ function verifyBackupFile(backupPath) {
1556
+ const db = new Database(backupPath, { readonly: true });
1557
+ try {
1558
+ const integrityRow = db.query("PRAGMA integrity_check").get();
1559
+ const integrity = String(integrityRow?.integrity_check ?? "unknown");
1560
+ const missingTables = REQUIRED_TABLES.filter((table) => !tableExists(db, table));
1561
+ const schemaVersion = missingTables.includes("schema_migrations") ? null : db.query("SELECT value FROM schema_migrations WHERE key = 'schema_version'").get()?.value ?? null;
1562
+ const currentOk = missingTables.length === 0 && schemaVersion === CURRENT_SCHEMA_VERSION;
1563
+ const restorableV1 = schemaVersion === "1" && missingTables.every((table) => PROBE_TABLES.has(table));
1564
+ return {
1565
+ ok: integrity === "ok" && (currentOk || restorableV1),
1566
+ backupPath,
1567
+ integrity,
1568
+ schemaVersion,
1569
+ missingTables,
1570
+ monitors: tableCount(db, "monitors"),
1571
+ results: tableCount(db, "check_results"),
1572
+ incidents: tableCount(db, "incidents")
1573
+ };
1574
+ } finally {
1575
+ db.close();
1576
+ }
1577
+ }
1578
+ function tableCount(db, table) {
1579
+ if (!tableExists(db, table))
1580
+ return 0;
1581
+ const row = db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
1582
+ return Number(row?.count ?? 0);
1583
+ }
1584
+ function tableExists(db, table) {
1585
+ const row = db.query("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
1586
+ return Number(row?.count ?? 0) > 0;
392
1587
  }
393
- function normalizeCreateMonitor(input) {
1588
+ function normalizeCreateMonitor(input, allowBrowserPage = false) {
394
1589
  const name = input.name?.trim();
395
1590
  if (!name)
396
1591
  throw new Error("Monitor name is required");
397
- rejectControlCharacters(name, "Monitor name");
1592
+ rejectControlCharacters2(name, "Monitor name");
398
1593
  const method = normalizeMethod(input.method ?? "GET");
399
1594
  const expectedStatus = normalizeExpectedStatus(input.expectedStatus);
400
1595
  const enabled = normalizeEnabled(input.enabled);
401
- if (input.kind === "http") {
1596
+ if (input.kind === "http" || input.kind === "browser_page") {
1597
+ if (input.kind === "browser_page" && !allowBrowserPage) {
1598
+ throw new Error("browser_page monitors must be imported with explicit browser evidence support");
1599
+ }
402
1600
  const url = normalizeHttpUrl(input.url);
403
1601
  return {
404
1602
  name,
@@ -406,16 +1604,16 @@ function normalizeCreateMonitor(input) {
406
1604
  url,
407
1605
  method,
408
1606
  expectedStatus,
409
- intervalSeconds: boundedInteger(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
410
- timeoutMs: boundedInteger(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
411
- retryCount: boundedInteger(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
1607
+ intervalSeconds: boundedInteger2(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
1608
+ timeoutMs: boundedInteger2(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
1609
+ retryCount: boundedInteger2(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
412
1610
  enabled
413
1611
  };
414
1612
  } else if (input.kind === "tcp") {
415
1613
  const host = input.host?.trim();
416
1614
  if (!host)
417
1615
  throw new Error("TCP monitors require host");
418
- rejectControlCharacters(host, "TCP host");
1616
+ rejectControlCharacters2(host, "TCP host");
419
1617
  if (!Number.isInteger(input.port) || input.port <= 0 || input.port > 65535) {
420
1618
  throw new Error("TCP monitors require a port from 1 to 65535");
421
1619
  }
@@ -426,19 +1624,19 @@ function normalizeCreateMonitor(input) {
426
1624
  port: input.port,
427
1625
  method,
428
1626
  expectedStatus: null,
429
- intervalSeconds: boundedInteger(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
430
- timeoutMs: boundedInteger(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
431
- retryCount: boundedInteger(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
1627
+ intervalSeconds: boundedInteger2(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
1628
+ timeoutMs: boundedInteger2(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
1629
+ retryCount: boundedInteger2(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
432
1630
  enabled
433
1631
  };
434
1632
  } else {
435
- throw new Error("Monitor kind must be http or tcp");
1633
+ throw new Error("Monitor kind must be http, tcp, or browser_page");
436
1634
  }
437
1635
  }
438
1636
  function definitionChanged(current, next) {
439
1637
  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
1638
  }
441
- function normalizeUpdateMonitor(current, input, updatedAt) {
1639
+ function normalizeUpdateMonitor(current, input, updatedAt, allowBrowserPage = false) {
442
1640
  const merged = {
443
1641
  ...current,
444
1642
  ...input,
@@ -457,7 +1655,7 @@ function normalizeUpdateMonitor(current, input, updatedAt) {
457
1655
  timeoutMs: merged.timeoutMs,
458
1656
  retryCount: merged.retryCount,
459
1657
  enabled: merged.enabled
460
- });
1658
+ }, allowBrowserPage || current.kind === "browser_page");
461
1659
  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;
462
1660
  const status = normalized.enabled ? checkDefinitionChanged || !current.enabled ? "unknown" : current.status : "paused";
463
1661
  return {
@@ -486,6 +1684,11 @@ function normalizeHttpUrl(value) {
486
1684
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
487
1685
  throw new Error("HTTP monitor url must use http or https");
488
1686
  }
1687
+ for (const key of [...parsed.searchParams.keys()]) {
1688
+ if (SECRET_URL_PARAM_PATTERN.test(key))
1689
+ parsed.searchParams.set(key, "[redacted]");
1690
+ }
1691
+ parsed.hash = "";
489
1692
  return parsed.toString();
490
1693
  }
491
1694
  function normalizeMethod(value) {
@@ -509,11 +1712,25 @@ function normalizeEnabled(value) {
509
1712
  throw new Error("enabled must be a boolean");
510
1713
  return value;
511
1714
  }
512
- function rejectControlCharacters(value, label) {
1715
+ function rejectControlCharacters2(value, label) {
513
1716
  if (/[\x00-\x1f\x7f-\x9f]/.test(value)) {
514
1717
  throw new Error(`${label} must not contain control characters`);
515
1718
  }
516
1719
  }
1720
+ function normalizeScheduleSlot(value) {
1721
+ const slot = value.trim();
1722
+ if (!slot)
1723
+ throw new Error("Probe job scheduleSlot is required");
1724
+ if (slot.length > 128)
1725
+ throw new Error("Probe job scheduleSlot is too long");
1726
+ rejectControlCharacters2(slot, "Probe job scheduleSlot");
1727
+ return slot;
1728
+ }
1729
+ function assertIsoTimestamp(value, label) {
1730
+ if (!Number.isFinite(Date.parse(value))) {
1731
+ throw new Error(`${label} must be an ISO timestamp`);
1732
+ }
1733
+ }
517
1734
  function monitorFromRow(row) {
518
1735
  return {
519
1736
  id: row.id,
@@ -544,9 +1761,83 @@ function checkResultFromRow(row) {
544
1761
  latencyMs: row.latency_ms,
545
1762
  statusCode: row.status_code,
546
1763
  error: row.error,
547
- attemptCount: row.attempt_count
1764
+ attemptCount: row.attempt_count,
1765
+ evidence: parseEvidence(row.evidence_json)
1766
+ };
1767
+ }
1768
+ function provenanceFromRow(row) {
1769
+ return {
1770
+ monitorId: row.monitor_id,
1771
+ source: row.source,
1772
+ sourceId: row.source_id,
1773
+ sourceLabel: row.source_label,
1774
+ importedAt: row.imported_at,
1775
+ snapshot: parseJson(row.snapshot_json)
1776
+ };
1777
+ }
1778
+ function importBatchFromRow(row) {
1779
+ return {
1780
+ id: row.id,
1781
+ source: row.source,
1782
+ status: row.status,
1783
+ createdAt: row.created_at,
1784
+ rolledBackAt: row.rolled_back_at,
1785
+ records: Array.isArray(parseJson(row.records_json)) ? parseJson(row.records_json) : []
1786
+ };
1787
+ }
1788
+ function probeIdentityFromRow(row) {
1789
+ return {
1790
+ id: row.id,
1791
+ name: row.name,
1792
+ publicKeyPem: row.public_key_pem,
1793
+ publicKeyFingerprint: row.public_key_fingerprint,
1794
+ enabled: Boolean(row.enabled),
1795
+ createdAt: row.created_at,
1796
+ lastSeenAt: row.last_seen_at
548
1797
  };
549
1798
  }
1799
+ function probeSubmissionFromRow(row) {
1800
+ return {
1801
+ id: row.id,
1802
+ probeId: row.probe_id,
1803
+ jobId: row.job_id ?? "",
1804
+ monitorId: row.monitor_id,
1805
+ checkResultId: row.check_result_id,
1806
+ nonce: row.nonce,
1807
+ checkedAt: row.checked_at,
1808
+ submittedAt: row.submitted_at
1809
+ };
1810
+ }
1811
+ function probeCheckJobFromRow(row) {
1812
+ return {
1813
+ id: row.id,
1814
+ monitorId: row.monitor_id,
1815
+ monitorRevision: row.monitor_revision ?? 1,
1816
+ scheduleSlot: row.schedule_slot,
1817
+ status: row.status,
1818
+ claimedByProbeId: row.claimed_by_probe_id,
1819
+ fencingToken: row.fencing_token,
1820
+ dueAt: row.due_at,
1821
+ claimedAt: row.claimed_at,
1822
+ leaseExpiresAt: row.lease_expires_at,
1823
+ submittedResultId: row.submitted_result_id,
1824
+ createdAt: row.created_at,
1825
+ updatedAt: row.updated_at
1826
+ };
1827
+ }
1828
+ function parseEvidence(value) {
1829
+ if (!value)
1830
+ return null;
1831
+ const parsed = parseJson(value);
1832
+ return parsed && typeof parsed === "object" ? parsed : null;
1833
+ }
1834
+ function parseJson(value) {
1835
+ try {
1836
+ return JSON.parse(value);
1837
+ } catch {
1838
+ return null;
1839
+ }
1840
+ }
550
1841
  function incidentFromRow(row) {
551
1842
  return {
552
1843
  id: row.id,
@@ -561,9 +1852,9 @@ function incidentFromRow(row) {
561
1852
  };
562
1853
  }
563
1854
  function newId(prefix) {
564
- return `${prefix}_${randomUUID().replace(/-/g, "").slice(0, 18)}`;
1855
+ return `${prefix}_${randomUUID2().replace(/-/g, "").slice(0, 18)}`;
565
1856
  }
566
- function boundedInteger(value, label, min, max) {
1857
+ function boundedInteger2(value, label, min, max) {
567
1858
  if (!Number.isInteger(value) || value < min || value > max) {
568
1859
  throw new Error(`${label} must be an integer from ${min} to ${max}`);
569
1860
  }
@@ -574,6 +1865,9 @@ function clampLimit(value) {
574
1865
  return 50;
575
1866
  return Math.max(1, Math.min(Math.floor(value), MAX_RESULT_LIMIT));
576
1867
  }
1868
+ function statementChanges(result) {
1869
+ return Number(result?.changes ?? 0);
1870
+ }
577
1871
  function round(value, places) {
578
1872
  const factor = 10 ** places;
579
1873
  return Math.round(value * factor) / factor;
@@ -648,7 +1942,7 @@ function renderMonitorLine(item) {
648
1942
  return `- ${item.monitor.status.toUpperCase()} ${item.monitor.name} (${targetLabel(item)}): uptime ${uptime}, latency ${latency}${incident}`;
649
1943
  }
650
1944
  function targetLabel(item) {
651
- return item.monitor.kind === "http" ? item.monitor.url ?? "" : `${item.monitor.host}:${item.monitor.port}`;
1945
+ return item.monitor.kind === "tcp" ? `${item.monitor.host}:${item.monitor.port}` : item.monitor.url ?? "";
652
1946
  }
653
1947
  function resolveEmailTarget(value) {
654
1948
  const target = typeof value === "boolean" ? {} : value;
@@ -850,14 +2144,17 @@ function redactOptional(value, secrets) {
850
2144
  }
851
2145
 
852
2146
  // src/service.ts
853
- import { randomUUID as randomUUID2 } from "crypto";
2147
+ import { createPublicKey, randomUUID as randomUUID3 } from "crypto";
2148
+ var MAX_PROBE_RESULT_AGE_MS = 15 * 60000;
2149
+ var MAX_PROBE_RESULT_FUTURE_MS = 5 * 60000;
2150
+
854
2151
  class UptimeService {
855
2152
  store;
856
2153
  checkRunner;
857
- leaseOwner = `svc_${randomUUID2().replace(/-/g, "").slice(0, 18)}`;
2154
+ leaseOwner = `svc_${randomUUID3().replace(/-/g, "").slice(0, 18)}`;
858
2155
  inFlightChecks = new Set;
859
2156
  constructor(options = {}) {
860
- this.store = options.store ?? new UptimeStore(options);
2157
+ this.store = options.store ?? new UptimeStore({ mode: "local", ...options });
861
2158
  this.checkRunner = options.checkRunner ?? runMonitorCheck;
862
2159
  }
863
2160
  close() {
@@ -887,13 +2184,71 @@ class UptimeService {
887
2184
  summary() {
888
2185
  return this.store.summary();
889
2186
  }
2187
+ createProbe(input) {
2188
+ const store = this.probeStore();
2189
+ const publicKeyPem = input.publicKeyPem ? normalizeProbePublicKeyPem(input.publicKeyPem) : undefined;
2190
+ const keyPair = publicKeyPem ? {
2191
+ publicKeyPem,
2192
+ privateKeyPem: undefined,
2193
+ publicKeyFingerprint: probePublicKeyFingerprint(publicKeyPem)
2194
+ } : generateProbeKeyPair();
2195
+ const probe = store.createProbeIdentity({
2196
+ name: input.name,
2197
+ publicKeyPem: keyPair.publicKeyPem,
2198
+ publicKeyFingerprint: keyPair.publicKeyFingerprint,
2199
+ enabled: input.enabled
2200
+ });
2201
+ return { ...probe, privateKeyPem: keyPair.privateKeyPem };
2202
+ }
2203
+ listProbes(options = {}) {
2204
+ return this.probeStore().listProbeIdentities(options);
2205
+ }
2206
+ getProbe(idOrName) {
2207
+ return this.probeStore().getProbeIdentity(idOrName);
2208
+ }
2209
+ updateProbe(idOrName, input) {
2210
+ return this.probeStore().updateProbeIdentity(idOrName, input);
2211
+ }
2212
+ createProbeCheckJob(input) {
2213
+ return this.probeStore().createProbeCheckJob(input);
2214
+ }
2215
+ getProbeCheckJob(id) {
2216
+ return this.probeStore().getProbeCheckJob(id);
2217
+ }
2218
+ claimProbeCheckJob(input) {
2219
+ return this.probeStore().claimProbeCheckJob(input);
2220
+ }
2221
+ submitProbeResult(input) {
2222
+ const execute = () => this.submitProbeResultInTransaction(input);
2223
+ return this.store.runInTransaction ? this.store.runInTransaction(execute) : execute();
2224
+ }
2225
+ previewImport(request) {
2226
+ return previewImport(this.store, request);
2227
+ }
2228
+ applyImport(request) {
2229
+ return applyImport(this.store, request);
2230
+ }
2231
+ rollbackImport(batchId) {
2232
+ return rollbackImport(this.store, batchId);
2233
+ }
2234
+ backup(destinationPath) {
2235
+ return this.store.backup(destinationPath);
2236
+ }
2237
+ verifyBackup(backupPath) {
2238
+ return this.store.verifyBackup(backupPath);
2239
+ }
890
2240
  buildReport(options = {}) {
891
2241
  return buildUptimeReport(this.summary(), options);
892
2242
  }
893
2243
  async sendReport(options = {}) {
2244
+ if (this.store.mode === "hosted" && (options.email || options.sms || options.logs)) {
2245
+ throw new Error("hosted report delivery requires configured channel refs");
2246
+ }
894
2247
  return sendUptimeReport(this.summary(), options);
895
2248
  }
896
2249
  async checkMonitor(idOrName) {
2250
+ if (this.store.mode === "hosted")
2251
+ throw new Error("hosted checks require check_jobs and probes");
897
2252
  const monitor = this.store.getMonitor(idOrName);
898
2253
  if (!monitor)
899
2254
  throw new Error(`Monitor not found: ${idOrName}`);
@@ -922,6 +2277,7 @@ class UptimeService {
922
2277
  latencyMs: last.latencyMs,
923
2278
  statusCode: last.statusCode ?? null,
924
2279
  error: last.error ?? null,
2280
+ evidence: last.evidence ?? null,
925
2281
  attemptCount,
926
2282
  expectedMonitorRevision: monitor.revision
927
2283
  });
@@ -931,6 +2287,8 @@ class UptimeService {
931
2287
  }
932
2288
  }
933
2289
  async checkAll() {
2290
+ if (this.store.mode === "hosted")
2291
+ throw new Error("hosted checks require check_jobs and probes");
934
2292
  const monitors = this.store.listMonitors();
935
2293
  const results = [];
936
2294
  for (const monitor of monitors) {
@@ -939,6 +2297,8 @@ class UptimeService {
939
2297
  return results;
940
2298
  }
941
2299
  startScheduler(options = {}) {
2300
+ if (this.store.mode === "hosted")
2301
+ throw new Error("hosted scheduler requires check_jobs and probes");
942
2302
  const tickMs = options.tickMs ?? 1000;
943
2303
  const timer = setInterval(() => {
944
2304
  this.runDueChecks().catch((error) => {
@@ -950,6 +2310,8 @@ class UptimeService {
950
2310
  };
951
2311
  }
952
2312
  async runDueChecks(now = new Date) {
2313
+ if (this.store.mode === "hosted")
2314
+ throw new Error("hosted checks require check_jobs and probes");
953
2315
  const due = this.store.listMonitors().filter((monitor) => this.isDue(monitor, now));
954
2316
  const results = [];
955
2317
  for (const monitor of due) {
@@ -976,9 +2338,116 @@ class UptimeService {
976
2338
  const last = new Date(monitor.lastCheckedAt).getTime();
977
2339
  return now.getTime() - last >= monitor.intervalSeconds * 1000;
978
2340
  }
2341
+ probeStore() {
2342
+ if (this.store.mode === "hosted") {
2343
+ throw new Error("hosted probe APIs require cloud check_jobs, workspace stores, and audit logging");
2344
+ }
2345
+ const store = this.store;
2346
+ const required = [
2347
+ "createProbeIdentity",
2348
+ "listProbeIdentities",
2349
+ "getProbeIdentity",
2350
+ "updateProbeIdentity",
2351
+ "touchProbeIdentity",
2352
+ "createProbeCheckJob",
2353
+ "getProbeCheckJob",
2354
+ "claimProbeCheckJob",
2355
+ "completeProbeCheckJob",
2356
+ "getProbeSubmission",
2357
+ "recordProbeSubmission"
2358
+ ];
2359
+ for (const method of required) {
2360
+ if (typeof store[method] !== "function") {
2361
+ throw new Error("probe support requires a probe-capable store");
2362
+ }
2363
+ }
2364
+ return store;
2365
+ }
2366
+ submitProbeResultInTransaction(input) {
2367
+ const store = this.probeStore();
2368
+ const probe = store.getProbeIdentity(input.probeId);
2369
+ if (!probe)
2370
+ throw new Error(`Probe not found: ${input.probeId}`);
2371
+ if (!probe.enabled)
2372
+ throw new Error(`Probe is disabled: ${probe.name}`);
2373
+ const monitor = this.store.getMonitor(input.monitorId);
2374
+ if (!monitor)
2375
+ throw new Error(`Monitor not found: ${input.monitorId}`);
2376
+ if (!monitor.enabled)
2377
+ throw new Error(`Monitor is disabled: ${monitor.name}`);
2378
+ if (probe.id !== input.probeId)
2379
+ throw new Error("Probe result must use canonical probe id");
2380
+ if (monitor.id !== input.monitorId)
2381
+ throw new Error("Probe result must use canonical monitor id");
2382
+ validateProbeSubmission(input);
2383
+ const job = store.getProbeCheckJob(input.jobId);
2384
+ if (!job)
2385
+ throw new Error(`Probe job not found: ${input.jobId}`);
2386
+ if (job.monitorId !== monitor.id)
2387
+ throw new Error("Probe job does not match monitor");
2388
+ if (job.scheduleSlot !== input.scheduleSlot)
2389
+ throw new Error("Probe job scheduleSlot does not match submission");
2390
+ if (!verifyProbeResultSignature({ ...input, probeId: probe.id, monitorId: monitor.id }, probe.publicKeyPem)) {
2391
+ throw new Error("Probe result signature is invalid");
2392
+ }
2393
+ const existingReceipt = store.getProbeSubmission(probe.id, input.nonce);
2394
+ if (existingReceipt) {
2395
+ if (existingReceipt.jobId !== input.jobId || existingReceipt.monitorId !== monitor.id || existingReceipt.checkedAt !== input.checkedAt) {
2396
+ throw new Error("Probe nonce already submitted");
2397
+ }
2398
+ const existingResult = this.store.getCheckResult?.(existingReceipt.checkResultId);
2399
+ if (!existingResult)
2400
+ throw new Error("Probe nonce already submitted");
2401
+ return { result: existingResult, receipt: existingReceipt };
2402
+ }
2403
+ if (job.monitorRevision !== input.monitorRevision)
2404
+ throw new Error("Probe job monitorRevision does not match submission");
2405
+ if (job.monitorRevision !== monitor.revision)
2406
+ throw new StaleCheckResultError(`Monitor changed since probe job was created: ${monitor.name}`);
2407
+ if (job.status === "submitted")
2408
+ throw new Error("Probe job already submitted");
2409
+ if (job.status === "cancelled")
2410
+ throw new Error("Probe job is cancelled");
2411
+ if (job.status !== "claimed")
2412
+ throw new Error(`Probe job is not claimable for submission: ${job.status}`);
2413
+ if (job.claimedByProbeId !== probe.id)
2414
+ throw new Error("Probe job was claimed by another probe");
2415
+ if (job.fencingToken !== input.fencingToken)
2416
+ throw new Error("Probe job fencing token is invalid");
2417
+ if (!job.leaseExpiresAt || job.leaseExpiresAt <= new Date().toISOString())
2418
+ throw new Error("Probe job lease expired");
2419
+ const result = this.store.recordCheckResult({
2420
+ monitorId: monitor.id,
2421
+ checkedAt: input.checkedAt,
2422
+ status: input.status,
2423
+ latencyMs: input.latencyMs,
2424
+ statusCode: input.statusCode ?? null,
2425
+ error: input.error ?? null,
2426
+ evidence: input.evidence ?? null,
2427
+ attemptCount: input.attemptCount ?? 1,
2428
+ expectedMonitorRevision: input.monitorRevision
2429
+ });
2430
+ const receipt = store.recordProbeSubmission({
2431
+ probeId: probe.id,
2432
+ jobId: job.id,
2433
+ monitorId: monitor.id,
2434
+ checkResultId: result.id,
2435
+ nonce: input.nonce,
2436
+ checkedAt: input.checkedAt
2437
+ });
2438
+ store.completeProbeCheckJob({
2439
+ jobId: job.id,
2440
+ probeId: probe.id,
2441
+ fencingToken: input.fencingToken,
2442
+ checkResultId: result.id,
2443
+ submittedAt: receipt.submittedAt
2444
+ });
2445
+ store.touchProbeIdentity(probe.id, receipt.submittedAt);
2446
+ return { result, receipt };
2447
+ }
979
2448
  }
980
2449
  function createUptimeClient(options = {}) {
981
- return new UptimeService(options);
2450
+ return new UptimeService({ mode: "local", ...options });
982
2451
  }
983
2452
 
984
2453
  class MonitorCheckBusyError extends Error {
@@ -987,6 +2456,58 @@ class MonitorCheckBusyError extends Error {
987
2456
  this.name = "MonitorCheckBusyError";
988
2457
  }
989
2458
  }
2459
+ function validateProbeSubmission(input) {
2460
+ if (!input.jobId.trim())
2461
+ throw new Error("Probe submission jobId is required");
2462
+ if (!input.scheduleSlot.trim())
2463
+ throw new Error("Probe submission scheduleSlot is required");
2464
+ if (!input.fencingToken.trim())
2465
+ throw new Error("Probe submission fencingToken is required");
2466
+ if (!input.nonce.trim())
2467
+ throw new Error("Probe submission nonce is required");
2468
+ if (input.nonce.length > 128)
2469
+ throw new Error("Probe submission nonce is too long");
2470
+ if (/[\x00-\x1f\x7f-\x9f]/.test(input.nonce))
2471
+ throw new Error("Probe submission nonce must not contain control characters");
2472
+ if (input.status !== "up" && input.status !== "down")
2473
+ throw new Error("Probe result status must be up or down");
2474
+ if (input.latencyMs !== null && (!Number.isFinite(input.latencyMs) || input.latencyMs < 0)) {
2475
+ throw new Error("Probe result latencyMs must be null or a non-negative number");
2476
+ }
2477
+ if (input.statusCode !== undefined && input.statusCode !== null && (!Number.isInteger(input.statusCode) || input.statusCode < 100 || input.statusCode > 599)) {
2478
+ throw new Error("Probe result statusCode must be an HTTP status from 100 to 599");
2479
+ }
2480
+ if (input.attemptCount !== undefined && (!Number.isInteger(input.attemptCount) || input.attemptCount < 1 || input.attemptCount > 20)) {
2481
+ throw new Error("Probe result attemptCount must be an integer from 1 to 20");
2482
+ }
2483
+ const monitorRevision = input.monitorRevision;
2484
+ if (!Number.isInteger(monitorRevision) || monitorRevision < 1) {
2485
+ throw new Error("Probe result monitorRevision is required");
2486
+ }
2487
+ const checkedAtMs = Date.parse(input.checkedAt);
2488
+ if (!Number.isFinite(checkedAtMs))
2489
+ throw new Error("Probe result checkedAt must be an ISO timestamp");
2490
+ const now = Date.now();
2491
+ if (checkedAtMs > now + MAX_PROBE_RESULT_FUTURE_MS)
2492
+ throw new Error("Probe result checkedAt is too far in the future");
2493
+ if (checkedAtMs < now - MAX_PROBE_RESULT_AGE_MS)
2494
+ throw new Error("Probe result checkedAt is too old");
2495
+ if (!input.signature.trim())
2496
+ throw new Error("Probe result signature is required");
2497
+ }
2498
+ function normalizeProbePublicKeyPem(publicKeyPem) {
2499
+ try {
2500
+ const key = createPublicKey(publicKeyPem);
2501
+ if (key.asymmetricKeyType !== "ed25519") {
2502
+ throw new Error("Probe public key must be an Ed25519 public key");
2503
+ }
2504
+ return key.export({ format: "pem", type: "spki" }).toString();
2505
+ } catch (error) {
2506
+ if (error instanceof Error && error.message.includes("Ed25519"))
2507
+ throw error;
2508
+ throw new Error("Probe public key must be a valid PEM Ed25519 public key");
2509
+ }
2510
+ }
990
2511
  export {
991
2512
  createUptimeClient,
992
2513
  UptimeService,