pi-antigravity-rotator 1.9.2 → 1.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.3] - 2026-04-29
4
+
5
+ ### Added
6
+ - **Admin Broadcast Notifications**: The dashboard now supports operator-controlled broadcast notifications. A new `notification-poller` checks the telemetry server every 30 minutes for active announcements, allowing operators to push critical alerts (like required re-enrollments or bug notices) to all connected clients.
7
+ - **Admin Notification UI**: The telemetry receiver now includes a full dashboard at `/notifications` to create, edit, delete, and preview broadcast messages with version-targeting capabilities.
8
+
9
+ ### Changed
10
+ - **Telemetry Heartbeat**: Reduced the telemetry heartbeat interval from 6 hours to 1 hour for more timely metrics reporting.
11
+
3
12
  ## [1.9.2] - 2026-04-29
4
13
 
5
14
  ### Fixed
package/README.md CHANGED
@@ -19,6 +19,7 @@ Multi-account rotation proxy for Google Antigravity. Distributes API usage acros
19
19
  - **Advanced Telemetry & Statistics** -- Track exactly how much USD you save compared to a paid API plan, predict quota depletion with the Forecast grid, view Latency tracking (p50/p95), and explore 60-day historical usage heatmaps
20
20
  - **Web dashboard** -- Real-time view of model routing table, per-account quota bars with per-model timers, and flagged account alerts
21
21
  - **Auto-update notifications** -- The dashboard checks npm for new releases every 30 minutes and shows a banner with one-click update when a newer version is available
22
+ - **Broadcast notifications** -- Operator-controlled announcements and alerts delivered directly to the dashboard
22
23
  - **State persistence** -- Survives restarts; routing assignments, per-model request counters, cooldowns, and flags are saved to disk
23
24
 
24
25
  ## Quick Start
@@ -360,7 +361,7 @@ pi-antigravity-rotator collects **anonymous usage telemetry** to help understand
360
361
 
361
362
  ### What is collected
362
363
 
363
- **Heartbeat** (at boot, every 6h, at shutdown):
364
+ **Heartbeat** (at boot, every 1h, at shutdown):
364
365
  - Random install ID (UUID — not tied to any account or person)
365
366
  - Rotator version, Node.js version, OS, architecture
366
367
  - Account count, models in use, total request count, uptime
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-antigravity-rotator",
3
- "version": "1.9.2",
3
+ "version": "1.9.3",
4
4
  "description": "Multi-account rotation proxy for Google Antigravity with per-model routing, real-time quota tracking, and infringement detection",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/dashboard.ts CHANGED
@@ -1033,6 +1033,120 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
1033
1033
  .btn-update-dismiss:hover {
1034
1034
  color: var(--text);
1035
1035
  }
1036
+
1037
+ /* ── Admin Notification Banners ── */
1038
+ .notif-container {
1039
+ display: flex;
1040
+ flex-direction: column;
1041
+ gap: 10px;
1042
+ margin-bottom: 2px;
1043
+ }
1044
+
1045
+ .notif-banner {
1046
+ display: flex;
1047
+ align-items: flex-start;
1048
+ gap: 12px;
1049
+ padding: 12px 18px;
1050
+ border-radius: var(--radius);
1051
+ animation: bannerSlideIn 0.4s ease-out;
1052
+ }
1053
+
1054
+ .notif-banner.notif-info {
1055
+ background: linear-gradient(135deg, rgba(96, 165, 250, 0.10), rgba(99, 179, 237, 0.06));
1056
+ border: 1px solid rgba(96, 165, 250, 0.30);
1057
+ }
1058
+
1059
+ .notif-banner.notif-warning {
1060
+ background: linear-gradient(135deg, rgba(251, 191, 36, 0.10), rgba(246, 224, 94, 0.06));
1061
+ border: 1px solid rgba(251, 191, 36, 0.30);
1062
+ }
1063
+
1064
+ .notif-banner.notif-critical {
1065
+ background: linear-gradient(135deg, rgba(248, 113, 113, 0.12), rgba(252, 129, 129, 0.06));
1066
+ border: 1px solid rgba(248, 113, 113, 0.35);
1067
+ animation: bannerSlideIn 0.4s ease-out, notifPulse 3s ease-in-out 1;
1068
+ }
1069
+
1070
+ @keyframes notifPulse {
1071
+ 0%, 100% { box-shadow: none; }
1072
+ 50% { box-shadow: 0 0 16px rgba(248, 113, 113, 0.25); }
1073
+ }
1074
+
1075
+ .notif-icon {
1076
+ font-size: 18px;
1077
+ flex-shrink: 0;
1078
+ margin-top: 1px;
1079
+ }
1080
+
1081
+ .notif-content {
1082
+ flex: 1;
1083
+ min-width: 0;
1084
+ }
1085
+
1086
+ .notif-title {
1087
+ font-weight: 700;
1088
+ font-size: 13px;
1089
+ margin-bottom: 3px;
1090
+ }
1091
+
1092
+ .notif-info .notif-title { color: var(--blue); }
1093
+ .notif-warning .notif-title { color: var(--yellow); }
1094
+ .notif-critical .notif-title { color: var(--red); }
1095
+
1096
+ .notif-msg {
1097
+ font-size: 12px;
1098
+ color: var(--text-dim);
1099
+ line-height: 1.45;
1100
+ }
1101
+
1102
+ .notif-actions {
1103
+ display: flex;
1104
+ gap: 8px;
1105
+ margin-top: 8px;
1106
+ align-items: center;
1107
+ }
1108
+
1109
+ .notif-action-btn {
1110
+ display: inline-block;
1111
+ padding: 4px 12px;
1112
+ border-radius: 6px;
1113
+ font-size: 11px;
1114
+ font-weight: 600;
1115
+ text-decoration: none;
1116
+ border: 1px solid rgba(255,255,255,0.15);
1117
+ color: var(--text);
1118
+ background: rgba(255,255,255,0.06);
1119
+ cursor: pointer;
1120
+ font-family: var(--font);
1121
+ transition: background 0.2s;
1122
+ }
1123
+
1124
+ .notif-action-btn:hover {
1125
+ background: rgba(255,255,255,0.12);
1126
+ }
1127
+
1128
+ .notif-dismiss {
1129
+ background: none;
1130
+ border: none;
1131
+ color: var(--text-dim);
1132
+ cursor: pointer;
1133
+ font-size: 16px;
1134
+ padding: 4px;
1135
+ line-height: 1;
1136
+ opacity: 0.6;
1137
+ transition: opacity 0.2s;
1138
+ flex-shrink: 0;
1139
+ margin-left: auto;
1140
+ }
1141
+
1142
+ .notif-dismiss:hover {
1143
+ opacity: 1;
1144
+ color: var(--text);
1145
+ }
1146
+
1147
+ .notif-bell-dot {
1148
+ background: var(--yellow);
1149
+ }
1036
1150
  </style>
