@hasna/uptime 0.1.2 → 0.1.4
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/CHANGELOG.md +50 -0
- package/README.md +72 -1
- package/dist/api.d.ts +12 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +2427 -123
- package/dist/checks.d.ts +23 -1
- package/dist/checks.d.ts.map +1 -1
- package/dist/checks.js +131 -2
- package/dist/cli/index.js +2772 -115
- package/dist/dashboard.js +1 -1
- package/dist/imports.d.ts +90 -0
- package/dist/imports.d.ts.map +1 -0
- package/dist/imports.js +556 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2437 -123
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +2307 -49
- package/dist/paths.d.ts +1 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +4 -0
- package/dist/probes.d.ts +13 -0
- package/dist/probes.d.ts.map +1 -0
- package/dist/probes.js +62 -0
- package/dist/report.d.ts +2 -7
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +1 -1
- package/dist/service.d.ts +152 -4
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +2154 -60
- package/dist/store.d.ts +130 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1086 -18
- package/dist/target-policy.d.ts +7 -0
- package/dist/target-policy.d.ts.map +1 -0
- package/dist/types.d.ts +189 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -2
package/dist/index.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
|
-
|
|
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,30 @@ 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 = [
|
|
829
|
+
"schema_migrations",
|
|
830
|
+
"monitors",
|
|
831
|
+
"check_results",
|
|
832
|
+
"incidents",
|
|
833
|
+
"check_leases",
|
|
834
|
+
"monitor_provenance",
|
|
835
|
+
"import_batches",
|
|
836
|
+
"probe_identities",
|
|
837
|
+
"probe_check_jobs",
|
|
838
|
+
"probe_submissions",
|
|
839
|
+
"report_schedules",
|
|
840
|
+
"report_runs",
|
|
841
|
+
"audit_events"
|
|
842
|
+
];
|
|
843
|
+
var PROBE_TABLES = new Set(["probe_identities", "probe_check_jobs", "probe_submissions"]);
|
|
844
|
+
var REPORT_AUDIT_TABLES = new Set(["report_schedules", "report_runs", "audit_events"]);
|
|
845
|
+
var CURRENT_SCHEMA_VERSION = "3";
|
|
90
846
|
|
|
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
847
|
class StaleCheckResultError extends Error {
|
|
102
848
|
constructor(message) {
|
|
103
849
|
super(message);
|
|
@@ -107,9 +853,20 @@ class StaleCheckResultError extends Error {
|
|
|
107
853
|
|
|
108
854
|
class UptimeStore {
|
|
109
855
|
dbPath;
|
|
856
|
+
mode;
|
|
857
|
+
dataMode;
|
|
110
858
|
db;
|
|
111
859
|
constructor(options = {}) {
|
|
112
|
-
this.
|
|
860
|
+
this.mode = resolveRuntimeMode(options.mode ?? "local");
|
|
861
|
+
const cloudDatabaseUrl = options.cloudDatabaseUrl ?? process.env.HASNA_UPTIME_DATABASE_URL;
|
|
862
|
+
if (this.mode === "hosted" && cloudDatabaseUrl) {
|
|
863
|
+
throw new Error("hosted cloud database adapter is not implemented yet");
|
|
864
|
+
}
|
|
865
|
+
if (this.mode === "hosted" && !allowHostedLocalStore(options.allowHostedLocalStore)) {
|
|
866
|
+
throw new Error("hosted mode requires a cloud data layer; set HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE=1 only for explicit local fallback testing");
|
|
867
|
+
}
|
|
868
|
+
this.dataMode = this.mode === "hosted" ? "hosted-local-sqlite" : "local-sqlite";
|
|
869
|
+
this.dbPath = options.dbPath ?? (this.mode === "hosted" ? uptimeHostedFallbackDbPath() : uptimeDbPath());
|
|
113
870
|
if (this.dbPath !== ":memory:") {
|
|
114
871
|
mkdirSync2(dirname(this.dbPath), { recursive: true });
|
|
115
872
|
}
|
|
@@ -126,7 +883,7 @@ class UptimeStore {
|
|
|
126
883
|
CREATE TABLE IF NOT EXISTS monitors (
|
|
127
884
|
id TEXT PRIMARY KEY,
|
|
128
885
|
name TEXT NOT NULL UNIQUE,
|
|
129
|
-
kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp')),
|
|
886
|
+
kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
|
|
130
887
|
url TEXT,
|
|
131
888
|
host TEXT,
|
|
132
889
|
port INTEGER,
|
|
@@ -144,6 +901,7 @@ class UptimeStore {
|
|
|
144
901
|
)
|
|
145
902
|
`);
|
|
146
903
|
this.ensureColumn("monitors", "revision", "INTEGER NOT NULL DEFAULT 1");
|
|
904
|
+
this.ensureMonitorKindAllowsBrowserPage();
|
|
147
905
|
this.db.run(`
|
|
148
906
|
CREATE TABLE IF NOT EXISTS check_results (
|
|
149
907
|
id TEXT PRIMARY KEY,
|
|
@@ -153,9 +911,11 @@ class UptimeStore {
|
|
|
153
911
|
latency_ms REAL,
|
|
154
912
|
status_code INTEGER,
|
|
155
913
|
error TEXT,
|
|
156
|
-
attempt_count INTEGER NOT NULL DEFAULT 1
|
|
914
|
+
attempt_count INTEGER NOT NULL DEFAULT 1,
|
|
915
|
+
evidence_json TEXT
|
|
157
916
|
)
|
|
158
917
|
`);
|
|
918
|
+
this.ensureColumn("check_results", "evidence_json", "TEXT");
|
|
159
919
|
this.db.run(`
|
|
160
920
|
CREATE TABLE IF NOT EXISTS incidents (
|
|
161
921
|
id TEXT PRIMARY KEY,
|
|
@@ -169,6 +929,71 @@ class UptimeStore {
|
|
|
169
929
|
reason TEXT
|
|
170
930
|
)
|
|
171
931
|
`);
|
|
932
|
+
this.db.run(`
|
|
933
|
+
CREATE TABLE IF NOT EXISTS monitor_provenance (
|
|
934
|
+
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
935
|
+
source TEXT NOT NULL,
|
|
936
|
+
source_id TEXT NOT NULL,
|
|
937
|
+
source_label TEXT,
|
|
938
|
+
imported_at TEXT NOT NULL,
|
|
939
|
+
snapshot_json TEXT NOT NULL,
|
|
940
|
+
PRIMARY KEY (source, source_id)
|
|
941
|
+
)
|
|
942
|
+
`);
|
|
943
|
+
this.db.run(`
|
|
944
|
+
CREATE TABLE IF NOT EXISTS import_batches (
|
|
945
|
+
id TEXT PRIMARY KEY,
|
|
946
|
+
source TEXT NOT NULL,
|
|
947
|
+
status TEXT NOT NULL CHECK (status IN ('applied', 'rolled_back')),
|
|
948
|
+
created_at TEXT NOT NULL,
|
|
949
|
+
rolled_back_at TEXT,
|
|
950
|
+
records_json TEXT NOT NULL
|
|
951
|
+
)
|
|
952
|
+
`);
|
|
953
|
+
this.db.run(`
|
|
954
|
+
CREATE TABLE IF NOT EXISTS probe_identities (
|
|
955
|
+
id TEXT PRIMARY KEY,
|
|
956
|
+
name TEXT NOT NULL UNIQUE,
|
|
957
|
+
public_key_pem TEXT NOT NULL,
|
|
958
|
+
public_key_fingerprint TEXT NOT NULL UNIQUE,
|
|
959
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
960
|
+
created_at TEXT NOT NULL,
|
|
961
|
+
last_seen_at TEXT
|
|
962
|
+
)
|
|
963
|
+
`);
|
|
964
|
+
this.db.run(`
|
|
965
|
+
CREATE TABLE IF NOT EXISTS probe_submissions (
|
|
966
|
+
id TEXT PRIMARY KEY,
|
|
967
|
+
probe_id TEXT NOT NULL REFERENCES probe_identities(id) ON DELETE CASCADE,
|
|
968
|
+
job_id TEXT NOT NULL,
|
|
969
|
+
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
970
|
+
check_result_id TEXT NOT NULL REFERENCES check_results(id) ON DELETE CASCADE,
|
|
971
|
+
nonce TEXT NOT NULL,
|
|
972
|
+
checked_at TEXT NOT NULL,
|
|
973
|
+
submitted_at TEXT NOT NULL,
|
|
974
|
+
UNIQUE (probe_id, nonce)
|
|
975
|
+
)
|
|
976
|
+
`);
|
|
977
|
+
this.ensureColumn("probe_submissions", "job_id", "TEXT");
|
|
978
|
+
this.db.run(`
|
|
979
|
+
CREATE TABLE IF NOT EXISTS probe_check_jobs (
|
|
980
|
+
id TEXT PRIMARY KEY,
|
|
981
|
+
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
982
|
+
monitor_revision INTEGER NOT NULL DEFAULT 1,
|
|
983
|
+
schedule_slot TEXT NOT NULL,
|
|
984
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'claimed', 'submitted', 'expired', 'cancelled')),
|
|
985
|
+
claimed_by_probe_id TEXT REFERENCES probe_identities(id) ON DELETE SET NULL,
|
|
986
|
+
fencing_token TEXT,
|
|
987
|
+
due_at TEXT NOT NULL,
|
|
988
|
+
claimed_at TEXT,
|
|
989
|
+
lease_expires_at TEXT,
|
|
990
|
+
submitted_result_id TEXT REFERENCES check_results(id) ON DELETE SET NULL,
|
|
991
|
+
created_at TEXT NOT NULL,
|
|
992
|
+
updated_at TEXT NOT NULL,
|
|
993
|
+
UNIQUE (monitor_id, schedule_slot)
|
|
994
|
+
)
|
|
995
|
+
`);
|
|
996
|
+
this.ensureColumn("probe_check_jobs", "monitor_revision", "INTEGER NOT NULL DEFAULT 1");
|
|
172
997
|
this.db.run(`
|
|
173
998
|
CREATE TABLE IF NOT EXISTS check_leases (
|
|
174
999
|
monitor_id TEXT PRIMARY KEY REFERENCES monitors(id) ON DELETE CASCADE,
|
|
@@ -177,12 +1002,113 @@ class UptimeStore {
|
|
|
177
1002
|
acquired_at TEXT NOT NULL
|
|
178
1003
|
)
|
|
179
1004
|
`);
|
|
1005
|
+
this.db.run(`
|
|
1006
|
+
CREATE TABLE IF NOT EXISTS report_schedules (
|
|
1007
|
+
id TEXT PRIMARY KEY,
|
|
1008
|
+
name TEXT NOT NULL UNIQUE,
|
|
1009
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
1010
|
+
interval_seconds INTEGER NOT NULL,
|
|
1011
|
+
next_run_at TEXT NOT NULL,
|
|
1012
|
+
last_run_at TEXT,
|
|
1013
|
+
subject TEXT,
|
|
1014
|
+
channels_json TEXT NOT NULL,
|
|
1015
|
+
created_at TEXT NOT NULL,
|
|
1016
|
+
updated_at TEXT NOT NULL
|
|
1017
|
+
)
|
|
1018
|
+
`);
|
|
1019
|
+
this.db.run(`
|
|
1020
|
+
CREATE TABLE IF NOT EXISTS report_runs (
|
|
1021
|
+
id TEXT PRIMARY KEY,
|
|
1022
|
+
schedule_id TEXT REFERENCES report_schedules(id) ON DELETE SET NULL,
|
|
1023
|
+
status TEXT NOT NULL CHECK (status IN ('success', 'failed')),
|
|
1024
|
+
started_at TEXT NOT NULL,
|
|
1025
|
+
finished_at TEXT NOT NULL,
|
|
1026
|
+
deliveries_json TEXT NOT NULL,
|
|
1027
|
+
error TEXT,
|
|
1028
|
+
report_json TEXT
|
|
1029
|
+
)
|
|
1030
|
+
`);
|
|
1031
|
+
this.db.run(`
|
|
1032
|
+
CREATE TABLE IF NOT EXISTS audit_events (
|
|
1033
|
+
id TEXT PRIMARY KEY,
|
|
1034
|
+
action TEXT NOT NULL,
|
|
1035
|
+
resource_type TEXT,
|
|
1036
|
+
resource_id TEXT,
|
|
1037
|
+
message TEXT,
|
|
1038
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
1039
|
+
actor TEXT,
|
|
1040
|
+
created_at TEXT NOT NULL
|
|
1041
|
+
)
|
|
1042
|
+
`);
|
|
1043
|
+
this.db.run(`
|
|
1044
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
1045
|
+
key TEXT PRIMARY KEY,
|
|
1046
|
+
value TEXT NOT NULL,
|
|
1047
|
+
updated_at TEXT NOT NULL
|
|
1048
|
+
)
|
|
1049
|
+
`);
|
|
1050
|
+
this.db.query("INSERT OR REPLACE INTO schema_migrations (key, value, updated_at) VALUES ('schema_version', ?, ?)").run(CURRENT_SCHEMA_VERSION, new Date().toISOString());
|
|
180
1051
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_results_monitor_time ON check_results(monitor_id, checked_at DESC)");
|
|
181
1052
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents(monitor_id, status)");
|
|
182
1053
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_check_leases_until ON check_leases(leased_until)");
|
|
1054
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_monitor_provenance_monitor ON monitor_provenance(monitor_id)");
|
|
1055
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_status_due ON probe_check_jobs(status, due_at)");
|
|
1056
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_probe_status ON probe_check_jobs(claimed_by_probe_id, status)");
|
|
1057
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_probe_time ON probe_submissions(probe_id, submitted_at DESC)");
|
|
1058
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_monitor_time ON probe_submissions(monitor_id, checked_at DESC)");
|
|
1059
|
+
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 != ''");
|
|
1060
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_report_schedules_due ON report_schedules(enabled, next_run_at)");
|
|
1061
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_report_runs_schedule_time ON report_runs(schedule_id, started_at DESC)");
|
|
1062
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_audit_events_resource_time ON audit_events(resource_type, resource_id, created_at DESC)");
|
|
1063
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_audit_events_time ON audit_events(created_at DESC)");
|
|
183
1064
|
}
|
|
184
|
-
|
|
185
|
-
|
|
1065
|
+
backup(destinationPath) {
|
|
1066
|
+
if (this.dbPath === ":memory:" && !destinationPath) {
|
|
1067
|
+
throw new Error("backup path is required for in-memory stores");
|
|
1068
|
+
}
|
|
1069
|
+
const createdAt = new Date().toISOString();
|
|
1070
|
+
const backupPath = destinationPath ?? join2(dirname(this.dbPath), "backups", `uptime-${createdAt.replace(/[:.]/g, "-")}.db`);
|
|
1071
|
+
mkdirSync2(dirname(backupPath), { recursive: true });
|
|
1072
|
+
if (this.dbPath === ":memory:") {
|
|
1073
|
+
this.vacuumInto(backupPath);
|
|
1074
|
+
} else {
|
|
1075
|
+
this.db.run("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
1076
|
+
copyFileSync(this.dbPath, backupPath);
|
|
1077
|
+
}
|
|
1078
|
+
const bytes = statSync(backupPath).size;
|
|
1079
|
+
return { sourcePath: this.dbPath, backupPath, bytes, createdAt };
|
|
1080
|
+
}
|
|
1081
|
+
verifyBackup(backupPath) {
|
|
1082
|
+
return verifyBackupFile(backupPath);
|
|
1083
|
+
}
|
|
1084
|
+
static verifyBackup(backupPath) {
|
|
1085
|
+
return verifyBackupFile(backupPath);
|
|
1086
|
+
}
|
|
1087
|
+
static restoreBackup(backupPath, destinationPath = uptimeDbPath()) {
|
|
1088
|
+
const check = verifyBackupFile(backupPath);
|
|
1089
|
+
if (!check.ok)
|
|
1090
|
+
throw new Error(`backup integrity check failed: ${check.integrity}`);
|
|
1091
|
+
if (destinationPath === ":memory:")
|
|
1092
|
+
throw new Error("cannot restore a backup to an in-memory store");
|
|
1093
|
+
if (existsSync(destinationPath) || existsSync(`${destinationPath}-wal`) || existsSync(`${destinationPath}-shm`)) {
|
|
1094
|
+
throw new Error("restore destination already exists or has SQLite sidecar files");
|
|
1095
|
+
}
|
|
1096
|
+
mkdirSync2(dirname(destinationPath), { recursive: true });
|
|
1097
|
+
copyFileSync(backupPath, destinationPath);
|
|
1098
|
+
const bytes = statSync(destinationPath).size;
|
|
1099
|
+
return {
|
|
1100
|
+
sourcePath: backupPath,
|
|
1101
|
+
backupPath: destinationPath,
|
|
1102
|
+
bytes,
|
|
1103
|
+
createdAt: new Date().toISOString()
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
createMonitor(input, options = {}) {
|
|
1107
|
+
if (this.mode === "hosted")
|
|
1108
|
+
assertHostedTargetAllowed(input);
|
|
1109
|
+
const normalized = normalizeCreateMonitor(input, options.allowBrowserPage === true);
|
|
1110
|
+
if (this.mode === "hosted")
|
|
1111
|
+
assertHostedTargetAllowed(normalized);
|
|
186
1112
|
const now = new Date().toISOString();
|
|
187
1113
|
const monitor = {
|
|
188
1114
|
id: newId("mon"),
|
|
@@ -218,12 +1144,22 @@ class UptimeStore {
|
|
|
218
1144
|
const row = this.db.query("SELECT * FROM monitors WHERE id = ? OR name = ?").get(idOrName, idOrName);
|
|
219
1145
|
return row ? monitorFromRow(row) : null;
|
|
220
1146
|
}
|
|
221
|
-
updateMonitor(idOrName, input) {
|
|
1147
|
+
updateMonitor(idOrName, input, options = {}) {
|
|
222
1148
|
const current = this.getMonitor(idOrName);
|
|
223
1149
|
if (!current)
|
|
224
1150
|
throw new Error(`Monitor not found: ${idOrName}`);
|
|
1151
|
+
if (this.mode === "hosted") {
|
|
1152
|
+
assertHostedTargetAllowed({
|
|
1153
|
+
kind: input.kind ?? current.kind,
|
|
1154
|
+
url: input.url ?? current.url ?? undefined,
|
|
1155
|
+
host: input.host ?? current.host ?? undefined,
|
|
1156
|
+
port: input.port ?? current.port ?? undefined
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
225
1159
|
const updatedAt = new Date().toISOString();
|
|
226
|
-
const next = normalizeUpdateMonitor(current, input, updatedAt);
|
|
1160
|
+
const next = normalizeUpdateMonitor(current, input, updatedAt, options.allowBrowserPage === true);
|
|
1161
|
+
if (this.mode === "hosted")
|
|
1162
|
+
assertHostedTargetAllowed(next);
|
|
227
1163
|
this.db.query(`UPDATE monitors SET
|
|
228
1164
|
name = ?, kind = ?, url = ?, host = ?, port = ?, method = ?,
|
|
229
1165
|
expected_status = ?, interval_seconds = ?, timeout_ms = ?,
|
|
@@ -242,6 +1178,315 @@ class UptimeStore {
|
|
|
242
1178
|
this.db.query("DELETE FROM monitors WHERE id = ?").run(current.id);
|
|
243
1179
|
return true;
|
|
244
1180
|
}
|
|
1181
|
+
createProbeIdentity(input) {
|
|
1182
|
+
const name = input.name.trim();
|
|
1183
|
+
if (!name)
|
|
1184
|
+
throw new Error("Probe name is required");
|
|
1185
|
+
rejectControlCharacters2(name, "Probe name");
|
|
1186
|
+
const now = new Date().toISOString();
|
|
1187
|
+
const probe = {
|
|
1188
|
+
id: newId("prb"),
|
|
1189
|
+
name,
|
|
1190
|
+
publicKeyPem: input.publicKeyPem.trim(),
|
|
1191
|
+
publicKeyFingerprint: input.publicKeyFingerprint,
|
|
1192
|
+
enabled: input.enabled ?? true,
|
|
1193
|
+
createdAt: now,
|
|
1194
|
+
lastSeenAt: null
|
|
1195
|
+
};
|
|
1196
|
+
if (!probe.publicKeyPem)
|
|
1197
|
+
throw new Error("Probe public key is required");
|
|
1198
|
+
this.db.query(`INSERT INTO probe_identities (
|
|
1199
|
+
id, name, public_key_pem, public_key_fingerprint, enabled, created_at, last_seen_at
|
|
1200
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(probe.id, probe.name, probe.publicKeyPem, probe.publicKeyFingerprint, probe.enabled ? 1 : 0, probe.createdAt, probe.lastSeenAt);
|
|
1201
|
+
return probe;
|
|
1202
|
+
}
|
|
1203
|
+
listProbeIdentities(options = {}) {
|
|
1204
|
+
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();
|
|
1205
|
+
return rows.map(probeIdentityFromRow);
|
|
1206
|
+
}
|
|
1207
|
+
getProbeIdentity(idOrName) {
|
|
1208
|
+
const row = this.db.query("SELECT * FROM probe_identities WHERE id = ? OR name = ?").get(idOrName, idOrName);
|
|
1209
|
+
return row ? probeIdentityFromRow(row) : null;
|
|
1210
|
+
}
|
|
1211
|
+
updateProbeIdentity(idOrName, input) {
|
|
1212
|
+
const current = this.getProbeIdentity(idOrName);
|
|
1213
|
+
if (!current)
|
|
1214
|
+
throw new Error(`Probe not found: ${idOrName}`);
|
|
1215
|
+
const name = input.name === undefined ? current.name : input.name.trim();
|
|
1216
|
+
if (!name)
|
|
1217
|
+
throw new Error("Probe name is required");
|
|
1218
|
+
rejectControlCharacters2(name, "Probe name");
|
|
1219
|
+
const enabled = input.enabled ?? current.enabled;
|
|
1220
|
+
this.db.query("UPDATE probe_identities SET name = ?, enabled = ? WHERE id = ?").run(name, enabled ? 1 : 0, current.id);
|
|
1221
|
+
return this.getProbeIdentity(current.id);
|
|
1222
|
+
}
|
|
1223
|
+
touchProbeIdentity(idOrName, seenAt = new Date().toISOString()) {
|
|
1224
|
+
const probe = this.getProbeIdentity(idOrName);
|
|
1225
|
+
if (!probe)
|
|
1226
|
+
throw new Error(`Probe not found: ${idOrName}`);
|
|
1227
|
+
this.db.query("UPDATE probe_identities SET last_seen_at = ? WHERE id = ?").run(seenAt, probe.id);
|
|
1228
|
+
}
|
|
1229
|
+
createProbeCheckJob(input) {
|
|
1230
|
+
const monitor = this.getMonitor(input.monitorId);
|
|
1231
|
+
if (!monitor)
|
|
1232
|
+
throw new Error(`Monitor not found: ${input.monitorId}`);
|
|
1233
|
+
if (!monitor.enabled)
|
|
1234
|
+
throw new Error(`Monitor is disabled: ${monitor.name}`);
|
|
1235
|
+
const scheduleSlot = normalizeScheduleSlot(input.scheduleSlot);
|
|
1236
|
+
const dueAt = input.dueAt ?? new Date().toISOString();
|
|
1237
|
+
assertIsoTimestamp(dueAt, "Probe job dueAt");
|
|
1238
|
+
const now = new Date().toISOString();
|
|
1239
|
+
const existing = this.db.query("SELECT * FROM probe_check_jobs WHERE monitor_id = ? AND schedule_slot = ?").get(monitor.id, scheduleSlot);
|
|
1240
|
+
if (existing)
|
|
1241
|
+
return probeCheckJobFromRow(existing);
|
|
1242
|
+
const job = {
|
|
1243
|
+
id: newId("job"),
|
|
1244
|
+
monitorId: monitor.id,
|
|
1245
|
+
monitorRevision: monitor.revision,
|
|
1246
|
+
scheduleSlot,
|
|
1247
|
+
status: "pending",
|
|
1248
|
+
claimedByProbeId: null,
|
|
1249
|
+
fencingToken: null,
|
|
1250
|
+
dueAt,
|
|
1251
|
+
claimedAt: null,
|
|
1252
|
+
leaseExpiresAt: null,
|
|
1253
|
+
submittedResultId: null,
|
|
1254
|
+
createdAt: now,
|
|
1255
|
+
updatedAt: now
|
|
1256
|
+
};
|
|
1257
|
+
this.db.query(`INSERT INTO probe_check_jobs (
|
|
1258
|
+
id, monitor_id, monitor_revision, schedule_slot, status, claimed_by_probe_id, fencing_token,
|
|
1259
|
+
due_at, claimed_at, lease_expires_at, submitted_result_id, created_at, updated_at
|
|
1260
|
+
) 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);
|
|
1261
|
+
return job;
|
|
1262
|
+
}
|
|
1263
|
+
getProbeCheckJob(id) {
|
|
1264
|
+
const row = this.db.query("SELECT * FROM probe_check_jobs WHERE id = ?").get(id);
|
|
1265
|
+
return row ? probeCheckJobFromRow(row) : null;
|
|
1266
|
+
}
|
|
1267
|
+
claimProbeCheckJob(input) {
|
|
1268
|
+
const tx = this.db.transaction(() => {
|
|
1269
|
+
const probe = this.getProbeIdentity(input.probeId);
|
|
1270
|
+
if (!probe)
|
|
1271
|
+
throw new Error(`Probe not found: ${input.probeId}`);
|
|
1272
|
+
if (!probe.enabled)
|
|
1273
|
+
throw new Error(`Probe is disabled: ${probe.name}`);
|
|
1274
|
+
const current = this.getProbeCheckJob(input.jobId);
|
|
1275
|
+
if (!current)
|
|
1276
|
+
throw new Error(`Probe job not found: ${input.jobId}`);
|
|
1277
|
+
const now = new Date;
|
|
1278
|
+
const nowIso = now.toISOString();
|
|
1279
|
+
if (current.status === "submitted")
|
|
1280
|
+
throw new Error("Probe job already submitted");
|
|
1281
|
+
if (current.status === "cancelled")
|
|
1282
|
+
throw new Error("Probe job is cancelled");
|
|
1283
|
+
if (current.dueAt > nowIso)
|
|
1284
|
+
throw new Error("Probe job is not due yet");
|
|
1285
|
+
const leaseExpired = Boolean(current.leaseExpiresAt && current.leaseExpiresAt <= nowIso);
|
|
1286
|
+
if (current.status === "claimed" && !leaseExpired && current.claimedByProbeId !== probe.id) {
|
|
1287
|
+
throw new Error("Probe job already claimed by another probe");
|
|
1288
|
+
}
|
|
1289
|
+
if (current.status !== "pending" && current.status !== "claimed" && current.status !== "expired") {
|
|
1290
|
+
throw new Error(`Probe job is not claimable: ${current.status}`);
|
|
1291
|
+
}
|
|
1292
|
+
const leaseExpiresAt = new Date(now.getTime() + Math.max(1000, input.leaseTtlMs ?? 120000)).toISOString();
|
|
1293
|
+
const fencingToken = newId("fence");
|
|
1294
|
+
const update = this.db.query(`UPDATE probe_check_jobs
|
|
1295
|
+
SET status = 'claimed', claimed_by_probe_id = ?, fencing_token = ?, claimed_at = ?, lease_expires_at = ?, updated_at = ?
|
|
1296
|
+
WHERE id = ?
|
|
1297
|
+
AND submitted_result_id IS NULL
|
|
1298
|
+
AND (
|
|
1299
|
+
status IN ('pending', 'expired')
|
|
1300
|
+
OR (status = 'claimed' AND (claimed_by_probe_id = ? OR lease_expires_at <= ?))
|
|
1301
|
+
)`).run(probe.id, fencingToken, nowIso, leaseExpiresAt, nowIso, current.id, probe.id, nowIso);
|
|
1302
|
+
if (statementChanges(update) !== 1)
|
|
1303
|
+
throw new Error("Probe job claim raced; retry");
|
|
1304
|
+
this.touchProbeIdentity(probe.id, nowIso);
|
|
1305
|
+
return this.getProbeCheckJob(current.id);
|
|
1306
|
+
});
|
|
1307
|
+
return tx();
|
|
1308
|
+
}
|
|
1309
|
+
completeProbeCheckJob(input) {
|
|
1310
|
+
const job = this.getProbeCheckJob(input.jobId);
|
|
1311
|
+
if (!job)
|
|
1312
|
+
throw new Error(`Probe job not found: ${input.jobId}`);
|
|
1313
|
+
const submittedAt = input.submittedAt ?? new Date().toISOString();
|
|
1314
|
+
if (job.status !== "claimed")
|
|
1315
|
+
throw new Error(`Probe job is not claimable for submission: ${job.status}`);
|
|
1316
|
+
if (job.claimedByProbeId !== input.probeId)
|
|
1317
|
+
throw new Error("Probe job was claimed by another probe");
|
|
1318
|
+
if (job.fencingToken !== input.fencingToken)
|
|
1319
|
+
throw new Error("Probe job fencing token is invalid");
|
|
1320
|
+
if (!job.leaseExpiresAt || job.leaseExpiresAt <= submittedAt) {
|
|
1321
|
+
this.expireProbeCheckJob(job.id, submittedAt);
|
|
1322
|
+
throw new Error("Probe job lease expired");
|
|
1323
|
+
}
|
|
1324
|
+
const update = this.db.query(`UPDATE probe_check_jobs
|
|
1325
|
+
SET status = 'submitted', submitted_result_id = ?, updated_at = ?
|
|
1326
|
+
WHERE id = ?
|
|
1327
|
+
AND status = 'claimed'
|
|
1328
|
+
AND claimed_by_probe_id = ?
|
|
1329
|
+
AND fencing_token = ?
|
|
1330
|
+
AND lease_expires_at > ?
|
|
1331
|
+
AND submitted_result_id IS NULL`).run(input.checkResultId, submittedAt, job.id, input.probeId, input.fencingToken, submittedAt);
|
|
1332
|
+
if (statementChanges(update) !== 1)
|
|
1333
|
+
throw new Error("Probe job submission raced; retry");
|
|
1334
|
+
return this.getProbeCheckJob(job.id);
|
|
1335
|
+
}
|
|
1336
|
+
expireProbeCheckJob(jobId, updatedAt = new Date().toISOString()) {
|
|
1337
|
+
this.db.query("UPDATE probe_check_jobs SET status = 'expired', updated_at = ? WHERE id = ? AND status != 'submitted'").run(updatedAt, jobId);
|
|
1338
|
+
}
|
|
1339
|
+
getProbeSubmission(probeId, nonce) {
|
|
1340
|
+
const row = this.db.query("SELECT * FROM probe_submissions WHERE probe_id = ? AND nonce = ?").get(probeId, nonce);
|
|
1341
|
+
return row ? probeSubmissionFromRow(row) : null;
|
|
1342
|
+
}
|
|
1343
|
+
recordProbeSubmission(input) {
|
|
1344
|
+
const submittedAt = input.submittedAt ?? new Date().toISOString();
|
|
1345
|
+
const receipt = {
|
|
1346
|
+
id: newId("psb"),
|
|
1347
|
+
probeId: input.probeId,
|
|
1348
|
+
jobId: input.jobId,
|
|
1349
|
+
monitorId: input.monitorId,
|
|
1350
|
+
checkResultId: input.checkResultId,
|
|
1351
|
+
nonce: input.nonce,
|
|
1352
|
+
checkedAt: input.checkedAt,
|
|
1353
|
+
submittedAt
|
|
1354
|
+
};
|
|
1355
|
+
this.db.query(`INSERT INTO probe_submissions (
|
|
1356
|
+
id, probe_id, job_id, monitor_id, check_result_id, nonce, checked_at, submitted_at
|
|
1357
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(receipt.id, receipt.probeId, receipt.jobId, receipt.monitorId, receipt.checkResultId, receipt.nonce, receipt.checkedAt, receipt.submittedAt);
|
|
1358
|
+
return receipt;
|
|
1359
|
+
}
|
|
1360
|
+
createReportSchedule(input) {
|
|
1361
|
+
const normalized = normalizeReportScheduleInput(input);
|
|
1362
|
+
const now = new Date().toISOString();
|
|
1363
|
+
const schedule = {
|
|
1364
|
+
id: newId("rps"),
|
|
1365
|
+
name: normalized.name,
|
|
1366
|
+
enabled: normalized.enabled,
|
|
1367
|
+
intervalSeconds: normalized.intervalSeconds,
|
|
1368
|
+
nextRunAt: normalized.nextRunAt,
|
|
1369
|
+
lastRunAt: null,
|
|
1370
|
+
subject: normalized.subject,
|
|
1371
|
+
channels: normalized.channels,
|
|
1372
|
+
createdAt: now,
|
|
1373
|
+
updatedAt: now
|
|
1374
|
+
};
|
|
1375
|
+
this.db.query(`INSERT INTO report_schedules (
|
|
1376
|
+
id, name, enabled, interval_seconds, next_run_at, last_run_at,
|
|
1377
|
+
subject, channels_json, created_at, updated_at
|
|
1378
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(schedule.id, schedule.name, schedule.enabled ? 1 : 0, schedule.intervalSeconds, schedule.nextRunAt, schedule.lastRunAt, schedule.subject, JSON.stringify(schedule.channels), schedule.createdAt, schedule.updatedAt);
|
|
1379
|
+
return schedule;
|
|
1380
|
+
}
|
|
1381
|
+
listReportSchedules(options = {}) {
|
|
1382
|
+
const rows = options.includeDisabled ? this.db.query("SELECT * FROM report_schedules ORDER BY name ASC").all() : this.db.query("SELECT * FROM report_schedules WHERE enabled = 1 ORDER BY name ASC").all();
|
|
1383
|
+
return rows.map(reportScheduleFromRow);
|
|
1384
|
+
}
|
|
1385
|
+
listDueReportSchedules(nowIso = new Date().toISOString()) {
|
|
1386
|
+
assertIsoTimestamp(nowIso, "Report schedule due timestamp");
|
|
1387
|
+
const rows = this.db.query("SELECT * FROM report_schedules WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at ASC, name ASC").all(nowIso);
|
|
1388
|
+
return rows.map(reportScheduleFromRow);
|
|
1389
|
+
}
|
|
1390
|
+
getReportSchedule(idOrName) {
|
|
1391
|
+
const row = this.db.query("SELECT * FROM report_schedules WHERE id = ? OR name = ?").get(idOrName, idOrName);
|
|
1392
|
+
return row ? reportScheduleFromRow(row) : null;
|
|
1393
|
+
}
|
|
1394
|
+
updateReportSchedule(idOrName, input) {
|
|
1395
|
+
const current = this.getReportSchedule(idOrName);
|
|
1396
|
+
if (!current)
|
|
1397
|
+
throw new Error(`Report schedule not found: ${idOrName}`);
|
|
1398
|
+
const normalized = normalizeReportScheduleInput({
|
|
1399
|
+
name: input.name ?? current.name,
|
|
1400
|
+
intervalSeconds: input.intervalSeconds ?? current.intervalSeconds,
|
|
1401
|
+
nextRunAt: input.nextRunAt ?? current.nextRunAt,
|
|
1402
|
+
enabled: input.enabled ?? current.enabled,
|
|
1403
|
+
subject: input.subject === undefined ? current.subject : input.subject,
|
|
1404
|
+
channels: input.channels ?? current.channels
|
|
1405
|
+
});
|
|
1406
|
+
const updatedAt = new Date().toISOString();
|
|
1407
|
+
this.db.query(`UPDATE report_schedules SET
|
|
1408
|
+
name = ?, enabled = ?, interval_seconds = ?, next_run_at = ?,
|
|
1409
|
+
subject = ?, channels_json = ?, updated_at = ?
|
|
1410
|
+
WHERE id = ?`).run(normalized.name, normalized.enabled ? 1 : 0, normalized.intervalSeconds, normalized.nextRunAt, normalized.subject, JSON.stringify(normalized.channels), updatedAt, current.id);
|
|
1411
|
+
return this.getReportSchedule(current.id);
|
|
1412
|
+
}
|
|
1413
|
+
deleteReportSchedule(idOrName) {
|
|
1414
|
+
const current = this.getReportSchedule(idOrName);
|
|
1415
|
+
if (!current)
|
|
1416
|
+
return false;
|
|
1417
|
+
this.db.query("DELETE FROM report_schedules WHERE id = ?").run(current.id);
|
|
1418
|
+
return true;
|
|
1419
|
+
}
|
|
1420
|
+
recordReportRun(input) {
|
|
1421
|
+
const startedAt = input.startedAt ?? new Date().toISOString();
|
|
1422
|
+
const finishedAt = input.finishedAt ?? new Date().toISOString();
|
|
1423
|
+
assertIsoTimestamp(startedAt, "Report run startedAt");
|
|
1424
|
+
assertIsoTimestamp(finishedAt, "Report run finishedAt");
|
|
1425
|
+
if (input.status !== "success" && input.status !== "failed") {
|
|
1426
|
+
throw new Error("Report run status must be success or failed");
|
|
1427
|
+
}
|
|
1428
|
+
if (input.scheduleId && !this.getReportSchedule(input.scheduleId)) {
|
|
1429
|
+
throw new Error(`Report schedule not found: ${input.scheduleId}`);
|
|
1430
|
+
}
|
|
1431
|
+
const run = {
|
|
1432
|
+
id: newId("rpr"),
|
|
1433
|
+
scheduleId: input.scheduleId ?? null,
|
|
1434
|
+
status: input.status,
|
|
1435
|
+
startedAt,
|
|
1436
|
+
finishedAt,
|
|
1437
|
+
deliveries: normalizeReportDeliveries(input.deliveries ?? []),
|
|
1438
|
+
error: normalizeNullableRedactedText(input.error, "Report run error", 1000),
|
|
1439
|
+
reportJson: input.reportJson ?? null
|
|
1440
|
+
};
|
|
1441
|
+
this.db.query(`INSERT INTO report_runs (
|
|
1442
|
+
id, schedule_id, status, started_at, finished_at, deliveries_json,
|
|
1443
|
+
error, report_json
|
|
1444
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(run.id, run.scheduleId, run.status, run.startedAt, run.finishedAt, JSON.stringify(run.deliveries), run.error, run.reportJson ? JSON.stringify(run.reportJson) : null);
|
|
1445
|
+
if (run.scheduleId) {
|
|
1446
|
+
this.advanceReportSchedule(run.scheduleId, run.finishedAt);
|
|
1447
|
+
}
|
|
1448
|
+
return run;
|
|
1449
|
+
}
|
|
1450
|
+
listReportRuns(options = {}) {
|
|
1451
|
+
const limit = clampLimit(options.limit ?? 50);
|
|
1452
|
+
const rows = options.scheduleId ? this.db.query("SELECT * FROM report_runs WHERE schedule_id = ? ORDER BY started_at DESC, id DESC LIMIT ?").all(options.scheduleId, limit) : this.db.query("SELECT * FROM report_runs ORDER BY started_at DESC, id DESC LIMIT ?").all(limit);
|
|
1453
|
+
return rows.map(reportRunFromRow);
|
|
1454
|
+
}
|
|
1455
|
+
recordAuditEvent(input) {
|
|
1456
|
+
const action = normalizeAuditText(input.action, "Audit action", 160);
|
|
1457
|
+
const createdAt = input.createdAt ?? new Date().toISOString();
|
|
1458
|
+
assertIsoTimestamp(createdAt, "Audit event createdAt");
|
|
1459
|
+
const event = {
|
|
1460
|
+
id: newId("aud"),
|
|
1461
|
+
action,
|
|
1462
|
+
resourceType: normalizeNullableAuditText(input.resourceType, "Audit resourceType", 80),
|
|
1463
|
+
resourceId: normalizeNullableAuditText(input.resourceId, "Audit resourceId", 160),
|
|
1464
|
+
message: normalizeNullableAuditText(input.message, "Audit message", 500),
|
|
1465
|
+
metadata: normalizeAuditMetadata(input.metadata ?? {}),
|
|
1466
|
+
actor: normalizeNullableAuditText(input.actor, "Audit actor", 160),
|
|
1467
|
+
createdAt
|
|
1468
|
+
};
|
|
1469
|
+
this.db.query(`INSERT INTO audit_events (
|
|
1470
|
+
id, action, resource_type, resource_id, message, metadata_json, actor, created_at
|
|
1471
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(event.id, event.action, event.resourceType, event.resourceId, event.message, JSON.stringify(event.metadata), event.actor, event.createdAt);
|
|
1472
|
+
return event;
|
|
1473
|
+
}
|
|
1474
|
+
listAuditEvents(options = {}) {
|
|
1475
|
+
const clauses = [];
|
|
1476
|
+
const args = [];
|
|
1477
|
+
if (options.resourceType) {
|
|
1478
|
+
clauses.push("resource_type = ?");
|
|
1479
|
+
args.push(options.resourceType);
|
|
1480
|
+
}
|
|
1481
|
+
if (options.resourceId) {
|
|
1482
|
+
clauses.push("resource_id = ?");
|
|
1483
|
+
args.push(options.resourceId);
|
|
1484
|
+
}
|
|
1485
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
1486
|
+
args.push(clampLimit(options.limit ?? 50));
|
|
1487
|
+
const rows = this.db.query(`SELECT * FROM audit_events ${where} ORDER BY created_at DESC, id DESC LIMIT ?`).all(...args);
|
|
1488
|
+
return rows.map(auditEventFromRow);
|
|
1489
|
+
}
|
|
245
1490
|
acquireCheckLease(monitorId, owner, ttlMs) {
|
|
246
1491
|
const now = new Date;
|
|
247
1492
|
const nowIso = now.toISOString();
|
|
@@ -276,7 +1521,8 @@ class UptimeStore {
|
|
|
276
1521
|
latencyMs: input.latencyMs,
|
|
277
1522
|
statusCode: input.statusCode,
|
|
278
1523
|
error: input.error,
|
|
279
|
-
attemptCount: Math.max(1, input.attemptCount)
|
|
1524
|
+
attemptCount: Math.max(1, input.attemptCount),
|
|
1525
|
+
evidence: input.evidence ?? null
|
|
280
1526
|
};
|
|
281
1527
|
const tx = this.db.transaction(() => {
|
|
282
1528
|
const current = this.db.query("SELECT * FROM monitors WHERE id = ?").get(result.monitorId);
|
|
@@ -289,19 +1535,59 @@ class UptimeStore {
|
|
|
289
1535
|
throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${current.name}`);
|
|
290
1536
|
}
|
|
291
1537
|
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);
|
|
1538
|
+
id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count, evidence_json
|
|
1539
|
+
) 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
1540
|
this.db.query("UPDATE monitors SET status = ?, last_checked_at = ?, updated_at = ? WHERE id = ?").run(result.status, result.checkedAt, result.checkedAt, result.monitorId);
|
|
295
1541
|
this.reconcileIncidentInTransaction(result);
|
|
296
1542
|
});
|
|
297
1543
|
tx();
|
|
298
1544
|
return result;
|
|
299
1545
|
}
|
|
1546
|
+
getCheckResult(id) {
|
|
1547
|
+
const row = this.db.query("SELECT * FROM check_results WHERE id = ?").get(id);
|
|
1548
|
+
return row ? checkResultFromRow(row) : null;
|
|
1549
|
+
}
|
|
300
1550
|
listResults(options = {}) {
|
|
301
1551
|
const limit = clampLimit(options.limit ?? 50);
|
|
302
1552
|
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
1553
|
return rows.map(checkResultFromRow);
|
|
304
1554
|
}
|
|
1555
|
+
getProvenance(source, sourceId) {
|
|
1556
|
+
const row = this.db.query("SELECT * FROM monitor_provenance WHERE source = ? AND source_id = ?").get(source, sourceId);
|
|
1557
|
+
return row ? provenanceFromRow(row) : null;
|
|
1558
|
+
}
|
|
1559
|
+
upsertMonitorProvenance(input) {
|
|
1560
|
+
const importedAt = new Date().toISOString();
|
|
1561
|
+
this.db.query(`INSERT INTO monitor_provenance (
|
|
1562
|
+
monitor_id, source, source_id, source_label, imported_at, snapshot_json
|
|
1563
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
1564
|
+
ON CONFLICT(source, source_id) DO UPDATE SET
|
|
1565
|
+
monitor_id = excluded.monitor_id,
|
|
1566
|
+
source_label = excluded.source_label,
|
|
1567
|
+
imported_at = excluded.imported_at,
|
|
1568
|
+
snapshot_json = excluded.snapshot_json`).run(input.monitorId, input.source, input.sourceId, input.sourceLabel ?? null, importedAt, JSON.stringify(input.snapshot));
|
|
1569
|
+
return this.getProvenance(input.source, input.sourceId);
|
|
1570
|
+
}
|
|
1571
|
+
saveImportBatch(input) {
|
|
1572
|
+
const createdAt = new Date().toISOString();
|
|
1573
|
+
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));
|
|
1574
|
+
return this.getImportBatch(input.id);
|
|
1575
|
+
}
|
|
1576
|
+
getImportBatch(batchId) {
|
|
1577
|
+
const row = this.db.query("SELECT * FROM import_batches WHERE id = ?").get(batchId);
|
|
1578
|
+
return row ? importBatchFromRow(row) : null;
|
|
1579
|
+
}
|
|
1580
|
+
markImportBatchRolledBack(batchId) {
|
|
1581
|
+
const rolledBackAt = new Date().toISOString();
|
|
1582
|
+
this.db.query("UPDATE import_batches SET status = 'rolled_back', rolled_back_at = ? WHERE id = ?").run(rolledBackAt, batchId);
|
|
1583
|
+
const batch = this.getImportBatch(batchId);
|
|
1584
|
+
if (!batch)
|
|
1585
|
+
throw new Error(`Import batch not found: ${batchId}`);
|
|
1586
|
+
return batch;
|
|
1587
|
+
}
|
|
1588
|
+
runInTransaction(fn) {
|
|
1589
|
+
return this.db.transaction(fn)();
|
|
1590
|
+
}
|
|
305
1591
|
listIncidents(options = {}) {
|
|
306
1592
|
const clauses = [];
|
|
307
1593
|
const args = [];
|
|
@@ -383,22 +1669,134 @@ class UptimeStore {
|
|
|
383
1669
|
closeOpenIncident(monitorId, closedAt) {
|
|
384
1670
|
this.db.query("UPDATE incidents SET status = 'closed', closed_at = ? WHERE monitor_id = ? AND status = 'open'").run(closedAt, monitorId);
|
|
385
1671
|
}
|
|
1672
|
+
advanceReportSchedule(scheduleId, finishedAt) {
|
|
1673
|
+
const schedule = this.getReportSchedule(scheduleId);
|
|
1674
|
+
if (!schedule)
|
|
1675
|
+
throw new Error(`Report schedule not found: ${scheduleId}`);
|
|
1676
|
+
const finishedMs = Date.parse(finishedAt);
|
|
1677
|
+
let nextMs = Math.max(Date.parse(schedule.nextRunAt), finishedMs);
|
|
1678
|
+
do {
|
|
1679
|
+
nextMs += schedule.intervalSeconds * 1000;
|
|
1680
|
+
} while (nextMs <= finishedMs);
|
|
1681
|
+
const nextRunAt = new Date(nextMs).toISOString();
|
|
1682
|
+
this.db.query("UPDATE report_schedules SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE id = ?").run(finishedAt, nextRunAt, finishedAt, schedule.id);
|
|
1683
|
+
}
|
|
386
1684
|
ensureColumn(table, name, definition) {
|
|
387
1685
|
const columns = this.db.query(`PRAGMA table_info(${table})`).all();
|
|
388
1686
|
if (!columns.some((column) => column.name === name)) {
|
|
389
1687
|
this.db.run(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
|
|
390
1688
|
}
|
|
391
1689
|
}
|
|
1690
|
+
ensureMonitorKindAllowsBrowserPage() {
|
|
1691
|
+
const row = this.db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'monitors'").get();
|
|
1692
|
+
if (!row?.sql || row.sql.includes("browser_page"))
|
|
1693
|
+
return;
|
|
1694
|
+
this.db.run("PRAGMA foreign_keys = OFF");
|
|
1695
|
+
this.db.run("PRAGMA legacy_alter_table = ON");
|
|
1696
|
+
try {
|
|
1697
|
+
const migrate = this.db.transaction(() => {
|
|
1698
|
+
this.db.run("ALTER TABLE monitors RENAME TO monitors_old_kind");
|
|
1699
|
+
this.db.run(`
|
|
1700
|
+
CREATE TABLE monitors (
|
|
1701
|
+
id TEXT PRIMARY KEY,
|
|
1702
|
+
name TEXT NOT NULL UNIQUE,
|
|
1703
|
+
kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
|
|
1704
|
+
url TEXT,
|
|
1705
|
+
host TEXT,
|
|
1706
|
+
port INTEGER,
|
|
1707
|
+
method TEXT NOT NULL DEFAULT 'GET',
|
|
1708
|
+
expected_status INTEGER,
|
|
1709
|
+
interval_seconds INTEGER NOT NULL DEFAULT 60,
|
|
1710
|
+
timeout_ms INTEGER NOT NULL DEFAULT 5000,
|
|
1711
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
1712
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
1713
|
+
status TEXT NOT NULL DEFAULT 'unknown',
|
|
1714
|
+
last_checked_at TEXT,
|
|
1715
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
1716
|
+
created_at TEXT NOT NULL,
|
|
1717
|
+
updated_at TEXT NOT NULL
|
|
1718
|
+
)
|
|
1719
|
+
`);
|
|
1720
|
+
this.db.run(`
|
|
1721
|
+
INSERT INTO monitors (
|
|
1722
|
+
id, name, kind, url, host, port, method, expected_status,
|
|
1723
|
+
interval_seconds, timeout_ms, retry_count, enabled, status,
|
|
1724
|
+
last_checked_at, revision, created_at, updated_at
|
|
1725
|
+
)
|
|
1726
|
+
SELECT
|
|
1727
|
+
id, name, kind, url, host, port, method, expected_status,
|
|
1728
|
+
interval_seconds, timeout_ms, retry_count, enabled, status,
|
|
1729
|
+
last_checked_at, revision, created_at, updated_at
|
|
1730
|
+
FROM monitors_old_kind
|
|
1731
|
+
`);
|
|
1732
|
+
this.db.run("DROP TABLE monitors_old_kind");
|
|
1733
|
+
});
|
|
1734
|
+
migrate();
|
|
1735
|
+
} finally {
|
|
1736
|
+
this.db.run("PRAGMA legacy_alter_table = OFF");
|
|
1737
|
+
this.db.run("PRAGMA foreign_keys = ON");
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
vacuumInto(backupPath) {
|
|
1741
|
+
const quoted = backupPath.replace(/'/g, "''");
|
|
1742
|
+
this.db.run(`VACUUM INTO '${quoted}'`);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
function resolveRuntimeMode(mode) {
|
|
1746
|
+
const value = mode ?? process.env.HASNA_UPTIME_MODE ?? "local";
|
|
1747
|
+
if (value === "local" || value === "hosted")
|
|
1748
|
+
return value;
|
|
1749
|
+
throw new Error("HASNA_UPTIME_MODE must be local or hosted");
|
|
392
1750
|
}
|
|
393
|
-
function
|
|
1751
|
+
function allowHostedLocalStore(value) {
|
|
1752
|
+
return value === true || process.env.HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE === "1";
|
|
1753
|
+
}
|
|
1754
|
+
function verifyBackupFile(backupPath) {
|
|
1755
|
+
const db = new Database(backupPath, { readonly: true });
|
|
1756
|
+
try {
|
|
1757
|
+
const integrityRow = db.query("PRAGMA integrity_check").get();
|
|
1758
|
+
const integrity = String(integrityRow?.integrity_check ?? "unknown");
|
|
1759
|
+
const missingTables = REQUIRED_TABLES.filter((table) => !tableExists(db, table));
|
|
1760
|
+
const schemaVersion = missingTables.includes("schema_migrations") ? null : db.query("SELECT value FROM schema_migrations WHERE key = 'schema_version'").get()?.value ?? null;
|
|
1761
|
+
const currentOk = missingTables.length === 0 && schemaVersion === CURRENT_SCHEMA_VERSION;
|
|
1762
|
+
const restorableV1 = schemaVersion === "1" && missingTables.every((table) => PROBE_TABLES.has(table) || REPORT_AUDIT_TABLES.has(table));
|
|
1763
|
+
const restorableV2 = schemaVersion === "2" && missingTables.every((table) => REPORT_AUDIT_TABLES.has(table));
|
|
1764
|
+
return {
|
|
1765
|
+
ok: integrity === "ok" && (currentOk || restorableV1 || restorableV2),
|
|
1766
|
+
backupPath,
|
|
1767
|
+
integrity,
|
|
1768
|
+
schemaVersion,
|
|
1769
|
+
missingTables,
|
|
1770
|
+
monitors: tableCount(db, "monitors"),
|
|
1771
|
+
results: tableCount(db, "check_results"),
|
|
1772
|
+
incidents: tableCount(db, "incidents")
|
|
1773
|
+
};
|
|
1774
|
+
} finally {
|
|
1775
|
+
db.close();
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
function tableCount(db, table) {
|
|
1779
|
+
if (!tableExists(db, table))
|
|
1780
|
+
return 0;
|
|
1781
|
+
const row = db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
|
|
1782
|
+
return Number(row?.count ?? 0);
|
|
1783
|
+
}
|
|
1784
|
+
function tableExists(db, table) {
|
|
1785
|
+
const row = db.query("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
1786
|
+
return Number(row?.count ?? 0) > 0;
|
|
1787
|
+
}
|
|
1788
|
+
function normalizeCreateMonitor(input, allowBrowserPage = false) {
|
|
394
1789
|
const name = input.name?.trim();
|
|
395
1790
|
if (!name)
|
|
396
1791
|
throw new Error("Monitor name is required");
|
|
397
|
-
|
|
1792
|
+
rejectControlCharacters2(name, "Monitor name");
|
|
398
1793
|
const method = normalizeMethod(input.method ?? "GET");
|
|
399
1794
|
const expectedStatus = normalizeExpectedStatus(input.expectedStatus);
|
|
400
1795
|
const enabled = normalizeEnabled(input.enabled);
|
|
401
|
-
if (input.kind === "http") {
|
|
1796
|
+
if (input.kind === "http" || input.kind === "browser_page") {
|
|
1797
|
+
if (input.kind === "browser_page" && !allowBrowserPage) {
|
|
1798
|
+
throw new Error("browser_page monitors must be imported with explicit browser evidence support");
|
|
1799
|
+
}
|
|
402
1800
|
const url = normalizeHttpUrl(input.url);
|
|
403
1801
|
return {
|
|
404
1802
|
name,
|
|
@@ -406,16 +1804,16 @@ function normalizeCreateMonitor(input) {
|
|
|
406
1804
|
url,
|
|
407
1805
|
method,
|
|
408
1806
|
expectedStatus,
|
|
409
|
-
intervalSeconds:
|
|
410
|
-
timeoutMs:
|
|
411
|
-
retryCount:
|
|
1807
|
+
intervalSeconds: boundedInteger2(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
|
|
1808
|
+
timeoutMs: boundedInteger2(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
|
|
1809
|
+
retryCount: boundedInteger2(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
|
|
412
1810
|
enabled
|
|
413
1811
|
};
|
|
414
1812
|
} else if (input.kind === "tcp") {
|
|
415
1813
|
const host = input.host?.trim();
|
|
416
1814
|
if (!host)
|
|
417
1815
|
throw new Error("TCP monitors require host");
|
|
418
|
-
|
|
1816
|
+
rejectControlCharacters2(host, "TCP host");
|
|
419
1817
|
if (!Number.isInteger(input.port) || input.port <= 0 || input.port > 65535) {
|
|
420
1818
|
throw new Error("TCP monitors require a port from 1 to 65535");
|
|
421
1819
|
}
|
|
@@ -426,19 +1824,19 @@ function normalizeCreateMonitor(input) {
|
|
|
426
1824
|
port: input.port,
|
|
427
1825
|
method,
|
|
428
1826
|
expectedStatus: null,
|
|
429
|
-
intervalSeconds:
|
|
430
|
-
timeoutMs:
|
|
431
|
-
retryCount:
|
|
1827
|
+
intervalSeconds: boundedInteger2(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
|
|
1828
|
+
timeoutMs: boundedInteger2(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
|
|
1829
|
+
retryCount: boundedInteger2(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
|
|
432
1830
|
enabled
|
|
433
1831
|
};
|
|
434
1832
|
} else {
|
|
435
|
-
throw new Error("Monitor kind must be http or
|
|
1833
|
+
throw new Error("Monitor kind must be http, tcp, or browser_page");
|
|
436
1834
|
}
|
|
437
1835
|
}
|
|
438
1836
|
function definitionChanged(current, next) {
|
|
439
1837
|
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
1838
|
}
|
|
441
|
-
function normalizeUpdateMonitor(current, input, updatedAt) {
|
|
1839
|
+
function normalizeUpdateMonitor(current, input, updatedAt, allowBrowserPage = false) {
|
|
442
1840
|
const merged = {
|
|
443
1841
|
...current,
|
|
444
1842
|
...input,
|
|
@@ -457,7 +1855,7 @@ function normalizeUpdateMonitor(current, input, updatedAt) {
|
|
|
457
1855
|
timeoutMs: merged.timeoutMs,
|
|
458
1856
|
retryCount: merged.retryCount,
|
|
459
1857
|
enabled: merged.enabled
|
|
460
|
-
});
|
|
1858
|
+
}, allowBrowserPage || current.kind === "browser_page");
|
|
461
1859
|
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
1860
|
const status = normalized.enabled ? checkDefinitionChanged || !current.enabled ? "unknown" : current.status : "paused";
|
|
463
1861
|
return {
|
|
@@ -486,32 +1884,220 @@ function normalizeHttpUrl(value) {
|
|
|
486
1884
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
487
1885
|
throw new Error("HTTP monitor url must use http or https");
|
|
488
1886
|
}
|
|
1887
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
1888
|
+
if (SECRET_URL_PARAM_PATTERN.test(key))
|
|
1889
|
+
parsed.searchParams.set(key, "[redacted]");
|
|
1890
|
+
}
|
|
1891
|
+
parsed.hash = "";
|
|
1892
|
+
return parsed.toString();
|
|
1893
|
+
}
|
|
1894
|
+
function normalizeMethod(value) {
|
|
1895
|
+
const method = value.trim().toUpperCase();
|
|
1896
|
+
if (!/^[A-Z]+$/.test(method))
|
|
1897
|
+
throw new Error("HTTP method must contain only letters");
|
|
1898
|
+
return method;
|
|
1899
|
+
}
|
|
1900
|
+
function normalizeExpectedStatus(value) {
|
|
1901
|
+
if (value == null)
|
|
1902
|
+
return null;
|
|
1903
|
+
if (!Number.isInteger(value) || value < 100 || value > 599) {
|
|
1904
|
+
throw new Error("expectedStatus must be an HTTP status from 100 to 599");
|
|
1905
|
+
}
|
|
1906
|
+
return value;
|
|
1907
|
+
}
|
|
1908
|
+
function normalizeEnabled(value) {
|
|
1909
|
+
if (value === undefined)
|
|
1910
|
+
return true;
|
|
1911
|
+
if (typeof value !== "boolean")
|
|
1912
|
+
throw new Error("enabled must be a boolean");
|
|
1913
|
+
return value;
|
|
1914
|
+
}
|
|
1915
|
+
function rejectControlCharacters2(value, label) {
|
|
1916
|
+
if (/[\x00-\x1f\x7f-\x9f]/.test(value)) {
|
|
1917
|
+
throw new Error(`${label} must not contain control characters`);
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
function normalizeScheduleSlot(value) {
|
|
1921
|
+
const slot = value.trim();
|
|
1922
|
+
if (!slot)
|
|
1923
|
+
throw new Error("Probe job scheduleSlot is required");
|
|
1924
|
+
if (slot.length > 128)
|
|
1925
|
+
throw new Error("Probe job scheduleSlot is too long");
|
|
1926
|
+
rejectControlCharacters2(slot, "Probe job scheduleSlot");
|
|
1927
|
+
return slot;
|
|
1928
|
+
}
|
|
1929
|
+
function normalizeReportScheduleInput(input) {
|
|
1930
|
+
const name = input.name?.trim();
|
|
1931
|
+
if (!name)
|
|
1932
|
+
throw new Error("Report schedule name is required");
|
|
1933
|
+
rejectControlCharacters2(name, "Report schedule name");
|
|
1934
|
+
const intervalSeconds = boundedInteger2(input.intervalSeconds, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS);
|
|
1935
|
+
const nextRunAt = input.nextRunAt ?? new Date().toISOString();
|
|
1936
|
+
assertIsoTimestamp(nextRunAt, "Report schedule nextRunAt");
|
|
1937
|
+
const enabled = normalizeEnabled(input.enabled);
|
|
1938
|
+
const subject = normalizeNullableBoundedText(input.subject, "Report schedule subject", 200);
|
|
1939
|
+
const channels = normalizeReportChannels(input.channels);
|
|
1940
|
+
return { name, intervalSeconds, nextRunAt, enabled, subject, channels };
|
|
1941
|
+
}
|
|
1942
|
+
function normalizeReportChannels(channels) {
|
|
1943
|
+
if (!channels || typeof channels !== "object")
|
|
1944
|
+
throw new Error("Report schedule channels are required");
|
|
1945
|
+
const normalized = {};
|
|
1946
|
+
if (channels.email !== undefined)
|
|
1947
|
+
normalized.email = normalizeChannelTarget(channels.email, "email", ["apiUrl", "from", "to", "subject", "providerId"]);
|
|
1948
|
+
if (channels.sms !== undefined)
|
|
1949
|
+
normalized.sms = normalizeChannelTarget(channels.sms, "sms", ["apiUrl", "from", "to"]);
|
|
1950
|
+
if (channels.logs !== undefined)
|
|
1951
|
+
normalized.logs = normalizeChannelTarget(channels.logs, "logs", ["apiUrl", "projectId", "environment", "service"]);
|
|
1952
|
+
if (!normalized.email && !normalized.sms && !normalized.logs) {
|
|
1953
|
+
throw new Error("Report schedule requires at least one channel");
|
|
1954
|
+
}
|
|
1955
|
+
return normalized;
|
|
1956
|
+
}
|
|
1957
|
+
function normalizeChannelTarget(value, channel, allowedKeys) {
|
|
1958
|
+
if (value === false || value == null)
|
|
1959
|
+
return false;
|
|
1960
|
+
if (value === true)
|
|
1961
|
+
return true;
|
|
1962
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1963
|
+
throw new Error(`Report schedule ${channel} channel must be true or an object`);
|
|
1964
|
+
}
|
|
1965
|
+
const record = value;
|
|
1966
|
+
const normalized = {};
|
|
1967
|
+
for (const [key, rawValue] of Object.entries(record)) {
|
|
1968
|
+
if (!allowedKeys.includes(key)) {
|
|
1969
|
+
if (/key|token|secret|password|credential|auth/i.test(key)) {
|
|
1970
|
+
throw new Error("Report schedules must not persist API keys or tokens; use environment variables or cloud channel refs");
|
|
1971
|
+
}
|
|
1972
|
+
throw new Error(`Unsupported report schedule ${channel} channel field: ${key}`);
|
|
1973
|
+
}
|
|
1974
|
+
if (rawValue === undefined || rawValue === null || rawValue === "")
|
|
1975
|
+
continue;
|
|
1976
|
+
if (key === "apiUrl" && Array.isArray(rawValue)) {
|
|
1977
|
+
throw new Error(`Report schedule ${channel}.${key} must be a string`);
|
|
1978
|
+
}
|
|
1979
|
+
if (Array.isArray(rawValue)) {
|
|
1980
|
+
const items = rawValue.map((item) => normalizeBoundedText(String(item), `Report schedule ${channel}.${key}`, 300));
|
|
1981
|
+
if (items.length > 0)
|
|
1982
|
+
normalized[key] = items;
|
|
1983
|
+
} else if (typeof rawValue === "string" || typeof rawValue === "number") {
|
|
1984
|
+
normalized[key] = key === "apiUrl" ? normalizeHttpIntegrationUrl(String(rawValue)) : normalizeBoundedText(String(rawValue), `Report schedule ${channel}.${key}`, 500);
|
|
1985
|
+
} else {
|
|
1986
|
+
throw new Error(`Report schedule ${channel}.${key} must be a string or string array`);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
return Object.keys(normalized).length > 0 ? normalized : true;
|
|
1990
|
+
}
|
|
1991
|
+
function normalizeHttpIntegrationUrl(value) {
|
|
1992
|
+
const parsed = new URL(value.trim());
|
|
1993
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1994
|
+
throw new Error("Report schedule integration API URL must use http or https");
|
|
1995
|
+
}
|
|
1996
|
+
if (parsed.username || parsed.password) {
|
|
1997
|
+
throw new Error("Report schedule integration API URL must not include credentials");
|
|
1998
|
+
}
|
|
1999
|
+
for (const key of parsed.searchParams.keys()) {
|
|
2000
|
+
if (SECRET_URL_PARAM_PATTERN.test(key)) {
|
|
2001
|
+
throw new Error("Report schedule integration API URL must not include secret query parameters");
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
parsed.hash = "";
|
|
489
2005
|
return parsed.toString();
|
|
490
2006
|
}
|
|
491
|
-
function
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
2007
|
+
function normalizeReportDeliveries(deliveries) {
|
|
2008
|
+
return deliveries.map((delivery) => {
|
|
2009
|
+
if (delivery.channel !== "email" && delivery.channel !== "sms" && delivery.channel !== "logs") {
|
|
2010
|
+
throw new Error("Report delivery channel must be email, sms, or logs");
|
|
2011
|
+
}
|
|
2012
|
+
return {
|
|
2013
|
+
channel: delivery.channel,
|
|
2014
|
+
ok: Boolean(delivery.ok),
|
|
2015
|
+
status: delivery.status,
|
|
2016
|
+
id: delivery.id === undefined ? undefined : normalizeRedactedText(String(delivery.id), "Report delivery id", 300),
|
|
2017
|
+
error: delivery.error === undefined ? undefined : normalizeRedactedText(String(delivery.error), "Report delivery error", 1000)
|
|
2018
|
+
};
|
|
2019
|
+
});
|
|
496
2020
|
}
|
|
497
|
-
function
|
|
2021
|
+
function normalizeAuditText(value, label, maxLength) {
|
|
2022
|
+
return normalizeBoundedText(value ?? "", label, maxLength);
|
|
2023
|
+
}
|
|
2024
|
+
function normalizeNullableAuditText(value, label, maxLength) {
|
|
2025
|
+
return normalizeNullableBoundedText(value, label, maxLength);
|
|
2026
|
+
}
|
|
2027
|
+
function normalizeNullableBoundedText(value, label, maxLength) {
|
|
498
2028
|
if (value == null)
|
|
499
2029
|
return null;
|
|
500
|
-
|
|
501
|
-
|
|
2030
|
+
const normalized = normalizeRedactedText(value, label, maxLength);
|
|
2031
|
+
return normalized || null;
|
|
2032
|
+
}
|
|
2033
|
+
function normalizeBoundedText(value, label, maxLength) {
|
|
2034
|
+
const normalized = value.trim();
|
|
2035
|
+
rejectControlCharacters2(normalized, label);
|
|
2036
|
+
if (normalized.length > maxLength)
|
|
2037
|
+
throw new Error(`${label} is too long`);
|
|
2038
|
+
return normalized;
|
|
2039
|
+
}
|
|
2040
|
+
function normalizeNullableRedactedText(value, label, maxLength) {
|
|
2041
|
+
if (value == null)
|
|
2042
|
+
return null;
|
|
2043
|
+
const normalized = normalizeRedactedText(value, label, maxLength);
|
|
2044
|
+
return normalized || null;
|
|
2045
|
+
}
|
|
2046
|
+
function normalizeRedactedText(value, label, maxLength) {
|
|
2047
|
+
return normalizeBoundedText(redactSecretString(value), label, maxLength);
|
|
2048
|
+
}
|
|
2049
|
+
function normalizeAuditMetadata(value) {
|
|
2050
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2051
|
+
throw new Error("Audit metadata must be an object");
|
|
502
2052
|
}
|
|
503
|
-
return value;
|
|
2053
|
+
return redactAuditSecrets(JSON.parse(JSON.stringify(value)));
|
|
504
2054
|
}
|
|
505
|
-
function
|
|
506
|
-
if (value
|
|
507
|
-
return
|
|
508
|
-
if (typeof value
|
|
509
|
-
|
|
510
|
-
|
|
2055
|
+
function redactAuditSecrets(value) {
|
|
2056
|
+
if (Array.isArray(value))
|
|
2057
|
+
return value.map(redactAuditSecrets);
|
|
2058
|
+
if (typeof value === "string")
|
|
2059
|
+
return redactSecretString(value);
|
|
2060
|
+
if (!value || typeof value !== "object")
|
|
2061
|
+
return value;
|
|
2062
|
+
const output = {};
|
|
2063
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
2064
|
+
output[key] = /key|token|secret|password|credential|auth/i.test(key) ? "[REDACTED]" : redactAuditSecrets(nested);
|
|
2065
|
+
}
|
|
2066
|
+
return output;
|
|
511
2067
|
}
|
|
512
|
-
function
|
|
513
|
-
|
|
514
|
-
|
|
2068
|
+
function redactSecretString(value) {
|
|
2069
|
+
let output = value.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]");
|
|
2070
|
+
output = output.replace(/https?:\/\/[^\s"'<>]+/gi, (match) => redactUrlString(match));
|
|
2071
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(output))
|
|
2072
|
+
return output;
|
|
2073
|
+
return redactUrlString(output);
|
|
2074
|
+
}
|
|
2075
|
+
function redactUrlString(value) {
|
|
2076
|
+
let trailing = "";
|
|
2077
|
+
let candidate = value;
|
|
2078
|
+
while (/[),.;\]]$/.test(candidate)) {
|
|
2079
|
+
trailing = `${candidate.slice(-1)}${trailing}`;
|
|
2080
|
+
candidate = candidate.slice(0, -1);
|
|
2081
|
+
}
|
|
2082
|
+
try {
|
|
2083
|
+
const parsed = new URL(candidate);
|
|
2084
|
+
if (parsed.username)
|
|
2085
|
+
parsed.username = "[REDACTED]";
|
|
2086
|
+
if (parsed.password)
|
|
2087
|
+
parsed.password = "[REDACTED]";
|
|
2088
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
2089
|
+
if (SECRET_URL_PARAM_PATTERN.test(key))
|
|
2090
|
+
parsed.searchParams.set(key, "[REDACTED]");
|
|
2091
|
+
}
|
|
2092
|
+
parsed.hash = "";
|
|
2093
|
+
return `${parsed.toString()}${trailing}`;
|
|
2094
|
+
} catch {
|
|
2095
|
+
return value;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
function assertIsoTimestamp(value, label) {
|
|
2099
|
+
if (!Number.isFinite(Date.parse(value))) {
|
|
2100
|
+
throw new Error(`${label} must be an ISO timestamp`);
|
|
515
2101
|
}
|
|
516
2102
|
}
|
|
517
2103
|
function monitorFromRow(row) {
|
|
@@ -544,9 +2130,137 @@ function checkResultFromRow(row) {
|
|
|
544
2130
|
latencyMs: row.latency_ms,
|
|
545
2131
|
statusCode: row.status_code,
|
|
546
2132
|
error: row.error,
|
|
547
|
-
attemptCount: row.attempt_count
|
|
2133
|
+
attemptCount: row.attempt_count,
|
|
2134
|
+
evidence: parseEvidence(row.evidence_json)
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
function provenanceFromRow(row) {
|
|
2138
|
+
return {
|
|
2139
|
+
monitorId: row.monitor_id,
|
|
2140
|
+
source: row.source,
|
|
2141
|
+
sourceId: row.source_id,
|
|
2142
|
+
sourceLabel: row.source_label,
|
|
2143
|
+
importedAt: row.imported_at,
|
|
2144
|
+
snapshot: parseJson(row.snapshot_json)
|
|
2145
|
+
};
|
|
2146
|
+
}
|
|
2147
|
+
function importBatchFromRow(row) {
|
|
2148
|
+
return {
|
|
2149
|
+
id: row.id,
|
|
2150
|
+
source: row.source,
|
|
2151
|
+
status: row.status,
|
|
2152
|
+
createdAt: row.created_at,
|
|
2153
|
+
rolledBackAt: row.rolled_back_at,
|
|
2154
|
+
records: Array.isArray(parseJson(row.records_json)) ? parseJson(row.records_json) : []
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2157
|
+
function probeIdentityFromRow(row) {
|
|
2158
|
+
return {
|
|
2159
|
+
id: row.id,
|
|
2160
|
+
name: row.name,
|
|
2161
|
+
publicKeyPem: row.public_key_pem,
|
|
2162
|
+
publicKeyFingerprint: row.public_key_fingerprint,
|
|
2163
|
+
enabled: Boolean(row.enabled),
|
|
2164
|
+
createdAt: row.created_at,
|
|
2165
|
+
lastSeenAt: row.last_seen_at
|
|
2166
|
+
};
|
|
2167
|
+
}
|
|
2168
|
+
function probeSubmissionFromRow(row) {
|
|
2169
|
+
return {
|
|
2170
|
+
id: row.id,
|
|
2171
|
+
probeId: row.probe_id,
|
|
2172
|
+
jobId: row.job_id ?? "",
|
|
2173
|
+
monitorId: row.monitor_id,
|
|
2174
|
+
checkResultId: row.check_result_id,
|
|
2175
|
+
nonce: row.nonce,
|
|
2176
|
+
checkedAt: row.checked_at,
|
|
2177
|
+
submittedAt: row.submitted_at
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
function probeCheckJobFromRow(row) {
|
|
2181
|
+
return {
|
|
2182
|
+
id: row.id,
|
|
2183
|
+
monitorId: row.monitor_id,
|
|
2184
|
+
monitorRevision: row.monitor_revision ?? 1,
|
|
2185
|
+
scheduleSlot: row.schedule_slot,
|
|
2186
|
+
status: row.status,
|
|
2187
|
+
claimedByProbeId: row.claimed_by_probe_id,
|
|
2188
|
+
fencingToken: row.fencing_token,
|
|
2189
|
+
dueAt: row.due_at,
|
|
2190
|
+
claimedAt: row.claimed_at,
|
|
2191
|
+
leaseExpiresAt: row.lease_expires_at,
|
|
2192
|
+
submittedResultId: row.submitted_result_id,
|
|
2193
|
+
createdAt: row.created_at,
|
|
2194
|
+
updatedAt: row.updated_at
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
function reportScheduleFromRow(row) {
|
|
2198
|
+
return {
|
|
2199
|
+
id: row.id,
|
|
2200
|
+
name: row.name,
|
|
2201
|
+
enabled: Boolean(row.enabled),
|
|
2202
|
+
intervalSeconds: row.interval_seconds,
|
|
2203
|
+
nextRunAt: row.next_run_at,
|
|
2204
|
+
lastRunAt: row.last_run_at,
|
|
2205
|
+
subject: row.subject,
|
|
2206
|
+
channels: parseReportChannels(row.channels_json),
|
|
2207
|
+
createdAt: row.created_at,
|
|
2208
|
+
updatedAt: row.updated_at
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
function reportRunFromRow(row) {
|
|
2212
|
+
return {
|
|
2213
|
+
id: row.id,
|
|
2214
|
+
scheduleId: row.schedule_id,
|
|
2215
|
+
status: row.status,
|
|
2216
|
+
startedAt: row.started_at,
|
|
2217
|
+
finishedAt: row.finished_at,
|
|
2218
|
+
deliveries: parseReportDeliveries(row.deliveries_json),
|
|
2219
|
+
error: row.error,
|
|
2220
|
+
reportJson: parseRecord(row.report_json)
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
2223
|
+
function auditEventFromRow(row) {
|
|
2224
|
+
return {
|
|
2225
|
+
id: row.id,
|
|
2226
|
+
action: row.action,
|
|
2227
|
+
resourceType: row.resource_type,
|
|
2228
|
+
resourceId: row.resource_id,
|
|
2229
|
+
message: row.message,
|
|
2230
|
+
metadata: parseRecord(row.metadata_json) ?? {},
|
|
2231
|
+
actor: row.actor,
|
|
2232
|
+
createdAt: row.created_at
|
|
548
2233
|
};
|
|
549
2234
|
}
|
|
2235
|
+
function parseEvidence(value) {
|
|
2236
|
+
if (!value)
|
|
2237
|
+
return null;
|
|
2238
|
+
const parsed = parseJson(value);
|
|
2239
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
2240
|
+
}
|
|
2241
|
+
function parseReportChannels(value) {
|
|
2242
|
+
const parsed = parseJson(value);
|
|
2243
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
2244
|
+
return {};
|
|
2245
|
+
return parsed;
|
|
2246
|
+
}
|
|
2247
|
+
function parseReportDeliveries(value) {
|
|
2248
|
+
const parsed = parseJson(value);
|
|
2249
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
2250
|
+
}
|
|
2251
|
+
function parseRecord(value) {
|
|
2252
|
+
if (!value)
|
|
2253
|
+
return null;
|
|
2254
|
+
const parsed = parseJson(value);
|
|
2255
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
2256
|
+
}
|
|
2257
|
+
function parseJson(value) {
|
|
2258
|
+
try {
|
|
2259
|
+
return JSON.parse(value);
|
|
2260
|
+
} catch {
|
|
2261
|
+
return null;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
550
2264
|
function incidentFromRow(row) {
|
|
551
2265
|
return {
|
|
552
2266
|
id: row.id,
|
|
@@ -561,9 +2275,9 @@ function incidentFromRow(row) {
|
|
|
561
2275
|
};
|
|
562
2276
|
}
|
|
563
2277
|
function newId(prefix) {
|
|
564
|
-
return `${prefix}_${
|
|
2278
|
+
return `${prefix}_${randomUUID2().replace(/-/g, "").slice(0, 18)}`;
|
|
565
2279
|
}
|
|
566
|
-
function
|
|
2280
|
+
function boundedInteger2(value, label, min, max) {
|
|
567
2281
|
if (!Number.isInteger(value) || value < min || value > max) {
|
|
568
2282
|
throw new Error(`${label} must be an integer from ${min} to ${max}`);
|
|
569
2283
|
}
|
|
@@ -574,6 +2288,9 @@ function clampLimit(value) {
|
|
|
574
2288
|
return 50;
|
|
575
2289
|
return Math.max(1, Math.min(Math.floor(value), MAX_RESULT_LIMIT));
|
|
576
2290
|
}
|
|
2291
|
+
function statementChanges(result) {
|
|
2292
|
+
return Number(result?.changes ?? 0);
|
|
2293
|
+
}
|
|
577
2294
|
function round(value, places) {
|
|
578
2295
|
const factor = 10 ** places;
|
|
579
2296
|
return Math.round(value * factor) / factor;
|
|
@@ -648,7 +2365,7 @@ function renderMonitorLine(item) {
|
|
|
648
2365
|
return `- ${item.monitor.status.toUpperCase()} ${item.monitor.name} (${targetLabel(item)}): uptime ${uptime}, latency ${latency}${incident}`;
|
|
649
2366
|
}
|
|
650
2367
|
function targetLabel(item) {
|
|
651
|
-
return item.monitor.kind === "
|
|
2368
|
+
return item.monitor.kind === "tcp" ? `${item.monitor.host}:${item.monitor.port}` : item.monitor.url ?? "";
|
|
652
2369
|
}
|
|
653
2370
|
function resolveEmailTarget(value) {
|
|
654
2371
|
const target = typeof value === "boolean" ? {} : value;
|
|
@@ -850,14 +2567,18 @@ function redactOptional(value, secrets) {
|
|
|
850
2567
|
}
|
|
851
2568
|
|
|
852
2569
|
// src/service.ts
|
|
853
|
-
import { randomUUID as
|
|
2570
|
+
import { createPublicKey, randomUUID as randomUUID3 } from "crypto";
|
|
2571
|
+
var MAX_PROBE_RESULT_AGE_MS = 15 * 60000;
|
|
2572
|
+
var MAX_PROBE_RESULT_FUTURE_MS = 5 * 60000;
|
|
2573
|
+
|
|
854
2574
|
class UptimeService {
|
|
855
2575
|
store;
|
|
856
2576
|
checkRunner;
|
|
857
|
-
leaseOwner = `svc_${
|
|
2577
|
+
leaseOwner = `svc_${randomUUID3().replace(/-/g, "").slice(0, 18)}`;
|
|
858
2578
|
inFlightChecks = new Set;
|
|
2579
|
+
inFlightReportSchedules = new Set;
|
|
859
2580
|
constructor(options = {}) {
|
|
860
|
-
this.store = options.store ?? new UptimeStore(options);
|
|
2581
|
+
this.store = options.store ?? new UptimeStore({ mode: "local", ...options });
|
|
861
2582
|
this.checkRunner = options.checkRunner ?? runMonitorCheck;
|
|
862
2583
|
}
|
|
863
2584
|
close() {
|
|
@@ -887,13 +2608,180 @@ class UptimeService {
|
|
|
887
2608
|
summary() {
|
|
888
2609
|
return this.store.summary();
|
|
889
2610
|
}
|
|
2611
|
+
createProbe(input) {
|
|
2612
|
+
const store = this.probeStore();
|
|
2613
|
+
const publicKeyPem = input.publicKeyPem ? normalizeProbePublicKeyPem(input.publicKeyPem) : undefined;
|
|
2614
|
+
const keyPair = publicKeyPem ? {
|
|
2615
|
+
publicKeyPem,
|
|
2616
|
+
privateKeyPem: undefined,
|
|
2617
|
+
publicKeyFingerprint: probePublicKeyFingerprint(publicKeyPem)
|
|
2618
|
+
} : generateProbeKeyPair();
|
|
2619
|
+
const probe = store.createProbeIdentity({
|
|
2620
|
+
name: input.name,
|
|
2621
|
+
publicKeyPem: keyPair.publicKeyPem,
|
|
2622
|
+
publicKeyFingerprint: keyPair.publicKeyFingerprint,
|
|
2623
|
+
enabled: input.enabled
|
|
2624
|
+
});
|
|
2625
|
+
return { ...probe, privateKeyPem: keyPair.privateKeyPem };
|
|
2626
|
+
}
|
|
2627
|
+
listProbes(options = {}) {
|
|
2628
|
+
return this.probeStore().listProbeIdentities(options);
|
|
2629
|
+
}
|
|
2630
|
+
getProbe(idOrName) {
|
|
2631
|
+
return this.probeStore().getProbeIdentity(idOrName);
|
|
2632
|
+
}
|
|
2633
|
+
updateProbe(idOrName, input) {
|
|
2634
|
+
return this.probeStore().updateProbeIdentity(idOrName, input);
|
|
2635
|
+
}
|
|
2636
|
+
createProbeCheckJob(input) {
|
|
2637
|
+
return this.probeStore().createProbeCheckJob(input);
|
|
2638
|
+
}
|
|
2639
|
+
getProbeCheckJob(id) {
|
|
2640
|
+
return this.probeStore().getProbeCheckJob(id);
|
|
2641
|
+
}
|
|
2642
|
+
claimProbeCheckJob(input) {
|
|
2643
|
+
return this.probeStore().claimProbeCheckJob(input);
|
|
2644
|
+
}
|
|
2645
|
+
submitProbeResult(input) {
|
|
2646
|
+
const execute = () => this.submitProbeResultInTransaction(input);
|
|
2647
|
+
return this.store.runInTransaction ? this.store.runInTransaction(execute) : execute();
|
|
2648
|
+
}
|
|
2649
|
+
previewImport(request) {
|
|
2650
|
+
return previewImport(this.store, request);
|
|
2651
|
+
}
|
|
2652
|
+
applyImport(request) {
|
|
2653
|
+
return applyImport(this.store, request);
|
|
2654
|
+
}
|
|
2655
|
+
rollbackImport(batchId) {
|
|
2656
|
+
return rollbackImport(this.store, batchId);
|
|
2657
|
+
}
|
|
2658
|
+
backup(destinationPath) {
|
|
2659
|
+
return this.store.backup(destinationPath);
|
|
2660
|
+
}
|
|
2661
|
+
verifyBackup(backupPath) {
|
|
2662
|
+
return this.store.verifyBackup(backupPath);
|
|
2663
|
+
}
|
|
890
2664
|
buildReport(options = {}) {
|
|
891
2665
|
return buildUptimeReport(this.summary(), options);
|
|
892
2666
|
}
|
|
893
2667
|
async sendReport(options = {}) {
|
|
2668
|
+
if (this.store.mode === "hosted" && (options.email || options.sms || options.logs)) {
|
|
2669
|
+
throw new Error("hosted report delivery requires configured channel refs");
|
|
2670
|
+
}
|
|
894
2671
|
return sendUptimeReport(this.summary(), options);
|
|
895
2672
|
}
|
|
2673
|
+
createReportSchedule(input) {
|
|
2674
|
+
const store = this.reportStore();
|
|
2675
|
+
const schedule = store.createReportSchedule(input);
|
|
2676
|
+
this.audit("report_schedule.create", "report_schedule", schedule.id, `Created report schedule ${schedule.name}`, {
|
|
2677
|
+
name: schedule.name,
|
|
2678
|
+
enabled: schedule.enabled,
|
|
2679
|
+
intervalSeconds: schedule.intervalSeconds,
|
|
2680
|
+
channels: enabledReportChannels(schedule)
|
|
2681
|
+
});
|
|
2682
|
+
return schedule;
|
|
2683
|
+
}
|
|
2684
|
+
listReportSchedules(options = {}) {
|
|
2685
|
+
return this.reportStore().listReportSchedules(options);
|
|
2686
|
+
}
|
|
2687
|
+
getReportSchedule(idOrName) {
|
|
2688
|
+
return this.reportStore().getReportSchedule(idOrName);
|
|
2689
|
+
}
|
|
2690
|
+
updateReportSchedule(idOrName, input) {
|
|
2691
|
+
const store = this.reportStore();
|
|
2692
|
+
const schedule = store.updateReportSchedule(idOrName, input);
|
|
2693
|
+
this.audit("report_schedule.update", "report_schedule", schedule.id, `Updated report schedule ${schedule.name}`, {
|
|
2694
|
+
name: schedule.name,
|
|
2695
|
+
enabled: schedule.enabled,
|
|
2696
|
+
intervalSeconds: schedule.intervalSeconds,
|
|
2697
|
+
channels: enabledReportChannels(schedule)
|
|
2698
|
+
});
|
|
2699
|
+
return schedule;
|
|
2700
|
+
}
|
|
2701
|
+
deleteReportSchedule(idOrName) {
|
|
2702
|
+
const store = this.reportStore();
|
|
2703
|
+
const schedule = store.getReportSchedule(idOrName);
|
|
2704
|
+
const deleted = store.deleteReportSchedule(idOrName);
|
|
2705
|
+
if (deleted && schedule) {
|
|
2706
|
+
this.audit("report_schedule.delete", "report_schedule", schedule.id, `Deleted report schedule ${schedule.name}`, {
|
|
2707
|
+
name: schedule.name
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
return deleted;
|
|
2711
|
+
}
|
|
2712
|
+
listReportRuns(options = {}) {
|
|
2713
|
+
return this.reportStore().listReportRuns(options);
|
|
2714
|
+
}
|
|
2715
|
+
listAuditEvents(options = {}) {
|
|
2716
|
+
return this.reportStore().listAuditEvents(options);
|
|
2717
|
+
}
|
|
2718
|
+
recordAuditEvent(input) {
|
|
2719
|
+
return this.reportStore().recordAuditEvent(input);
|
|
2720
|
+
}
|
|
2721
|
+
async runReportSchedule(idOrName, options = {}) {
|
|
2722
|
+
const store = this.reportStore();
|
|
2723
|
+
const schedule = store.getReportSchedule(idOrName);
|
|
2724
|
+
if (!schedule)
|
|
2725
|
+
throw new Error(`Report schedule not found: ${idOrName}`);
|
|
2726
|
+
if (!schedule.enabled)
|
|
2727
|
+
throw new Error(`Report schedule is disabled: ${schedule.name}`);
|
|
2728
|
+
if (this.inFlightReportSchedules.has(schedule.id))
|
|
2729
|
+
throw new Error(`Report schedule already running: ${schedule.name}`);
|
|
2730
|
+
this.inFlightReportSchedules.add(schedule.id);
|
|
2731
|
+
try {
|
|
2732
|
+
const startedAt = new Date().toISOString();
|
|
2733
|
+
let deliveries = [];
|
|
2734
|
+
let error = null;
|
|
2735
|
+
let reportJson = null;
|
|
2736
|
+
try {
|
|
2737
|
+
const report = this.buildReport({ subject: schedule.subject ?? undefined });
|
|
2738
|
+
reportJson = report.json;
|
|
2739
|
+
deliveries = await this.sendReport({
|
|
2740
|
+
subject: schedule.subject ?? undefined,
|
|
2741
|
+
email: schedule.channels.email,
|
|
2742
|
+
sms: schedule.channels.sms,
|
|
2743
|
+
logs: schedule.channels.logs,
|
|
2744
|
+
fetchImpl: options.fetchImpl
|
|
2745
|
+
});
|
|
2746
|
+
const failed = deliveries.filter((delivery) => !delivery.ok);
|
|
2747
|
+
if (failed.length > 0) {
|
|
2748
|
+
error = failed.map((delivery) => `${delivery.channel}: ${delivery.error ?? delivery.status ?? "failed"}`).join("; ");
|
|
2749
|
+
}
|
|
2750
|
+
} catch (caught) {
|
|
2751
|
+
error = caught instanceof Error ? caught.message : String(caught);
|
|
2752
|
+
}
|
|
2753
|
+
const finishedAt = new Date().toISOString();
|
|
2754
|
+
const run = store.recordReportRun({
|
|
2755
|
+
scheduleId: schedule.id,
|
|
2756
|
+
status: error ? "failed" : "success",
|
|
2757
|
+
startedAt,
|
|
2758
|
+
finishedAt,
|
|
2759
|
+
deliveries,
|
|
2760
|
+
error,
|
|
2761
|
+
reportJson
|
|
2762
|
+
});
|
|
2763
|
+
this.audit("report_schedule.run", "report_schedule", schedule.id, `Ran report schedule ${schedule.name}`, {
|
|
2764
|
+
runId: run.id,
|
|
2765
|
+
status: run.status,
|
|
2766
|
+
deliveryChannels: run.deliveries.map((delivery) => ({ channel: delivery.channel, ok: delivery.ok }))
|
|
2767
|
+
});
|
|
2768
|
+
return run;
|
|
2769
|
+
} finally {
|
|
2770
|
+
this.inFlightReportSchedules.delete(schedule.id);
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
async runDueReportSchedules(now = new Date, options = {}) {
|
|
2774
|
+
const store = this.reportStore();
|
|
2775
|
+
const schedules = store.listDueReportSchedules(now.toISOString());
|
|
2776
|
+
const runs = [];
|
|
2777
|
+
for (const schedule of schedules) {
|
|
2778
|
+
runs.push(await this.runReportSchedule(schedule.id, options));
|
|
2779
|
+
}
|
|
2780
|
+
return runs;
|
|
2781
|
+
}
|
|
896
2782
|
async checkMonitor(idOrName) {
|
|
2783
|
+
if (this.store.mode === "hosted")
|
|
2784
|
+
throw new Error("hosted checks require check_jobs and probes");
|
|
897
2785
|
const monitor = this.store.getMonitor(idOrName);
|
|
898
2786
|
if (!monitor)
|
|
899
2787
|
throw new Error(`Monitor not found: ${idOrName}`);
|
|
@@ -922,6 +2810,7 @@ class UptimeService {
|
|
|
922
2810
|
latencyMs: last.latencyMs,
|
|
923
2811
|
statusCode: last.statusCode ?? null,
|
|
924
2812
|
error: last.error ?? null,
|
|
2813
|
+
evidence: last.evidence ?? null,
|
|
925
2814
|
attemptCount,
|
|
926
2815
|
expectedMonitorRevision: monitor.revision
|
|
927
2816
|
});
|
|
@@ -931,6 +2820,8 @@ class UptimeService {
|
|
|
931
2820
|
}
|
|
932
2821
|
}
|
|
933
2822
|
async checkAll() {
|
|
2823
|
+
if (this.store.mode === "hosted")
|
|
2824
|
+
throw new Error("hosted checks require check_jobs and probes");
|
|
934
2825
|
const monitors = this.store.listMonitors();
|
|
935
2826
|
const results = [];
|
|
936
2827
|
for (const monitor of monitors) {
|
|
@@ -939,17 +2830,24 @@ class UptimeService {
|
|
|
939
2830
|
return results;
|
|
940
2831
|
}
|
|
941
2832
|
startScheduler(options = {}) {
|
|
2833
|
+
if (this.store.mode === "hosted")
|
|
2834
|
+
throw new Error("hosted scheduler requires check_jobs and probes");
|
|
942
2835
|
const tickMs = options.tickMs ?? 1000;
|
|
943
2836
|
const timer = setInterval(() => {
|
|
944
2837
|
this.runDueChecks().catch((error) => {
|
|
945
2838
|
console.error(error instanceof Error ? error.message : String(error));
|
|
946
2839
|
});
|
|
2840
|
+
this.runDueReportSchedules(new Date, { fetchImpl: options.reportFetchImpl }).catch((error) => {
|
|
2841
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
2842
|
+
});
|
|
947
2843
|
}, tickMs);
|
|
948
2844
|
return {
|
|
949
2845
|
stop: () => clearInterval(timer)
|
|
950
2846
|
};
|
|
951
2847
|
}
|
|
952
2848
|
async runDueChecks(now = new Date) {
|
|
2849
|
+
if (this.store.mode === "hosted")
|
|
2850
|
+
throw new Error("hosted checks require check_jobs and probes");
|
|
953
2851
|
const due = this.store.listMonitors().filter((monitor) => this.isDue(monitor, now));
|
|
954
2852
|
const results = [];
|
|
955
2853
|
for (const monitor of due) {
|
|
@@ -976,9 +2874,150 @@ class UptimeService {
|
|
|
976
2874
|
const last = new Date(monitor.lastCheckedAt).getTime();
|
|
977
2875
|
return now.getTime() - last >= monitor.intervalSeconds * 1000;
|
|
978
2876
|
}
|
|
2877
|
+
probeStore() {
|
|
2878
|
+
if (this.store.mode === "hosted") {
|
|
2879
|
+
throw new Error("hosted probe APIs require cloud check_jobs, workspace stores, and audit logging");
|
|
2880
|
+
}
|
|
2881
|
+
const store = this.store;
|
|
2882
|
+
const required = [
|
|
2883
|
+
"createProbeIdentity",
|
|
2884
|
+
"listProbeIdentities",
|
|
2885
|
+
"getProbeIdentity",
|
|
2886
|
+
"updateProbeIdentity",
|
|
2887
|
+
"touchProbeIdentity",
|
|
2888
|
+
"createProbeCheckJob",
|
|
2889
|
+
"getProbeCheckJob",
|
|
2890
|
+
"claimProbeCheckJob",
|
|
2891
|
+
"completeProbeCheckJob",
|
|
2892
|
+
"getProbeSubmission",
|
|
2893
|
+
"recordProbeSubmission"
|
|
2894
|
+
];
|
|
2895
|
+
for (const method of required) {
|
|
2896
|
+
if (typeof store[method] !== "function") {
|
|
2897
|
+
throw new Error("probe support requires a probe-capable store");
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
return store;
|
|
2901
|
+
}
|
|
2902
|
+
reportStore() {
|
|
2903
|
+
if (this.store.mode === "hosted") {
|
|
2904
|
+
throw new Error("hosted report schedules require cloud channel refs, workspace stores, and audit logging");
|
|
2905
|
+
}
|
|
2906
|
+
const store = this.store;
|
|
2907
|
+
const required = [
|
|
2908
|
+
"createReportSchedule",
|
|
2909
|
+
"listReportSchedules",
|
|
2910
|
+
"listDueReportSchedules",
|
|
2911
|
+
"getReportSchedule",
|
|
2912
|
+
"updateReportSchedule",
|
|
2913
|
+
"deleteReportSchedule",
|
|
2914
|
+
"recordReportRun",
|
|
2915
|
+
"listReportRuns",
|
|
2916
|
+
"recordAuditEvent",
|
|
2917
|
+
"listAuditEvents"
|
|
2918
|
+
];
|
|
2919
|
+
for (const method of required) {
|
|
2920
|
+
if (typeof store[method] !== "function") {
|
|
2921
|
+
throw new Error("report scheduling requires a report-capable store");
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
return store;
|
|
2925
|
+
}
|
|
2926
|
+
audit(action, resourceType, resourceId, message, metadata) {
|
|
2927
|
+
this.reportStore().recordAuditEvent({
|
|
2928
|
+
action,
|
|
2929
|
+
resourceType,
|
|
2930
|
+
resourceId,
|
|
2931
|
+
message,
|
|
2932
|
+
metadata,
|
|
2933
|
+
actor: "local"
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2936
|
+
submitProbeResultInTransaction(input) {
|
|
2937
|
+
const store = this.probeStore();
|
|
2938
|
+
const probe = store.getProbeIdentity(input.probeId);
|
|
2939
|
+
if (!probe)
|
|
2940
|
+
throw new Error(`Probe not found: ${input.probeId}`);
|
|
2941
|
+
if (!probe.enabled)
|
|
2942
|
+
throw new Error(`Probe is disabled: ${probe.name}`);
|
|
2943
|
+
const monitor = this.store.getMonitor(input.monitorId);
|
|
2944
|
+
if (!monitor)
|
|
2945
|
+
throw new Error(`Monitor not found: ${input.monitorId}`);
|
|
2946
|
+
if (!monitor.enabled)
|
|
2947
|
+
throw new Error(`Monitor is disabled: ${monitor.name}`);
|
|
2948
|
+
if (probe.id !== input.probeId)
|
|
2949
|
+
throw new Error("Probe result must use canonical probe id");
|
|
2950
|
+
if (monitor.id !== input.monitorId)
|
|
2951
|
+
throw new Error("Probe result must use canonical monitor id");
|
|
2952
|
+
validateProbeSubmission(input);
|
|
2953
|
+
const job = store.getProbeCheckJob(input.jobId);
|
|
2954
|
+
if (!job)
|
|
2955
|
+
throw new Error(`Probe job not found: ${input.jobId}`);
|
|
2956
|
+
if (job.monitorId !== monitor.id)
|
|
2957
|
+
throw new Error("Probe job does not match monitor");
|
|
2958
|
+
if (job.scheduleSlot !== input.scheduleSlot)
|
|
2959
|
+
throw new Error("Probe job scheduleSlot does not match submission");
|
|
2960
|
+
if (!verifyProbeResultSignature({ ...input, probeId: probe.id, monitorId: monitor.id }, probe.publicKeyPem)) {
|
|
2961
|
+
throw new Error("Probe result signature is invalid");
|
|
2962
|
+
}
|
|
2963
|
+
const existingReceipt = store.getProbeSubmission(probe.id, input.nonce);
|
|
2964
|
+
if (existingReceipt) {
|
|
2965
|
+
if (existingReceipt.jobId !== input.jobId || existingReceipt.monitorId !== monitor.id || existingReceipt.checkedAt !== input.checkedAt) {
|
|
2966
|
+
throw new Error("Probe nonce already submitted");
|
|
2967
|
+
}
|
|
2968
|
+
const existingResult = this.store.getCheckResult?.(existingReceipt.checkResultId);
|
|
2969
|
+
if (!existingResult)
|
|
2970
|
+
throw new Error("Probe nonce already submitted");
|
|
2971
|
+
return { result: existingResult, receipt: existingReceipt };
|
|
2972
|
+
}
|
|
2973
|
+
if (job.monitorRevision !== input.monitorRevision)
|
|
2974
|
+
throw new Error("Probe job monitorRevision does not match submission");
|
|
2975
|
+
if (job.monitorRevision !== monitor.revision)
|
|
2976
|
+
throw new StaleCheckResultError(`Monitor changed since probe job was created: ${monitor.name}`);
|
|
2977
|
+
if (job.status === "submitted")
|
|
2978
|
+
throw new Error("Probe job already submitted");
|
|
2979
|
+
if (job.status === "cancelled")
|
|
2980
|
+
throw new Error("Probe job is cancelled");
|
|
2981
|
+
if (job.status !== "claimed")
|
|
2982
|
+
throw new Error(`Probe job is not claimable for submission: ${job.status}`);
|
|
2983
|
+
if (job.claimedByProbeId !== probe.id)
|
|
2984
|
+
throw new Error("Probe job was claimed by another probe");
|
|
2985
|
+
if (job.fencingToken !== input.fencingToken)
|
|
2986
|
+
throw new Error("Probe job fencing token is invalid");
|
|
2987
|
+
if (!job.leaseExpiresAt || job.leaseExpiresAt <= new Date().toISOString())
|
|
2988
|
+
throw new Error("Probe job lease expired");
|
|
2989
|
+
const result = this.store.recordCheckResult({
|
|
2990
|
+
monitorId: monitor.id,
|
|
2991
|
+
checkedAt: input.checkedAt,
|
|
2992
|
+
status: input.status,
|
|
2993
|
+
latencyMs: input.latencyMs,
|
|
2994
|
+
statusCode: input.statusCode ?? null,
|
|
2995
|
+
error: input.error ?? null,
|
|
2996
|
+
evidence: input.evidence ?? null,
|
|
2997
|
+
attemptCount: input.attemptCount ?? 1,
|
|
2998
|
+
expectedMonitorRevision: input.monitorRevision
|
|
2999
|
+
});
|
|
3000
|
+
const receipt = store.recordProbeSubmission({
|
|
3001
|
+
probeId: probe.id,
|
|
3002
|
+
jobId: job.id,
|
|
3003
|
+
monitorId: monitor.id,
|
|
3004
|
+
checkResultId: result.id,
|
|
3005
|
+
nonce: input.nonce,
|
|
3006
|
+
checkedAt: input.checkedAt
|
|
3007
|
+
});
|
|
3008
|
+
store.completeProbeCheckJob({
|
|
3009
|
+
jobId: job.id,
|
|
3010
|
+
probeId: probe.id,
|
|
3011
|
+
fencingToken: input.fencingToken,
|
|
3012
|
+
checkResultId: result.id,
|
|
3013
|
+
submittedAt: receipt.submittedAt
|
|
3014
|
+
});
|
|
3015
|
+
store.touchProbeIdentity(probe.id, receipt.submittedAt);
|
|
3016
|
+
return { result, receipt };
|
|
3017
|
+
}
|
|
979
3018
|
}
|
|
980
3019
|
function createUptimeClient(options = {}) {
|
|
981
|
-
return new UptimeService(options);
|
|
3020
|
+
return new UptimeService({ mode: "local", ...options });
|
|
982
3021
|
}
|
|
983
3022
|
|
|
984
3023
|
class MonitorCheckBusyError extends Error {
|
|
@@ -987,6 +3026,61 @@ class MonitorCheckBusyError extends Error {
|
|
|
987
3026
|
this.name = "MonitorCheckBusyError";
|
|
988
3027
|
}
|
|
989
3028
|
}
|
|
3029
|
+
function enabledReportChannels(schedule) {
|
|
3030
|
+
return ["email", "sms", "logs"].filter((channel) => Boolean(schedule.channels[channel]));
|
|
3031
|
+
}
|
|
3032
|
+
function validateProbeSubmission(input) {
|
|
3033
|
+
if (!input.jobId.trim())
|
|
3034
|
+
throw new Error("Probe submission jobId is required");
|
|
3035
|
+
if (!input.scheduleSlot.trim())
|
|
3036
|
+
throw new Error("Probe submission scheduleSlot is required");
|
|
3037
|
+
if (!input.fencingToken.trim())
|
|
3038
|
+
throw new Error("Probe submission fencingToken is required");
|
|
3039
|
+
if (!input.nonce.trim())
|
|
3040
|
+
throw new Error("Probe submission nonce is required");
|
|
3041
|
+
if (input.nonce.length > 128)
|
|
3042
|
+
throw new Error("Probe submission nonce is too long");
|
|
3043
|
+
if (/[\x00-\x1f\x7f-\x9f]/.test(input.nonce))
|
|
3044
|
+
throw new Error("Probe submission nonce must not contain control characters");
|
|
3045
|
+
if (input.status !== "up" && input.status !== "down")
|
|
3046
|
+
throw new Error("Probe result status must be up or down");
|
|
3047
|
+
if (input.latencyMs !== null && (!Number.isFinite(input.latencyMs) || input.latencyMs < 0)) {
|
|
3048
|
+
throw new Error("Probe result latencyMs must be null or a non-negative number");
|
|
3049
|
+
}
|
|
3050
|
+
if (input.statusCode !== undefined && input.statusCode !== null && (!Number.isInteger(input.statusCode) || input.statusCode < 100 || input.statusCode > 599)) {
|
|
3051
|
+
throw new Error("Probe result statusCode must be an HTTP status from 100 to 599");
|
|
3052
|
+
}
|
|
3053
|
+
if (input.attemptCount !== undefined && (!Number.isInteger(input.attemptCount) || input.attemptCount < 1 || input.attemptCount > 20)) {
|
|
3054
|
+
throw new Error("Probe result attemptCount must be an integer from 1 to 20");
|
|
3055
|
+
}
|
|
3056
|
+
const monitorRevision = input.monitorRevision;
|
|
3057
|
+
if (!Number.isInteger(monitorRevision) || monitorRevision < 1) {
|
|
3058
|
+
throw new Error("Probe result monitorRevision is required");
|
|
3059
|
+
}
|
|
3060
|
+
const checkedAtMs = Date.parse(input.checkedAt);
|
|
3061
|
+
if (!Number.isFinite(checkedAtMs))
|
|
3062
|
+
throw new Error("Probe result checkedAt must be an ISO timestamp");
|
|
3063
|
+
const now = Date.now();
|
|
3064
|
+
if (checkedAtMs > now + MAX_PROBE_RESULT_FUTURE_MS)
|
|
3065
|
+
throw new Error("Probe result checkedAt is too far in the future");
|
|
3066
|
+
if (checkedAtMs < now - MAX_PROBE_RESULT_AGE_MS)
|
|
3067
|
+
throw new Error("Probe result checkedAt is too old");
|
|
3068
|
+
if (!input.signature.trim())
|
|
3069
|
+
throw new Error("Probe result signature is required");
|
|
3070
|
+
}
|
|
3071
|
+
function normalizeProbePublicKeyPem(publicKeyPem) {
|
|
3072
|
+
try {
|
|
3073
|
+
const key = createPublicKey(publicKeyPem);
|
|
3074
|
+
if (key.asymmetricKeyType !== "ed25519") {
|
|
3075
|
+
throw new Error("Probe public key must be an Ed25519 public key");
|
|
3076
|
+
}
|
|
3077
|
+
return key.export({ format: "pem", type: "spki" }).toString();
|
|
3078
|
+
} catch (error) {
|
|
3079
|
+
if (error instanceof Error && error.message.includes("Ed25519"))
|
|
3080
|
+
throw error;
|
|
3081
|
+
throw new Error("Probe public key must be a valid PEM Ed25519 public key");
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
990
3084
|
|
|
991
3085
|
// src/dashboard.ts
|
|
992
3086
|
function dashboardHtml() {
|
|
@@ -1204,7 +3298,7 @@ function dashboardHtml() {
|
|
|
1204
3298
|
clear(root);
|
|
1205
3299
|
for (const item of summary.monitors) {
|
|
1206
3300
|
const m = item.monitor;
|
|
1207
|
-
const target = m.kind === '
|
|
3301
|
+
const target = m.kind === 'tcp' ? m.host + ':' + m.port : m.url;
|
|
1208
3302
|
const incident = item.openIncident ? 'open since ' + new Date(item.openIncident.openedAt).toLocaleString() : '-';
|
|
1209
3303
|
const tr = document.createElement('tr');
|
|
1210
3304
|
const name = document.createElement('td');
|
|
@@ -1339,83 +3433,56 @@ function dashboardHtml() {
|
|
|
1339
3433
|
}
|
|
1340
3434
|
|
|
1341
3435
|
// src/api.ts
|
|
3436
|
+
import { timingSafeEqual } from "crypto";
|
|
1342
3437
|
function createApiHandler(service, options = {}) {
|
|
3438
|
+
const mode = options.mode ? resolveRuntimeMode(options.mode) : service.store.mode;
|
|
3439
|
+
if (mode !== service.store.mode) {
|
|
3440
|
+
throw new Error(`API mode ${mode} does not match store mode ${service.store.mode}`);
|
|
3441
|
+
}
|
|
1343
3442
|
return async (request) => {
|
|
1344
3443
|
const url = new URL(request.url);
|
|
1345
3444
|
try {
|
|
1346
|
-
validateLocalMutationRequest(request, url, options);
|
|
1347
|
-
if (request.method === "GET" && url.pathname === "/") {
|
|
1348
|
-
return html(dashboardHtml());
|
|
1349
|
-
}
|
|
1350
3445
|
if (request.method === "GET" && url.pathname === "/health") {
|
|
1351
|
-
return json({ ok: true, service: "uptime" });
|
|
1352
|
-
}
|
|
1353
|
-
if (request.method === "GET" && url.pathname === "/api/summary") {
|
|
1354
|
-
return json(service.summary());
|
|
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
|
-
}
|
|
1363
|
-
if (request.method === "GET" && url.pathname === "/api/monitors") {
|
|
1364
|
-
return json(service.listMonitors({ includeDisabled: url.searchParams.get("includeDisabled") === "true" }));
|
|
1365
|
-
}
|
|
1366
|
-
if (request.method === "POST" && url.pathname === "/api/monitors") {
|
|
1367
|
-
return json(service.createMonitor(await jsonBody(request)), 201);
|
|
3446
|
+
return json({ ok: true, service: "uptime", mode, dataMode: service.store.dataMode });
|
|
1368
3447
|
}
|
|
1369
|
-
if (
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
monitorId: url.searchParams.get("monitorId") ?? undefined,
|
|
1374
|
-
limit: numericParam(url, "limit", 50)
|
|
1375
|
-
}));
|
|
1376
|
-
}
|
|
1377
|
-
if (request.method === "GET" && url.pathname === "/api/results") {
|
|
1378
|
-
return json(service.listResults({
|
|
1379
|
-
monitorId: url.searchParams.get("monitorId") ?? undefined,
|
|
1380
|
-
limit: numericParam(url, "limit", 50)
|
|
1381
|
-
}));
|
|
1382
|
-
}
|
|
1383
|
-
if (request.method === "POST" && url.pathname === "/api/check-all") {
|
|
1384
|
-
return json(await service.checkAll());
|
|
3448
|
+
if (mode === "hosted") {
|
|
3449
|
+
return await handleHostedRequest(service, request, url, options);
|
|
3450
|
+
} else {
|
|
3451
|
+
validateLocalMutationRequest(request, url, options);
|
|
1385
3452
|
}
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
const id = decodeURIComponent(monitorMatch[1]);
|
|
1389
|
-
if (request.method === "GET" && !monitorMatch[2]) {
|
|
1390
|
-
const monitor = service.getMonitor(id);
|
|
1391
|
-
return monitor ? json(monitor) : json({ error: "not found" }, 404);
|
|
1392
|
-
}
|
|
1393
|
-
if (request.method === "PATCH" && !monitorMatch[2]) {
|
|
1394
|
-
return json(service.updateMonitor(id, await jsonBody(request)));
|
|
1395
|
-
}
|
|
1396
|
-
if (request.method === "DELETE" && !monitorMatch[2]) {
|
|
1397
|
-
return json({ deleted: service.deleteMonitor(id) });
|
|
1398
|
-
}
|
|
1399
|
-
if (request.method === "POST" && monitorMatch[2] === "check") {
|
|
1400
|
-
return json(await service.checkMonitor(id));
|
|
1401
|
-
}
|
|
3453
|
+
if (request.method === "GET" && url.pathname === "/") {
|
|
3454
|
+
return html(dashboardHtml());
|
|
1402
3455
|
}
|
|
1403
|
-
return
|
|
3456
|
+
return await handleApiRoute(service, request, url, url.pathname, options, false);
|
|
1404
3457
|
} catch (error) {
|
|
1405
3458
|
return json({ error: error instanceof Error ? error.message : String(error) }, error instanceof ApiError ? error.status : 400);
|
|
1406
3459
|
}
|
|
1407
3460
|
};
|
|
1408
3461
|
}
|
|
1409
3462
|
function serveUptime(options = {}) {
|
|
3463
|
+
const requestedMode = options.mode ? resolveRuntimeMode(options.mode) : options.service?.store.mode ?? "local";
|
|
3464
|
+
if (requestedMode === "hosted" && resolveHostedTokens(options).length === 0) {
|
|
3465
|
+
throw new Error("hosted mode requires HASNA_UPTIME_HOSTED_TOKEN or --hosted-token");
|
|
3466
|
+
}
|
|
1410
3467
|
const service = options.service ?? new UptimeService(options);
|
|
3468
|
+
const mode = service.store.mode;
|
|
3469
|
+
if (mode !== requestedMode) {
|
|
3470
|
+
throw new Error(`serve mode ${requestedMode} does not match store mode ${mode}`);
|
|
3471
|
+
}
|
|
3472
|
+
if (mode === "hosted" && options.check) {
|
|
3473
|
+
throw new Error("hosted scheduler requires check_jobs and probes");
|
|
3474
|
+
}
|
|
1411
3475
|
const scheduler = options.check ? service.startScheduler() : undefined;
|
|
1412
3476
|
const server = Bun.serve({
|
|
1413
3477
|
hostname: options.host ?? "127.0.0.1",
|
|
1414
3478
|
port: options.port ?? 3899,
|
|
1415
3479
|
fetch: createApiHandler(service, {
|
|
1416
3480
|
apiToken: options.apiToken,
|
|
3481
|
+
hostedToken: options.hostedToken,
|
|
3482
|
+
hostedTokens: options.hostedTokens,
|
|
1417
3483
|
allowUnsafeRemoteMutations: options.allowUnsafeRemoteMutations,
|
|
1418
|
-
trustedLoopback: isLoopbackHost(options.host ?? "127.0.0.1")
|
|
3484
|
+
trustedLoopback: isLoopbackHost(options.host ?? "127.0.0.1"),
|
|
3485
|
+
mode
|
|
1419
3486
|
})
|
|
1420
3487
|
});
|
|
1421
3488
|
return { server, service, scheduler };
|
|
@@ -1447,7 +3514,7 @@ function numericParam(url, name, fallback) {
|
|
|
1447
3514
|
function validateLocalMutationRequest(request, url, options) {
|
|
1448
3515
|
if (!["POST", "PATCH", "DELETE"].includes(request.method))
|
|
1449
3516
|
return;
|
|
1450
|
-
const apiToken = options.apiToken
|
|
3517
|
+
const apiToken = resolveApiToken(options.apiToken);
|
|
1451
3518
|
const hasToken = apiToken ? hasValidApiToken(request, apiToken) : false;
|
|
1452
3519
|
const allowUnsafeRemote = options.allowUnsafeRemoteMutations || process.env.HASNA_UPTIME_ALLOW_REMOTE_MUTATIONS === "1";
|
|
1453
3520
|
const trustedLoopback = options.trustedLoopback ?? isLoopbackHost(url.hostname);
|
|
@@ -1459,15 +3526,252 @@ function validateLocalMutationRequest(request, url, options) {
|
|
|
1459
3526
|
throw new ApiError("cross-origin mutation rejected", 403);
|
|
1460
3527
|
}
|
|
1461
3528
|
}
|
|
3529
|
+
async function handleHostedRequest(service, request, url, options) {
|
|
3530
|
+
if (url.pathname === "/") {
|
|
3531
|
+
requireHostedActor(request, url, options, "uptime:read");
|
|
3532
|
+
throw new ApiError("hosted dashboard requires the cloud dashboard shell", 501);
|
|
3533
|
+
}
|
|
3534
|
+
if (!url.pathname.startsWith("/api/v1/")) {
|
|
3535
|
+
requireHostedActor(request, url, options, "uptime:read");
|
|
3536
|
+
return json({ error: "not found" }, 404);
|
|
3537
|
+
}
|
|
3538
|
+
const apiPath = `/api${url.pathname.slice("/api/v1".length)}`;
|
|
3539
|
+
const scope = hostedScopeFor(request.method, apiPath);
|
|
3540
|
+
requireHostedActor(request, url, options, scope);
|
|
3541
|
+
if (["POST", "PATCH", "DELETE"].includes(request.method)) {
|
|
3542
|
+
const origin = request.headers.get("origin");
|
|
3543
|
+
if (origin && origin !== `${url.protocol}//${url.host}`) {
|
|
3544
|
+
throw new ApiError("cross-origin mutation rejected", 403);
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
return handleApiRoute(service, request, url, apiPath, options, true);
|
|
3548
|
+
}
|
|
3549
|
+
async function handleApiRoute(service, request, url, apiPath, options, hosted) {
|
|
3550
|
+
if (request.method === "GET" && apiPath === "/api/summary") {
|
|
3551
|
+
return json(service.summary());
|
|
3552
|
+
}
|
|
3553
|
+
if (request.method === "GET" && apiPath === "/api/report") {
|
|
3554
|
+
return json(service.buildReport());
|
|
3555
|
+
}
|
|
3556
|
+
if (request.method === "POST" && apiPath === "/api/report") {
|
|
3557
|
+
if (hosted)
|
|
3558
|
+
throw new ApiError("hosted report delivery requires configured channel refs", 501);
|
|
3559
|
+
const input = await jsonBody(request);
|
|
3560
|
+
return json(await service.sendReport({ ...input, fetchImpl: options.fetchImpl }));
|
|
3561
|
+
}
|
|
3562
|
+
if (hosted && (apiPath.startsWith("/api/report-schedules") || apiPath.startsWith("/api/report-runs") || apiPath.startsWith("/api/audit-events"))) {
|
|
3563
|
+
throw new ApiError("hosted report schedules require cloud channel refs, workspace stores, and audit logging", 501);
|
|
3564
|
+
}
|
|
3565
|
+
if (hosted && apiPath.startsWith("/api/probes")) {
|
|
3566
|
+
throw new ApiError("hosted probe APIs require cloud check_jobs, workspace stores, and audit logging", 501);
|
|
3567
|
+
}
|
|
3568
|
+
if (request.method === "GET" && apiPath === "/api/report-schedules") {
|
|
3569
|
+
return json(service.listReportSchedules({ includeDisabled: url.searchParams.get("includeDisabled") === "true" }));
|
|
3570
|
+
}
|
|
3571
|
+
if (request.method === "POST" && apiPath === "/api/report-schedules") {
|
|
3572
|
+
return json(service.createReportSchedule(await jsonBody(request)), 201);
|
|
3573
|
+
}
|
|
3574
|
+
if (request.method === "POST" && apiPath === "/api/report-schedules/run-due") {
|
|
3575
|
+
const input = await jsonBody(request);
|
|
3576
|
+
const now = input.now ? new Date(input.now) : new Date;
|
|
3577
|
+
return json(await service.runDueReportSchedules(now, { fetchImpl: options.fetchImpl }));
|
|
3578
|
+
}
|
|
3579
|
+
const reportScheduleRunMatch = apiPath.match(/^\/api\/report-schedules\/([^/]+)\/run$/);
|
|
3580
|
+
if (request.method === "POST" && reportScheduleRunMatch) {
|
|
3581
|
+
return json(await service.runReportSchedule(decodeURIComponent(reportScheduleRunMatch[1]), { fetchImpl: options.fetchImpl }));
|
|
3582
|
+
}
|
|
3583
|
+
const reportScheduleMatch = apiPath.match(/^\/api\/report-schedules\/([^/]+)$/);
|
|
3584
|
+
if (reportScheduleMatch) {
|
|
3585
|
+
const id = decodeURIComponent(reportScheduleMatch[1]);
|
|
3586
|
+
if (request.method === "GET") {
|
|
3587
|
+
const schedule = service.getReportSchedule(id);
|
|
3588
|
+
return schedule ? json(schedule) : json({ error: "not found" }, 404);
|
|
3589
|
+
}
|
|
3590
|
+
if (request.method === "PATCH") {
|
|
3591
|
+
return json(service.updateReportSchedule(id, await jsonBody(request)));
|
|
3592
|
+
}
|
|
3593
|
+
if (request.method === "DELETE") {
|
|
3594
|
+
return json({ deleted: service.deleteReportSchedule(id) });
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
if (request.method === "GET" && apiPath === "/api/report-runs") {
|
|
3598
|
+
return json(service.listReportRuns({
|
|
3599
|
+
scheduleId: url.searchParams.get("scheduleId") ?? undefined,
|
|
3600
|
+
limit: numericParam(url, "limit", 50)
|
|
3601
|
+
}));
|
|
3602
|
+
}
|
|
3603
|
+
if (request.method === "GET" && apiPath === "/api/audit-events") {
|
|
3604
|
+
return json(service.listAuditEvents({
|
|
3605
|
+
resourceType: url.searchParams.get("resourceType") ?? undefined,
|
|
3606
|
+
resourceId: url.searchParams.get("resourceId") ?? undefined,
|
|
3607
|
+
limit: numericParam(url, "limit", 50)
|
|
3608
|
+
}));
|
|
3609
|
+
}
|
|
3610
|
+
if (request.method === "GET" && apiPath === "/api/monitors") {
|
|
3611
|
+
return json(service.listMonitors({ includeDisabled: url.searchParams.get("includeDisabled") === "true" }));
|
|
3612
|
+
}
|
|
3613
|
+
if (request.method === "POST" && apiPath === "/api/monitors") {
|
|
3614
|
+
return json(service.createMonitor(await jsonBody(request)), 201);
|
|
3615
|
+
}
|
|
3616
|
+
if (request.method === "GET" && apiPath === "/api/incidents") {
|
|
3617
|
+
const status = url.searchParams.get("status");
|
|
3618
|
+
return json(service.listIncidents({
|
|
3619
|
+
status: status === "open" || status === "closed" ? status : undefined,
|
|
3620
|
+
monitorId: url.searchParams.get("monitorId") ?? undefined,
|
|
3621
|
+
limit: numericParam(url, "limit", 50)
|
|
3622
|
+
}));
|
|
3623
|
+
}
|
|
3624
|
+
if (request.method === "GET" && apiPath === "/api/results") {
|
|
3625
|
+
return json(service.listResults({
|
|
3626
|
+
monitorId: url.searchParams.get("monitorId") ?? undefined,
|
|
3627
|
+
limit: numericParam(url, "limit", 50)
|
|
3628
|
+
}));
|
|
3629
|
+
}
|
|
3630
|
+
if (request.method === "GET" && apiPath === "/api/probes") {
|
|
3631
|
+
return json(service.listProbes({ includeDisabled: url.searchParams.get("includeDisabled") === "true" }));
|
|
3632
|
+
}
|
|
3633
|
+
if (request.method === "POST" && apiPath === "/api/probes") {
|
|
3634
|
+
const input = await jsonBody(request);
|
|
3635
|
+
if (!input.publicKeyPem)
|
|
3636
|
+
throw new ApiError("API probe creation requires publicKeyPem; generate keys in the probe agent or CLI", 400);
|
|
3637
|
+
return json(service.createProbe(input), 201);
|
|
3638
|
+
}
|
|
3639
|
+
if (request.method === "POST" && apiPath === "/api/probes/jobs") {
|
|
3640
|
+
return json(service.createProbeCheckJob(await jsonBody(request)), 201);
|
|
3641
|
+
}
|
|
3642
|
+
const probeJobMatch = apiPath.match(/^\/api\/probes\/jobs\/([^/]+)$/);
|
|
3643
|
+
if (probeJobMatch) {
|
|
3644
|
+
const jobId = decodeURIComponent(probeJobMatch[1]);
|
|
3645
|
+
if (request.method === "GET") {
|
|
3646
|
+
const job = service.getProbeCheckJob(jobId);
|
|
3647
|
+
return job ? json({ ...job, fencingToken: null }) : json({ error: "not found" }, 404);
|
|
3648
|
+
}
|
|
3649
|
+
}
|
|
3650
|
+
const probeJobClaimMatch = apiPath.match(/^\/api\/probes\/jobs\/([^/]+)\/claim$/);
|
|
3651
|
+
if (request.method === "POST" && probeJobClaimMatch) {
|
|
3652
|
+
const input = await jsonBody(request);
|
|
3653
|
+
return json(service.claimProbeCheckJob({
|
|
3654
|
+
jobId: decodeURIComponent(probeJobClaimMatch[1]),
|
|
3655
|
+
probeId: input.probeId,
|
|
3656
|
+
leaseTtlMs: input.leaseTtlMs
|
|
3657
|
+
}));
|
|
3658
|
+
}
|
|
3659
|
+
if (request.method === "POST" && apiPath === "/api/probes/results") {
|
|
3660
|
+
return json(service.submitProbeResult(await jsonBody(request)), 201);
|
|
3661
|
+
}
|
|
3662
|
+
if (request.method === "POST" && apiPath === "/api/imports/preview") {
|
|
3663
|
+
return json(service.previewImport(await jsonBody(request)));
|
|
3664
|
+
}
|
|
3665
|
+
if (request.method === "POST" && apiPath === "/api/imports/apply") {
|
|
3666
|
+
if (hosted)
|
|
3667
|
+
throw new ApiError("hosted import apply requires cloud import_batches and audit", 501);
|
|
3668
|
+
return json(service.applyImport(await jsonBody(request)), 201);
|
|
3669
|
+
}
|
|
3670
|
+
const importRollbackMatch = apiPath.match(/^\/api\/imports\/([^/]+)\/rollback$/);
|
|
3671
|
+
if (request.method === "POST" && importRollbackMatch) {
|
|
3672
|
+
if (hosted)
|
|
3673
|
+
throw new ApiError("hosted import rollback requires cloud import_batches and audit", 501);
|
|
3674
|
+
return json(service.rollbackImport(decodeURIComponent(importRollbackMatch[1])));
|
|
3675
|
+
}
|
|
3676
|
+
if (request.method === "POST" && apiPath === "/api/check-all") {
|
|
3677
|
+
if (hosted)
|
|
3678
|
+
throw new ApiError("hosted checks require check_jobs and probes", 501);
|
|
3679
|
+
return json(await service.checkAll());
|
|
3680
|
+
}
|
|
3681
|
+
const monitorMatch = apiPath.match(/^\/api\/monitors\/([^/]+)(?:\/(check))?$/);
|
|
3682
|
+
if (monitorMatch) {
|
|
3683
|
+
const id = decodeURIComponent(monitorMatch[1]);
|
|
3684
|
+
if (request.method === "GET" && !monitorMatch[2]) {
|
|
3685
|
+
const monitor = service.getMonitor(id);
|
|
3686
|
+
return monitor ? json(monitor) : json({ error: "not found" }, 404);
|
|
3687
|
+
}
|
|
3688
|
+
if (request.method === "PATCH" && !monitorMatch[2]) {
|
|
3689
|
+
return json(service.updateMonitor(id, await jsonBody(request)));
|
|
3690
|
+
}
|
|
3691
|
+
if (request.method === "DELETE" && !monitorMatch[2]) {
|
|
3692
|
+
return json({ deleted: service.deleteMonitor(id) });
|
|
3693
|
+
}
|
|
3694
|
+
if (request.method === "POST" && monitorMatch[2] === "check") {
|
|
3695
|
+
if (hosted)
|
|
3696
|
+
throw new ApiError("hosted checks require check_jobs and probes", 501);
|
|
3697
|
+
return json(await service.checkMonitor(id));
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
return json({ error: "not found" }, 404);
|
|
3701
|
+
}
|
|
3702
|
+
function hostedScopeFor(method, apiPath) {
|
|
3703
|
+
if (method === "POST" && apiPath === "/api/report")
|
|
3704
|
+
return "uptime:report";
|
|
3705
|
+
if (apiPath.startsWith("/api/report-schedules") || apiPath.startsWith("/api/report-runs"))
|
|
3706
|
+
return method === "GET" ? "uptime:read" : "uptime:report";
|
|
3707
|
+
if (apiPath.startsWith("/api/audit-events"))
|
|
3708
|
+
return method === "GET" ? "uptime:read" : "uptime:admin";
|
|
3709
|
+
if (apiPath.startsWith("/api/probes"))
|
|
3710
|
+
return method === "GET" ? "uptime:read" : "uptime:probe";
|
|
3711
|
+
if (method === "POST" && (apiPath === "/api/check-all" || /\/check$/.test(apiPath)))
|
|
3712
|
+
return "uptime:probe";
|
|
3713
|
+
if (method === "GET")
|
|
3714
|
+
return "uptime:read";
|
|
3715
|
+
if (method === "POST" || method === "PATCH" || method === "DELETE")
|
|
3716
|
+
return "uptime:write";
|
|
3717
|
+
return "uptime:read";
|
|
3718
|
+
}
|
|
3719
|
+
function requireHostedActor(request, url, options, scope) {
|
|
3720
|
+
const tokens = resolveHostedTokens(options);
|
|
3721
|
+
if (tokens.length === 0)
|
|
3722
|
+
throw new ApiError("hosted auth token is not configured", 503);
|
|
3723
|
+
const candidate = bearerToken(request) ?? request.headers.get("x-uptime-hosted-token")?.trim();
|
|
3724
|
+
const token = candidate ? tokens.find((entry) => safeTokenEqual(candidate, entry.token)) : undefined;
|
|
3725
|
+
if (!token)
|
|
3726
|
+
throw new ApiError("authentication required", 401);
|
|
3727
|
+
const scopes = new Set(token.scopes);
|
|
3728
|
+
if (!scopes.has(scope) && !scopes.has("uptime:admin")) {
|
|
3729
|
+
throw new ApiError("insufficient scope", 403);
|
|
3730
|
+
}
|
|
3731
|
+
const workspaceId = token.workspaceId ?? "default";
|
|
3732
|
+
const requestedWorkspace = request.headers.get("x-uptime-workspace")?.trim() || url.searchParams.get("workspaceId")?.trim();
|
|
3733
|
+
if (requestedWorkspace && requestedWorkspace !== workspaceId) {
|
|
3734
|
+
throw new ApiError("workspace access denied", 403);
|
|
3735
|
+
}
|
|
3736
|
+
return { scopes, workspaceId };
|
|
3737
|
+
}
|
|
1462
3738
|
function isLoopbackHost(hostname) {
|
|
1463
3739
|
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
1464
3740
|
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
1465
3741
|
}
|
|
1466
3742
|
function hasValidApiToken(request, token) {
|
|
1467
|
-
const
|
|
1468
|
-
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
|
|
3743
|
+
const bearer = bearerToken(request);
|
|
1469
3744
|
const headerToken = request.headers.get("x-uptime-token")?.trim();
|
|
1470
|
-
return bearer
|
|
3745
|
+
return safeTokenEqual(bearer, token) || safeTokenEqual(headerToken, token);
|
|
3746
|
+
}
|
|
3747
|
+
function bearerToken(request) {
|
|
3748
|
+
const authorization = request.headers.get("authorization") ?? "";
|
|
3749
|
+
return authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
|
|
3750
|
+
}
|
|
3751
|
+
function resolveApiToken(token) {
|
|
3752
|
+
const value = token ?? process.env.HASNA_UPTIME_API_TOKEN;
|
|
3753
|
+
return value?.trim() || undefined;
|
|
3754
|
+
}
|
|
3755
|
+
function resolveHostedTokens(options) {
|
|
3756
|
+
if (options.hostedTokens?.length)
|
|
3757
|
+
return options.hostedTokens;
|
|
3758
|
+
const token = options.hostedToken ?? process.env.HASNA_UPTIME_HOSTED_TOKEN;
|
|
3759
|
+
if (!token?.trim())
|
|
3760
|
+
return [];
|
|
3761
|
+
return [{
|
|
3762
|
+
token: token.trim(),
|
|
3763
|
+
scopes: ["uptime:read", "uptime:write", "uptime:probe", "uptime:report"],
|
|
3764
|
+
workspaceId: process.env.HASNA_UPTIME_WORKSPACE_ID ?? "default"
|
|
3765
|
+
}];
|
|
3766
|
+
}
|
|
3767
|
+
function safeTokenEqual(candidate, expected) {
|
|
3768
|
+
if (!candidate)
|
|
3769
|
+
return false;
|
|
3770
|
+
const candidateBytes = Buffer.from(candidate);
|
|
3771
|
+
const expectedBytes = Buffer.from(expected);
|
|
3772
|
+
if (candidateBytes.length !== expectedBytes.length)
|
|
3773
|
+
return false;
|
|
3774
|
+
return timingSafeEqual(candidateBytes, expectedBytes);
|
|
1471
3775
|
}
|
|
1472
3776
|
async function jsonBody(request) {
|
|
1473
3777
|
const contentType = request.headers.get("content-type") ?? "";
|
|
@@ -1486,17 +3790,27 @@ class ApiError extends Error {
|
|
|
1486
3790
|
}
|
|
1487
3791
|
}
|
|
1488
3792
|
export {
|
|
3793
|
+
verifyProbeResultSignature,
|
|
3794
|
+
uptimeHostedFallbackDbPath,
|
|
1489
3795
|
uptimeHome,
|
|
1490
3796
|
uptimeDbPath,
|
|
3797
|
+
signProbeResult,
|
|
1491
3798
|
serveUptime,
|
|
1492
3799
|
sendUptimeReport,
|
|
1493
3800
|
runTcpCheck,
|
|
1494
3801
|
runMonitorCheck,
|
|
1495
3802
|
runHttpCheck,
|
|
3803
|
+
runBrowserPageCheck,
|
|
3804
|
+
rollbackImport,
|
|
3805
|
+
probeResultSigningPayload,
|
|
3806
|
+
probePublicKeyFingerprint,
|
|
3807
|
+
previewImport,
|
|
3808
|
+
generateProbeKeyPair,
|
|
1496
3809
|
ensureUptimeHome,
|
|
1497
3810
|
createUptimeClient,
|
|
1498
3811
|
createApiHandler,
|
|
1499
3812
|
buildUptimeReport,
|
|
3813
|
+
applyImport,
|
|
1500
3814
|
UptimeStore,
|
|
1501
3815
|
UptimeService
|
|
1502
3816
|
};
|