1037
1151
  </head>
1038
1152
  <body>
@@ -1043,6 +1157,8 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
1043
1157
  <div class="update-banner-actions" id="updateActions"></div>
1044
1158
  </div>
1045
1159
 
1160
+ <div class="notif-container" id="notifContainer"></div>
1161
+
1046
1162
  <div class="header">
1047
1163
  <div class="header-main">
1048
1164
  <div class="header-title-row">
@@ -1402,6 +1518,7 @@ function renderAccounts(data) {
1402
1518
  '<div class="ops-warning">' + freshPolicyHint + '</div>';
1403
1519
 
1404
1520
  renderUpdateBanner(data.updateInfo);
1521
+ renderNotifications(data.notifications);
1405
1522
  renderAttentionPanel(data);
1406
1523
  renderTokenChart(data.tokenUsage);
1407
1524
  renderHeatmap(data.tokenUsage);
@@ -2576,6 +2693,76 @@ function clearPendingRestart() {
2576
2693
  var banner = document.getElementById('updateBanner');
2577
2694
  if (banner) banner.className = 'update-banner';
2578
2695
  }
2696
+
2697
+ // ── Admin Notifications ──
2698
+ var NOTIF_ICONS = { info: '\u{2139}\ufe0f', warning: '\u26a0\ufe0f', critical: '\u{1f6a8}' };
2699
+
2700
+ function renderNotifications(notifications) {
2701
+ var container = document.getElementById('notifContainer');
2702
+ if (!container) return;
2703
+ if (!notifications || notifications.length === 0) {
2704
+ container.innerHTML = '';
2705
+ updateNotifBellBadge(0);
2706
+ return;
2707
+ }
2708
+
2709
+ var visibleCount = 0;
2710
+ var html = '';
2711
+ for (var i = 0; i < notifications.length; i++) {
2712
+ var n = notifications[i];
2713
+ // Check if user dismissed this notification
2714
+ if (localStorage.getItem('notif-dismissed-' + n.id)) continue;
2715
+ visibleCount++;
2716
+ var icon = NOTIF_ICONS[n.type] || NOTIF_ICONS.info;
2717
+ var typeClass = 'notif-' + (n.type || 'info');
2718
+ html += '<div class="notif-banner ' + typeClass + '" id="notif-' + escapeHtml(n.id) + '">';
2719
+ html += '<span class="notif-icon">' + icon + '</span>';
2720
+ html += '<div class="notif-content">';
2721
+ html += '<div class="notif-title">' + escapeHtml(n.title) + '</div>';
2722
+ html += '<div class="notif-msg">' + escapeHtml(n.message) + '</div>';
2723
+ if (n.actionUrl) {
2724
+ html += '<div class="notif-actions">';
2725
+ html += '<a class="notif-action-btn" href="' + escapeHtml(n.actionUrl) + '" target="_blank">' + escapeHtml(n.actionLabel || 'Learn More') + '</a>';
2726
+ html += '</div>';
2727
+ }
2728
+ html += '</div>';
2729
+ html += '<button class="notif-dismiss" onclick="dismissNotification(\\'' + escapeHtml(n.id) + '\\')" title="Dismiss">&times;</button>';
2730
+ html += '</div>';
2731
+ }
2732
+ container.innerHTML = html;
2733
+ updateNotifBellBadge(visibleCount);
2734
+ }
2735
+
2736
+ function dismissNotification(id) {
2737
+ localStorage.setItem('notif-dismissed-' + id, '1');
2738
+ var el = document.getElementById('notif-' + id);
2739
+ if (el) {
2740
+ el.style.opacity = '0';
2741
+ el.style.transform = 'translateY(-10px)';
2742
+ el.style.transition = 'opacity 0.3s, transform 0.3s';
2743
+ setTimeout(function() { el.remove(); }, 300);
2744
+ }
2745
+ // Recount visible
2746
+ var container = document.getElementById('notifContainer');
2747
+ if (container) {
2748
+ var remaining = container.querySelectorAll('.notif-banner').length - 1;
2749
+ updateNotifBellBadge(Math.max(0, remaining));
2750
+ }
2751
+ }
2752
+
2753
+ function updateNotifBellBadge(count) {
2754
+ // Update the attention bell badge if it exists
2755
+ var bellBtn = document.querySelector('.header-icon-btn.attention');
2756
+ if (!bellBtn) return;
2757
+ var badge = bellBtn.querySelector('.header-icon-badge');
2758
+ if (count > 0) {
2759
+ bellBtn.classList.add('has-items');
2760
+ if (badge) {
2761
+ badge.textContent = String(count);
2762
+ badge.style.display = '';
2763
+ }
2764
+ }
2765
+ }
2579
2766
  </script>
2580
2767
  </body>
2581
2768
  </html>`;
@@ -0,0 +1,123 @@
1
+ // Notification poller — fetches admin broadcast notifications from the telemetry server
2
+ //
3
+ // Polls GET /v1/notifications?version=x.y.z every 30 minutes.
4
+ // Respects PI_ROTATOR_TELEMETRY=off — if telemetry is disabled, no polling.
5
+ // Fire-and-forget: never throws, never blocks, never affects core rotator flow.
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { join, dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { logger } from "./logger.js";
11
+ import { isTelemetryEnabled } from "./telemetry.js";
12
+
13
+ const notifLogger = logger.child("notifications");
14
+
15
+ // Same base URL as telemetry endpoint (just different path)
16
+ const TELEMETRY_BASE = "http://telemetry.dragont.ec:3800";
17
+ const NOTIFICATIONS_URL = `${TELEMETRY_BASE}/v1/notifications`;
18
+
19
+ const POLL_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
20
+ const FETCH_TIMEOUT_MS = 10_000;
21
+
22
+ export interface AdminNotification {
23
+ id: string;
24
+ type: "info" | "warning" | "critical";
25
+ title: string;
26
+ message: string;
27
+ createdAt: string;
28
+ actionUrl?: string | null;
29
+ actionLabel?: string | null;
30
+ }
31
+
32
+ // ── Version ──────────────────────────────────────────────────────────
33
+ let _version: string | null = null;
34
+ function getVersion(): string {
35
+ if (_version) return _version;
36
+ try {
37
+ const __dirname = dirname(fileURLToPath(import.meta.url));
38
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
39
+ _version = pkg.version ?? "unknown";
40
+ } catch {
41
+ _version = "unknown";
42
+ }
43
+ return _version!;
44
+ }
45
+
46
+ // ── Cached notifications ─────────────────────────────────────────────
47
+ let _notifications: AdminNotification[] = [];
48
+ let _pollTimer: ReturnType<typeof setInterval> | null = null;
49
+
50
+ /**
51
+ * Fetch notifications from the telemetry server.
52
+ */
53
+ async function fetchNotifications(): Promise<void> {
54
+ try {
55
+ const version = getVersion();
56
+ const url = version !== "unknown"
57
+ ? `${NOTIFICATIONS_URL}?version=${encodeURIComponent(version)}`
58
+ : NOTIFICATIONS_URL;
59
+
60
+ const response = await fetch(url, {
61
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
62
+ });
63
+
64
+ if (!response.ok) {
65
+ notifLogger.log("info", `Notification poll returned ${response.status}`);
66
+ return;
67
+ }
68
+
69
+ const data = (await response.json()) as AdminNotification[];
70
+ if (Array.isArray(data)) {
71
+ _notifications = data;
72
+ if (data.length > 0) {
73
+ notifLogger.log("debug", `Fetched ${data.length} notification(s)`);
74
+ }
75
+ }
76
+ } catch {
77
+ // Fire-and-forget: network errors are expected (offline, server down, etc.)
78
+ // Never crash, never surface errors to user.
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Get the cached notifications. Returns immediately.
84
+ */
85
+ export function getNotifications(): AdminNotification[] {
86
+ return [..._notifications];
87
+ }
88
+
89
+ /**
90
+ * Start periodic notification polling.
91
+ * Initial fetch after a short delay (15s) to avoid slowing down startup.
92
+ */
93
+ export function startNotificationPoller(): void {
94
+ if (!isTelemetryEnabled()) {
95
+ notifLogger.log("debug", "Notification polling disabled (telemetry off)");
96
+ return;
97
+ }
98
+
99
+ // Initial delayed fetch
100
+ setTimeout(() => {
101
+ void fetchNotifications();
102
+ }, 15_000);
103
+
104
+ // Periodic poll
105
+ _pollTimer = setInterval(() => {
106
+ void fetchNotifications();
107
+ }, POLL_INTERVAL_MS);
108
+
109
+ // Don't prevent process exit
110
+ if (_pollTimer.unref) {
111
+ _pollTimer.unref();
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Stop notification polling.
117
+ */
118
+ export function stopNotificationPoller(): void {
119
+ if (_pollTimer) {
120
+ clearInterval(_pollTimer);
121
+ _pollTimer = null;
122
+ }
123
+ }
package/src/proxy.ts CHANGED
@@ -21,6 +21,7 @@ import { logger } from "./logger.js";
21
21
  import { trackFeature, reportFlagEvent, FLAG_PATTERNS, type FlagPattern } from "./telemetry.js";
22
22
  import type { FlagEventData } from "./telemetry.js";
23
23
  import { startVersionChecker, performSelfUpdate } from "./version-check.js";
24
+ import { startNotificationPoller } from "./notification-poller.js";
24
25
 
25
26
  const proxyLogger = logger.child("proxy");
26
27
 
@@ -632,6 +633,7 @@ function flattenHeaders(headers: IncomingMessage["headers"]): Record<string, str
632
633
 
633
634
  export function startProxy(rotator: AccountRotator, port: number): void {
634
635
  startVersionChecker();
636
+ startNotificationPoller();
635
637
  const sseClients = new Set<ServerResponse>();
636
638
  let sseBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
637
639
  const SSE_THROTTLE_MS = 1000; // max 1 push/second
package/src/rotator.ts CHANGED
@@ -32,6 +32,7 @@ import { getOAuthClientConfig } from "./oauth.js";
32
32
  import { fetchWithRetry } from "./fetch-with-retry.js";
33
33
  import { logger } from "./logger.js";
34
34
  import { getUpdateInfo } from "./version-check.js";
35
+ import { getNotifications } from "./notification-poller.js";
35
36
 
36
37
  const rotatorLogger = logger.child("rotator");
37
38
 
@@ -1391,6 +1392,7 @@ export class AccountRotator {
1391
1392
  tokenUsage: this.getTokenUsage(),
1392
1393
  latencyStats: this.getLatencyStats(),
1393
1394
  updateInfo: getUpdateInfo(),
1395
+ notifications: getNotifications(),
1394
1396
  };
1395
1397
  }
1396
1398
 
package/src/telemetry.ts CHANGED
@@ -27,7 +27,7 @@ const telemetryLogger = logger.child("telemetry");
27
27
  // Update this URL to your VPS before publishing to npm.
28
28
  const TELEMETRY_ENDPOINT = "http://telemetry.dragont.ec:3800/v1/events";
29
29
 
30
- const HEARTBEAT_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
30
+ const HEARTBEAT_INTERVAL_MS = 1 * 60 * 60 * 1000; // 1 hour
31
31
  const SEND_TIMEOUT_MS = 5000;
32
32
 
33
33
  // ── Known flag patterns (same list used in proxy.ts for detection) ───
package/src/types.ts CHANGED
@@ -225,6 +225,17 @@ export interface UpdateInfo {
225
225
  checkedAt: number;
226
226
  }
227
227
 
228
+ // Admin broadcast notification
229
+ export interface AdminNotification {
230
+ id: string;
231
+ type: "info" | "warning" | "critical";
232
+ title: string;
233
+ message: string;
234
+ createdAt: string;
235
+ actionUrl?: string | null;
236
+ actionLabel?: string | null;
237
+ }
238
+
228
239
  // Dashboard API response
229
240
  export interface StatusResponse {
230
241
  proxyPort: number;
@@ -264,6 +275,7 @@ export interface StatusResponse {
264
275
  tokenUsage: TokenUsageData;
265
276
  latencyStats: Record<string, { ttfb: { p50: number; p95: number }; total: { p50: number; p95: number }; count: number }>;
266
277
  updateInfo?: UpdateInfo;
278
+ notifications?: AdminNotification[];
267
279
  }
268
280
 
269
281
  export interface AccountStatus {
@@ -23,12 +23,14 @@
23
23
  import { createServer } from "node:http";
24
24
  import {
25
25
  appendFileSync,
26
+ writeFileSync,
26
27
  mkdirSync,
27
28
  existsSync,
28
29
  readdirSync,
29
30
  readFileSync,
30
31
  } from "node:fs";
31
32
  import { join } from "node:path";
33
+ import { randomUUID } from "node:crypto";
32
34
 
33
35
  const PORT = parseInt(process.env.PORT || "3800", 10);
34
36
  const DATA_DIR = process.env.DATA_DIR || "./data";
@@ -36,6 +38,68 @@ const STATS_TOKEN = process.env.STATS_TOKEN || "";
36
38
 
37
39
  if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
38
40
 
41
+ // ── Notifications Storage ────────────────────────────────────────────
42
+ const NOTIFICATIONS_FILE = join(DATA_DIR, "notifications.json");
43
+
44
+ function loadNotifications() {
45
+ try {
46
+ if (existsSync(NOTIFICATIONS_FILE)) {
47
+ return JSON.parse(readFileSync(NOTIFICATIONS_FILE, "utf-8"));
48
+ }
49
+ } catch { /* corrupted file, start fresh */ }
50
+ return [];
51
+ }
52
+
53
+ function saveNotifications(notifications) {
54
+ writeFileSync(NOTIFICATIONS_FILE, JSON.stringify(notifications, null, 2), "utf-8");
55
+ }
56
+
57
+ /**
58
+ * Simple semver comparison: returns true if a < b.
59
+ */
60
+ function semverLt(a, b) {
61
+ const pa = a.split(".").map(Number);
62
+ const pb = b.split(".").map(Number);
63
+ for (let i = 0; i < 3; i++) {
64
+ const av = pa[i] ?? 0;
65
+ const bv = pb[i] ?? 0;
66
+ if (av < bv) return true;
67
+ if (av > bv) return false;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ function semverLte(a, b) {
73
+ return a === b || semverLt(a, b);
74
+ }
75
+
76
+ /**
77
+ * Filter notifications for a client with a given version.
78
+ * Removes expired notifications and applies version targeting.
79
+ */
80
+ function getActiveNotifications(clientVersion) {
81
+ const all = loadNotifications();
82
+ const now = new Date().toISOString();
83
+ return all.filter((n) => {
84
+ // Filter expired
85
+ if (n.expiresAt && n.expiresAt < now) return false;
86
+ // Version targeting
87
+ if (clientVersion) {
88
+ if (n.minVersion && semverLt(clientVersion, n.minVersion)) return false;
89
+ if (n.maxVersion && !semverLte(clientVersion, n.maxVersion)) return false;
90
+ }
91
+ return true;
92
+ }).map((n) => ({
93
+ id: n.id,
94
+ type: n.type || "info",
95
+ title: n.title,
96
+ message: n.message,
97
+ createdAt: n.createdAt,
98
+ actionUrl: n.actionUrl || null,
99
+ actionLabel: n.actionLabel || null,
100
+ }));
101
+ }
102
+
39
103
  // ── Validation ───────────────────────────────────────────────────────
40
104
  const ALLOWED_EVENTS = new Set(["boot", "heartbeat", "shutdown", "flag"]);
41
105
  const MAX_BODY_BYTES = 4096;
@@ -690,6 +754,357 @@ setInterval(()=>{if(_token){const f={};const i=$('fInstall').value;if(i)f.instal
690
754
  </script>
691
755
  </body></html>`;
692
756
  }
757
+
758
+ // ── Notifications Admin UI ───────────────────────────────────────────
759
+ function buildNotificationsAdminHtml() {
760
+ return `<!DOCTYPE html>
761
+ <html lang="en">
762
+ <head>
763
+ <meta charset="UTF-8">
764
+ <meta name="viewport" content="width=device-width,initial-scale=1.0">
765
+ <title>Pi Rotator — Notification Manager</title>
766
+ <style>
767
+ *{box-sizing:border-box;margin:0;padding:0}
768
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f1117;color:#e2e8f0;min-height:100vh}
769
+ .header{background:#1a1f2e;border-bottom:1px solid #2d3748;padding:14px 24px;display:flex;align-items:center;gap:12px}
770
+ .header h1{font-size:17px;font-weight:700;color:#fff}
771
+ .header .nav{margin-left:auto;display:flex;gap:10px;align-items:center}
772
+ .header .nav a{color:#718096;font-size:13px;text-decoration:none;padding:4px 10px;border-radius:6px;transition:color .2s,background .2s}
773
+ .header .nav a:hover{color:#e2e8f0;background:rgba(255,255,255,.06)}
774
+ .token-bar{background:#1a1f2e;border-bottom:1px solid #2d3748;padding:10px 24px;display:flex;gap:8px;align-items:center}
775
+ .token-bar input[type=password]{flex:1;background:#0f1117;border:1px solid #2d3748;border-radius:6px;padding:7px 12px;color:#e2e8f0;font-size:13px;font-family:monospace}
776
+ .token-bar input[type=password]:focus{outline:none;border-color:#4299e1}
777
+ .token-bar button{background:#4299e1;color:#fff;border:none;border-radius:6px;padding:7px 16px;cursor:pointer;font-size:13px;font-weight:600;white-space:nowrap}
778
+ .token-bar button:hover{background:#3182ce}
779
+ .main{padding:20px 24px;max-width:1100px;margin:0 auto}
780
+ .section{background:#1a1f2e;border:1px solid #2d3748;border-radius:10px;padding:18px;margin-bottom:14px}
781
+ .section h2{font-size:11px;font-weight:700;color:#718096;text-transform:uppercase;letter-spacing:.08em;margin-bottom:14px}
782
+ .form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
783
+ .form-group{display:flex;flex-direction:column;gap:4px}
784
+ .form-group.full{grid-column:1/-1}
785
+ .form-group label{font-size:11px;color:#718096;text-transform:uppercase;letter-spacing:.05em;font-weight:600}
786
+ .form-group input,.form-group textarea,.form-group select{background:#0f1117;border:1px solid #2d3748;border-radius:6px;padding:8px 12px;color:#e2e8f0;font-size:13px;font-family:inherit}
787
+ .form-group input:focus,.form-group textarea:focus,.form-group select:focus{outline:none;border-color:#4299e1}
788
+ .form-group textarea{min-height:80px;resize:vertical}
789
+ .form-actions{display:flex;gap:8px;margin-top:14px;grid-column:1/-1}
790
+ .btn-primary{background:#4299e1;color:#fff;border:none;border-radius:6px;padding:8px 20px;cursor:pointer;font-size:13px;font-weight:600}
791
+ .btn-primary:hover{background:#3182ce}
792
+ .btn-primary:disabled{opacity:.5;cursor:not-allowed}
793
+ .btn-secondary{background:#2d3748;color:#a0aec0;border:none;border-radius:6px;padding:8px 16px;cursor:pointer;font-size:13px}
794
+ .btn-secondary:hover{background:#3d4a5e}
795
+ .btn-danger{background:rgba(248,113,113,.15);color:#fc8181;border:1px solid rgba(248,113,113,.3);border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-weight:600}
796
+ .btn-danger:hover{background:rgba(248,113,113,.25)}
797
+ .btn-edit{background:rgba(66,153,225,.12);color:#63b3ed;border:1px solid rgba(66,153,225,.3);border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-weight:600}
798
+ .btn-edit:hover{background:rgba(66,153,225,.22)}
799
+
800
+ table{width:100%;border-collapse:collapse;font-size:12px}
801
+ th{text-align:left;padding:7px 12px;color:#718096;border-bottom:1px solid #2d3748;font-weight:500;white-space:nowrap}
802
+ td{padding:7px 12px;border-bottom:1px solid #1f2535;color:#e2e8f0;vertical-align:top}
803
+ tr:last-child td{border-bottom:none}
804
+ .mono{font-family:monospace;color:#68d391;font-size:11px}
805
+ .type-badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}
806
+ .type-info{background:rgba(66,153,225,.15);color:#63b3ed}
807
+ .type-warning{background:rgba(251,191,36,.15);color:#fbbf24}
808
+ .type-critical{background:rgba(248,113,113,.15);color:#fc8181}
809
+ .status-active{color:#68d391;font-weight:600;font-size:11px}
810
+ .status-expired{color:#718096;font-style:italic;font-size:11px}
811
+ .empty{color:#4a5568;font-size:12px;padding:20px;text-align:center}
812
+ .error{background:#2d1515;border:1px solid #742a2a;border-radius:8px;padding:12px;color:#fc8181;margin-bottom:14px}
813
+ .success{background:#1c2d1c;border:1px solid #276749;border-radius:8px;padding:12px;color:#68d391;margin-bottom:14px}
814
+
815
+ .preview{margin-top:14px;grid-column:1/-1}
816
+ .preview-label{font-size:10px;color:#718096;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px;font-weight:700}
817
+ .preview-card{border-radius:10px;padding:14px 18px;display:flex;align-items:center;gap:12px}
818
+ .preview-card.p-info{background:linear-gradient(135deg,rgba(66,153,225,.12),rgba(99,179,237,.08));border:1px solid rgba(66,153,225,.35)}
819
+ .preview-card.p-warning{background:linear-gradient(135deg,rgba(251,191,36,.12),rgba(246,224,94,.08));border:1px solid rgba(251,191,36,.35)}
820
+ .preview-card.p-critical{background:linear-gradient(135deg,rgba(248,113,113,.12),rgba(252,129,129,.08));border:1px solid rgba(248,113,113,.35)}
821
+ .preview-icon{font-size:22px;flex-shrink:0}
822
+ .preview-content{flex:1;min-width:0}
823
+ .preview-title{font-weight:700;font-size:14px;margin-bottom:3px}
824
+ .preview-msg{font-size:12px;color:#a0aec0;line-height:1.4}
825
+ .preview-btn{display:inline-block;margin-top:6px;padding:4px 12px;border-radius:6px;font-size:11px;font-weight:600;text-decoration:none;border:1px solid rgba(255,255,255,.15);color:#e2e8f0;background:rgba(255,255,255,.06)}
826
+ .p-info .preview-title{color:#63b3ed}
827
+ .p-warning .preview-title{color:#fbbf24}
828
+ .p-critical .preview-title{color:#fc8181}
829
+ .version-hint{font-size:10px;color:#4a5568;margin-top:2px}
830
+ @media(max-width:700px){.form-grid{grid-template-columns:1fr}}
831
+ </style>
832
+ </head>
833
+ <body>
834
+ <div class="header">
835
+ <h1>\u{1f514} Notification Manager</h1>
836
+ <div class="nav">
837
+ <a href="/">Telemetry Dashboard</a>
838
+ </div>
839
+ </div>
840
+ <div class="token-bar">
841
+ <input type="password" id="tok" placeholder="Paste STATS_TOKEN here\u2026" />
842
+ <button onclick="authenticate()">Connect</button>
843
+ </div>
844
+
845
+ <div class="main">
846
+ <div class="error" id="errMsg" style="display:none"></div>
847
+ <div class="success" id="successMsg" style="display:none"></div>
848
+
849
+ <div id="authedContent" style="display:none">
850
+ <div class="section">
851
+ <h2 id="formTitle">\u2795 Compose New Notification</h2>
852
+ <div class="form-grid">
853
+ <input type="hidden" id="editId" value="" />
854
+ <div class="form-group">
855
+ <label>Type</label>
856
+ <select id="nType" onchange="updatePreview()">
857
+ <option value="info">Info</option>
858
+ <option value="warning">Warning</option>
859
+ <option value="critical">Critical</option>
860
+ </select>
861
+ </div>
862
+ <div class="form-group">
863
+ <label>Expires At</label>
864
+ <input type="datetime-local" id="nExpires" />
865
+ </div>
866
+ <div class="form-group full">
867
+ <label>Title</label>
868
+ <input type="text" id="nTitle" placeholder="Short headline for the notification" oninput="updatePreview()" />
869
+ </div>
870
+ <div class="form-group full">
871
+ <label>Message</label>
872
+ <textarea id="nMessage" placeholder="Full notification message. Explain what users need to do." oninput="updatePreview()"><\/textarea>
873
+ </div>
874
+ <div class="form-group">
875
+ <label>Min Version <span class="version-hint">(show to users &ge; this version)</span></label>
876
+ <input type="text" id="nMinVer" placeholder="e.g. 1.0.0" />
877
+ </div>
878
+ <div class="form-group">
879
+ <label>Max Version <span class="version-hint">(show to users &le; this version)</span></label>
880
+ <input type="text" id="nMaxVer" placeholder="e.g. 1.5.1" />
881
+ </div>
882
+ <div class="form-group">
883
+ <label>Action URL (optional)</label>
884
+ <input type="text" id="nActionUrl" placeholder="https://github.com/..." oninput="updatePreview()" />
885
+ </div>
886
+ <div class="form-group">
887
+ <label>Action Label (optional)</label>
888
+ <input type="text" id="nActionLabel" placeholder="e.g. View README" oninput="updatePreview()" />
889
+ </div>
890
+ <div class="preview" id="previewArea">
891
+ <div class="preview-label">Live Preview</div>
892
+ <div class="preview-card p-info" id="previewCard">
893
+ <span class="preview-icon" id="previewIcon">\u{2139}\u{fe0f}</span>
894
+ <div class="preview-content">
895
+ <div class="preview-title" id="previewTitle">Notification title</div>
896
+ <div class="preview-msg" id="previewMsg">Notification message will appear here</div>
897
+ </div>
898
+ </div>
899
+ </div>
900
+ <div class="form-actions">
901
+ <button class="btn-primary" id="btnSubmit" onclick="submitNotification()">Create Notification</button>
902
+ <button class="btn-secondary" id="btnCancel" onclick="cancelEdit()" style="display:none">Cancel Edit</button>
903
+ </div>
904
+ </div>
905
+ </div>
906
+
907
+ <div class="section">
908
+ <h2>\u{1f4cb} All Notifications</h2>
909
+ <div id="notifTable"></div>
910
+ </div>
911
+ </div>
912
+ </div>
913
+
914
+ <script>
915
+ var _token = '';
916
+ var _notifications = [];
917
+
918
+ function $(i) { return document.getElementById(i); }
919
+
920
+ function authenticate() {
921
+ var t = $('tok').value.trim();
922
+ if (!t) return;
923
+ _token = t;
924
+ localStorage.setItem('notif_token', t);
925
+ loadAll();
926
+ }
927
+
928
+ async function loadAll() {
929
+ try {
930
+ // We need to verify the token by trying to load raw notifications
931
+ // Use a GET with auth to confirm access
932
+ var r = await fetch('/v1/stats', { headers: { 'Authorization': 'Bearer ' + _token } });
933
+ if (r.status === 401) { showErr('Invalid token'); return; }
934
+ hideErr();
935
+ $('authedContent').style.display = 'block';
936
+ await refreshList();
937
+ } catch(e) { showErr(e.message); }
938
+ }
939
+
940
+ async function refreshList() {
941
+ try {
942
+ // Load all notifications from the file (we'll load the full list including expired)
943
+ var r = await fetch('/v1/notifications?all=true');
944
+ if (!r.ok) { showErr('Failed to load notifications'); return; }
945
+ _notifications = await r.json();
946
+ renderTable();
947
+ } catch(e) { showErr(e.message); }
948
+ }
949
+
950
+ function renderTable() {
951
+ var tb = $('notifTable');
952
+ if (!_notifications || _notifications.length === 0) {
953
+ tb.innerHTML = '<div class="empty">No notifications yet. Create one above!</div>';
954
+ return;
955
+ }
956
+ var now = new Date().toISOString();
957
+ var html = '<table><thead><tr><th>Type</th><th>Title</th><th>Message</th><th>Version Target</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead><tbody>';
958
+ for (var i = 0; i < _notifications.length; i++) {
959
+ var n = _notifications[i];
960
+ var isExpired = n.expiresAt && n.expiresAt < now;
961
+ var typeClass = 'type-' + (n.type || 'info');
962
+ var verTarget = '';
963
+ if (n.minVersion || n.maxVersion) {
964
+ verTarget = (n.minVersion ? '\u2265' + n.minVersion : '') + (n.minVersion && n.maxVersion ? ' ' : '') + (n.maxVersion ? '\u2264' + n.maxVersion : '');
965
+ } else {
966
+ verTarget = 'All';
967
+ }
968
+ html += '<tr>'
969
+ + '<td><span class="type-badge ' + typeClass + '">' + esc(n.type || 'info') + '</span></td>'
970
+ + '<td><strong>' + esc(n.title) + '</strong></td>'
971
+ + '<td style="max-width:280px;white-space:pre-wrap;word-break:break-word;font-size:11px;color:#a0aec0">' + esc(n.message).slice(0, 120) + (n.message.length > 120 ? '\u2026' : '') + '</td>'
972
+ + '<td class="mono">' + verTarget + '</td>'
973
+ + '<td><span class="' + (isExpired ? 'status-expired' : 'status-active') + '">' + (isExpired ? 'Expired' : 'Active') + '</span></td>'
974
+ + '<td class="mono">' + (n.createdAt ? n.createdAt.slice(0, 16) : '\u2014') + '</td>'
975
+ + '<td style="white-space:nowrap"><button class="btn-edit" onclick="editNotif(' + i + ')">Edit</button> <button class="btn-danger" onclick="deleteNotif(\\'' + esc(n.id) + '\\')">Delete</button></td>'
976
+ + '</tr>';
977
+ }
978
+ html += '</tbody></table>';
979
+ tb.innerHTML = html;
980
+ }
981
+
982
+ function editNotif(idx) {
983
+ var n = _notifications[idx];
984
+ if (!n) return;
985
+ $('editId').value = n.id;
986
+ $('nType').value = n.type || 'info';
987
+ $('nTitle').value = n.title || '';
988
+ $('nMessage').value = n.message || '';
989
+ $('nMinVer').value = n.minVersion || '';
990
+ $('nMaxVer').value = n.maxVersion || '';
991
+ $('nActionUrl').value = n.actionUrl || '';
992
+ $('nActionLabel').value = n.actionLabel || '';
993
+ if (n.expiresAt) {
994
+ $('nExpires').value = n.expiresAt.slice(0, 16);
995
+ } else {
996
+ $('nExpires').value = '';
997
+ }
998
+ $('formTitle').textContent = '\u270f\ufe0f Edit Notification';
999
+ $('btnSubmit').textContent = 'Update Notification';
1000
+ $('btnCancel').style.display = '';
1001
+ updatePreview();
1002
+ window.scrollTo({ top: 0, behavior: 'smooth' });
1003
+ }
1004
+
1005
+ function cancelEdit() {
1006
+ $('editId').value = '';
1007
+ $('nType').value = 'info';
1008
+ $('nTitle').value = '';
1009
+ $('nMessage').value = '';
1010
+ $('nMinVer').value = '';
1011
+ $('nMaxVer').value = '';
1012
+ $('nActionUrl').value = '';
1013
+ $('nActionLabel').value = '';
1014
+ $('nExpires').value = '';
1015
+ $('formTitle').textContent = '\u2795 Compose New Notification';
1016
+ $('btnSubmit').textContent = 'Create Notification';
1017
+ $('btnCancel').style.display = 'none';
1018
+ updatePreview();
1019
+ }
1020
+
1021
+ async function submitNotification() {
1022
+ var title = $('nTitle').value.trim();
1023
+ var message = $('nMessage').value.trim();
1024
+ if (!title || !message) { showErr('Title and message are required'); return; }
1025
+
1026
+ var payload = {
1027
+ type: $('nType').value,
1028
+ title: title,
1029
+ message: message,
1030
+ minVersion: $('nMinVer').value.trim() || null,
1031
+ maxVersion: $('nMaxVer').value.trim() || null,
1032
+ actionUrl: $('nActionUrl').value.trim() || null,
1033
+ actionLabel: $('nActionLabel').value.trim() || null,
1034
+ expiresAt: $('nExpires').value ? new Date($('nExpires').value).toISOString() : null,
1035
+ };
1036
+
1037
+ var editId = $('editId').value;
1038
+ if (editId) payload.id = editId;
1039
+
1040
+ $('btnSubmit').disabled = true;
1041
+ try {
1042
+ var r = await fetch('/v1/notifications', {
1043
+ method: 'POST',
1044
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _token },
1045
+ body: JSON.stringify(payload),
1046
+ });
1047
+ var result = await r.json();
1048
+ if (r.ok && result.ok) {
1049
+ showSuccess(editId ? 'Notification updated!' : 'Notification created!');
1050
+ cancelEdit();
1051
+ await refreshList();
1052
+ } else {
1053
+ showErr(result.error || 'Failed to save notification');
1054
+ }
1055
+ } catch(e) {
1056
+ showErr(e.message);
1057
+ }
1058
+ $('btnSubmit').disabled = false;
1059
+ }
1060
+
1061
+ async function deleteNotif(id) {
1062
+ if (!confirm('Delete this notification?')) return;
1063
+ try {
1064
+ var r = await fetch('/v1/notifications/' + encodeURIComponent(id), {
1065
+ method: 'DELETE',
1066
+ headers: { 'Authorization': 'Bearer ' + _token },
1067
+ });
1068
+ var result = await r.json();
1069
+ if (r.ok && result.ok) {
1070
+ showSuccess('Notification deleted');
1071
+ await refreshList();
1072
+ } else {
1073
+ showErr(result.error || 'Failed to delete');
1074
+ }
1075
+ } catch(e) { showErr(e.message); }
1076
+ }
1077
+
1078
+ var ICONS = { info: '\u{2139}\u{fe0f}', warning: '\u26a0\ufe0f', critical: '\u{1f6a8}' };
1079
+
1080
+ function updatePreview() {
1081
+ var type = $('nType').value;
1082
+ var title = $('nTitle').value || 'Notification title';
1083
+ var msg = $('nMessage').value || 'Notification message will appear here';
1084
+ var actionUrl = $('nActionUrl').value;
1085
+ var actionLabel = $('nActionLabel').value || 'Learn More';
1086
+ var card = $('previewCard');
1087
+ card.className = 'preview-card p-' + type;
1088
+ $('previewIcon').textContent = ICONS[type] || ICONS.info;
1089
+ $('previewTitle').textContent = title;
1090
+ var html = esc(msg);
1091
+ if (actionUrl) html += '<br/><a class="preview-btn" href="#" onclick="return false">' + esc(actionLabel) + '</a>';
1092
+ $('previewMsg').innerHTML = html;
1093
+ }
1094
+
1095
+ function esc(s) { if (!s) return ''; return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
1096
+ function showErr(m) { $('errMsg').textContent = '\u26a0 ' + m; $('errMsg').style.display = ''; setTimeout(function(){ $('errMsg').style.display='none'; }, 8000); }
1097
+ function hideErr() { $('errMsg').style.display = 'none'; }
1098
+ function showSuccess(m) { $('successMsg').textContent = '\u2713 ' + m; $('successMsg').style.display = ''; setTimeout(function(){ $('successMsg').style.display='none'; }, 4000); }
1099
+
1100
+ // Auto-connect from saved token
1101
+ var saved = localStorage.getItem('notif_token');
1102
+ if (saved) { _token = saved; $('tok').value = saved; loadAll(); }
1103
+ updatePreview();
1104
+ <\/script>
1105
+ </body></html>`;
1106
+ }
1107
+
693
1108
  // ── HTTP Server ──────────────────────────────────────────────────────
694
1109
  function readBody(req) {
695
1110
  return new Promise((resolve, reject) => {
@@ -729,6 +1144,119 @@ const server = createServer(async (req, res) => {
729
1144
  return;
730
1145
  }
731
1146
 
1147
+ // Notifications Admin UI
1148
+ if (method === "GET" && url === "/notifications") {
1149
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1150
+ res.end(buildNotificationsAdminHtml());
1151
+ return;
1152
+ }
1153
+
1154
+ // ── Notifications API ──────────────────────────────────────────
1155
+ // GET /v1/notifications — Public, returns active notifications
1156
+ // Add ?all=true to load all (including expired) for admin UI
1157
+ if (method === "GET" && url.startsWith("/v1/notifications")) {
1158
+ try {
1159
+ const q = parseQueryString(url);
1160
+ if (q.all === "true") {
1161
+ // Return ALL notifications (for admin management UI)
1162
+ res.writeHead(200, { "Content-Type": "application/json" });
1163
+ res.end(JSON.stringify(loadNotifications()));
1164
+ } else {
1165
+ const clientVersion = q.version || null;
1166
+ const active = getActiveNotifications(clientVersion);
1167
+ res.writeHead(200, { "Content-Type": "application/json" });
1168
+ res.end(JSON.stringify(active));
1169
+ }
1170
+ } catch (err) {
1171
+ res.writeHead(500, { "Content-Type": "application/json" });
1172
+ res.end(JSON.stringify({ error: "Failed to load notifications" }));
1173
+ }
1174
+ return;
1175
+ }
1176
+
1177
+ // POST /v1/notifications — Create/update notification (auth required)
1178
+ if (method === "POST" && url === "/v1/notifications") {
1179
+ if (!STATS_TOKEN) {
1180
+ res.writeHead(403, { "Content-Type": "application/json" });
1181
+ res.end(JSON.stringify({ error: "STATS_TOKEN not configured" }));
1182
+ return;
1183
+ }
1184
+ const auth = req.headers.authorization || "";
1185
+ if (auth !== `Bearer ${STATS_TOKEN}`) {
1186
+ res.writeHead(401, { "Content-Type": "application/json" });
1187
+ res.end(JSON.stringify({ error: "Unauthorized" }));
1188
+ return;
1189
+ }
1190
+ try {
1191
+ const body = await readBody(req);
1192
+ const data = JSON.parse(body);
1193
+ if (!data.title || !data.message) {
1194
+ res.writeHead(400, { "Content-Type": "application/json" });
1195
+ res.end(JSON.stringify({ error: "title and message are required" }));
1196
+ return;
1197
+ }
1198
+ const notifications = loadNotifications();
1199
+ const notification = {
1200
+ id: data.id || randomUUID(),
1201
+ type: data.type || "info",
1202
+ title: data.title,
1203
+ message: data.message,
1204
+ createdAt: data.createdAt || new Date().toISOString(),
1205
+ expiresAt: data.expiresAt || null,
1206
+ minVersion: data.minVersion || null,
1207
+ maxVersion: data.maxVersion || null,
1208
+ actionUrl: data.actionUrl || null,
1209
+ actionLabel: data.actionLabel || null,
1210
+ };
1211
+ // Update if id exists, otherwise add
1212
+ const existingIdx = notifications.findIndex((n) => n.id === notification.id);
1213
+ if (existingIdx >= 0) {
1214
+ notifications[existingIdx] = notification;
1215
+ } else {
1216
+ notifications.push(notification);
1217
+ }
1218
+ saveNotifications(notifications);
1219
+ res.writeHead(200, { "Content-Type": "application/json" });
1220
+ res.end(JSON.stringify({ ok: true, notification }));
1221
+ } catch (err) {
1222
+ res.writeHead(400, { "Content-Type": "application/json" });
1223
+ res.end(JSON.stringify({ error: "Bad request" }));
1224
+ }
1225
+ return;
1226
+ }
1227
+
1228
+ // DELETE /v1/notifications/:id — Remove notification (auth required)
1229
+ if (method === "DELETE" && url.startsWith("/v1/notifications/")) {
1230
+ if (!STATS_TOKEN) {
1231
+ res.writeHead(403, { "Content-Type": "application/json" });
1232
+ res.end(JSON.stringify({ error: "STATS_TOKEN not configured" }));
1233
+ return;
1234
+ }
1235
+ const auth = req.headers.authorization || "";
1236
+ if (auth !== `Bearer ${STATS_TOKEN}`) {
1237
+ res.writeHead(401, { "Content-Type": "application/json" });
1238
+ res.end(JSON.stringify({ error: "Unauthorized" }));
1239
+ return;
1240
+ }
1241
+ try {
1242
+ const id = decodeURIComponent(url.slice("/v1/notifications/".length));
1243
+ const notifications = loadNotifications();
1244
+ const filtered = notifications.filter((n) => n.id !== id);
1245
+ if (filtered.length === notifications.length) {
1246
+ res.writeHead(404, { "Content-Type": "application/json" });
1247
+ res.end(JSON.stringify({ error: "Notification not found" }));
1248
+ return;
1249
+ }
1250
+ saveNotifications(filtered);
1251
+ res.writeHead(200, { "Content-Type": "application/json" });
1252
+ res.end(JSON.stringify({ ok: true, deleted: id }));
1253
+ } catch {
1254
+ res.writeHead(500, { "Content-Type": "application/json" });
1255
+ res.end(JSON.stringify({ error: "Failed to delete notification" }));
1256
+ }
1257
+ return;
1258
+ }
1259
+
732
1260
  // Health check
733
1261
  if (method === "GET" && url === "/v1/health") {
734
1262
  res.writeHead(200, { "Content-Type": "application/json" });