Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

push-notifications.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

push-notifications.tsxBlame672 lines · 1 contributor
a2b3e99Claude1/**
2 * Block M2 addendum — Browser push notification management.
3 *
4 * Routes:
5 * GET /settings/notifications/push — UI page (enable / manage subscriptions)
6 * POST /api/push/subscribe — save a PushSubscription (requireAuth)
7 * POST /api/push/unsubscribe — remove a subscription (requireAuth)
8 * POST /api/push/test — send a test notification (requireAuth)
9 *
10 * VAPID: handled by src/lib/push.ts (pure Web Crypto, no npm dep required).
11 * All four keys land in process.env.VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY.
12 * When the keys are missing the lib falls back to a process-stable generated
13 * keypair with a console.warn — subscriptions work but break on restart.
14 *
15 * The subscription CRUD is delegated to src/lib/push-notify.ts which wraps
16 * src/lib/push.ts and exposes the inline-defined pushSubscriptions table.
17 *
18 * CSS: every class is prefixed `.pn-*` to avoid collisions with existing
19 * surfaces. No existing file is modified.
20 */
21
22import { Hono } from "hono";
23import { Layout } from "../views/layout";
24import { softAuth, requireAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import {
27 savePushSubscription,
28 deletePushSubscription,
29 listPushSubscriptions,
30 sendPushNotification,
31 type PushSubscriptionRow,
32} from "../lib/push-notify";
33import { getVapidPublicKey } from "../lib/push";
34
35const pushNotifRoutes = new Hono<AuthEnv>();
36
37// ---------------------------------------------------------------------------
38// Middleware — scoped; never "*"
39// ---------------------------------------------------------------------------
40
41pushNotifRoutes.use("/settings/notifications/push*", softAuth);
42pushNotifRoutes.use("/api/push/*", requireAuth);
43
44// ---------------------------------------------------------------------------
45// Inline CSS — .pn-* namespace
46// ---------------------------------------------------------------------------
47
48const PN_STYLES = `
49/* ── push-notifications page ── */
50.pn-wrap {
51 max-width: 760px;
52 margin: 0 auto;
53 padding: var(--space-5) var(--space-4);
54}
55
56/* Hero / breadcrumb */
57.pn-hero {
58 margin-bottom: var(--space-6);
59}
60.pn-crumbs {
61 display: flex;
62 align-items: center;
63 gap: 6px;
64 font-size: 13px;
65 color: var(--text-muted);
66 margin-bottom: var(--space-3);
67}
68.pn-crumbs a { color: var(--text-muted); text-decoration: none; }
69.pn-crumbs a:hover { color: var(--text); text-decoration: underline; }
70.pn-title {
71 font-size: 22px;
72 font-weight: 700;
73 margin: 0 0 6px;
74}
75.pn-sub {
76 font-size: 14px;
77 color: var(--text-muted);
78 margin: 0;
79 line-height: 1.55;
80}
81
82/* Status banner */
83.pn-banner {
84 display: flex;
85 align-items: center;
86 gap: 8px;
87 padding: 10px 14px;
88 border-radius: 8px;
89 font-size: 13px;
90 margin-bottom: var(--space-4);
91 background: rgba(63,185,80,0.08);
92 border: 1px solid rgba(63,185,80,0.25);
93 color: var(--text);
94}
95.pn-banner.is-error {
96 background: rgba(248,81,73,0.08);
97 border-color: rgba(248,81,73,0.25);
98}
99.pn-banner-dot {
100 width: 7px; height: 7px; border-radius: 50%;
101 background: #3fb950;
102 flex-shrink: 0;
103}
104.pn-banner.is-error .pn-banner-dot { background: #f85149; }
105
106/* Card */
107.pn-card {
108 background: var(--bg-elevated);
109 border: 1px solid var(--border);
110 border-radius: 12px;
111 padding: var(--space-5);
112 margin-bottom: var(--space-4);
113}
114.pn-card-title {
115 font-size: 15px;
116 font-weight: 600;
117 margin: 0 0 4px;
118}
119.pn-card-sub {
120 font-size: 13px;
121 color: var(--text-muted);
122 margin: 0 0 var(--space-4);
123 line-height: 1.5;
124}
125
126/* Enable button */
127.pn-enable-btn {
128 display: inline-flex;
129 align-items: center;
130 gap: 7px;
131 padding: 8px 16px;
132 background: var(--accent);
133 color: #fff;
134 border: none;
135 border-radius: 6px;
136 font-size: 13px;
137 font-weight: 600;
138 cursor: pointer;
139 transition: background 0.15s;
140}
141.pn-enable-btn:hover { background: var(--accent-hover); }
142.pn-enable-btn:disabled { opacity: 0.55; cursor: default; }
143
144.pn-secondary-btn {
145 display: inline-flex;
146 align-items: center;
147 gap: 7px;
148 padding: 7px 13px;
149 background: transparent;
150 color: var(--text-muted);
151 border: 1px solid var(--border);
152 border-radius: 6px;
153 font-size: 13px;
154 cursor: pointer;
155 transition: border-color 0.15s, color 0.15s;
156}
157.pn-secondary-btn:hover { color: var(--text); border-color: var(--text-muted); }
158
159/* Subscription list */
160.pn-sub-list {
161 list-style: none;
162 margin: 0;
163 padding: 0;
164 display: flex;
165 flex-direction: column;
166 gap: 10px;
167}
168.pn-sub-item {
169 display: flex;
170 align-items: center;
171 justify-content: space-between;
172 gap: 12px;
173 padding: 10px 14px;
174 background: var(--bg);
175 border: 1px solid var(--border);
176 border-radius: 8px;
177 font-size: 13px;
178}
179.pn-sub-meta {
180 min-width: 0;
181}
182.pn-sub-endpoint {
183 color: var(--text-muted);
184 font-size: 11px;
185 white-space: nowrap;
186 overflow: hidden;
187 text-overflow: ellipsis;
188 max-width: 420px;
189}
190.pn-sub-date {
191 color: var(--text-muted);
192 font-size: 11px;
193 margin-top: 2px;
194}
195.pn-remove-btn {
196 flex-shrink: 0;
197 padding: 4px 10px;
198 background: transparent;
199 color: #f85149;
200 border: 1px solid rgba(248,81,73,0.35);
201 border-radius: 5px;
202 font-size: 12px;
203 cursor: pointer;
204 transition: background 0.15s;
205}
206.pn-remove-btn:hover { background: rgba(248,81,73,0.08); }
207
208/* Events grid */
209.pn-events-grid {
210 display: grid;
211 gap: 10px;
212}
213.pn-event-row {
214 display: flex;
215 align-items: flex-start;
216 gap: 10px;
217 padding: 10px 12px;
218 border: 1px solid var(--border);
219 border-radius: 8px;
220 background: var(--bg);
221}
222.pn-event-icon {
223 font-size: 18px;
224 line-height: 1;
225 margin-top: 1px;
226 flex-shrink: 0;
227}
228.pn-event-label {
229 font-size: 13px;
230 font-weight: 600;
231 margin-bottom: 2px;
232}
233.pn-event-hint {
234 font-size: 12px;
235 color: var(--text-muted);
236}
237
238/* Empty state */
239.pn-empty {
240 text-align: center;
241 padding: var(--space-5) var(--space-4);
242 color: var(--text-muted);
243 font-size: 14px;
244}
245
246/* Inline JS status */
247#pn-js-status {
248 font-size: 13px;
249 margin-top: var(--space-3);
250 color: var(--text-muted);
251 min-height: 1.4em;
252}
253#pn-js-status.ok { color: #3fb950; }
254#pn-js-status.err { color: #f85149; }
255`;
256
257// ---------------------------------------------------------------------------
258// Helper: format a subscription row for display
259// ---------------------------------------------------------------------------
260
261function fmtEndpoint(endpoint: string): string {
262 try {
263 const u = new URL(endpoint);
264 return `${u.host}${u.pathname.slice(0, 32)}…`;
265 } catch {
266 return endpoint.slice(0, 48) + "…";
267 }
268}
269
270function fmtDate(d: Date | null | undefined): string {
271 if (!d) return "—";
272 return d.toLocaleDateString("en-US", {
273 year: "numeric",
274 month: "short",
275 day: "numeric",
276 });
277}
278
279// ---------------------------------------------------------------------------
280// GET /settings/notifications/push
281// ---------------------------------------------------------------------------
282
283pushNotifRoutes.get("/settings/notifications/push", requireAuth, async (c) => {
284 const user = c.get("user")!;
285 const success = c.req.query("success");
286 const error = c.req.query("error");
287
288 const subs: PushSubscriptionRow[] = await listPushSubscriptions(user.id);
289
290 let vapidPublicKey = "";
291 try {
292 vapidPublicKey = await getVapidPublicKey();
293 } catch {
294 vapidPublicKey = "";
295 }
296
297 return c.html(
298 <Layout title="Push notifications" user={user}>
299 <style dangerouslySetInnerHTML={{ __html: PN_STYLES }} />
300 <div class="pn-wrap">
301 {/* Breadcrumb / hero */}
302 <div class="pn-hero">
303 <nav class="pn-crumbs" aria-label="Breadcrumb">
304 <a href="/settings">Settings</a>
305 <span>/</span>
306 <a href="/settings/notifications">Notifications</a>
307 <span>/</span>
308 <span>Push</span>
309 </nav>
310 <h1 class="pn-title">Browser push notifications</h1>
311 <p class="pn-sub">
312 Subscribe this browser to receive push notifications for deploys,
313 gate failures, PR merges, and AI reviews — even when Gluecron is
314 not open.
315 </p>
316 </div>
317
318 {/* Banner */}
319 {success && (
320 <div class="pn-banner" role="status">
321 <span class="pn-banner-dot" aria-hidden="true" />
322 {decodeURIComponent(success)}
323 </div>
324 )}
325 {error && (
326 <div class="pn-banner is-error" role="alert">
327 <span class="pn-banner-dot" aria-hidden="true" />
328 {decodeURIComponent(error)}
329 </div>
330 )}
331
332 {/* Subscribe card */}
333 <div class="pn-card">
334 <h2 class="pn-card-title">Subscribe this browser</h2>
335 <p class="pn-card-sub">
336 Click the button below to request permission and register this
337 browser. You can subscribe multiple devices independently.
338 </p>
339
340 <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
341 <button
342 type="button"
343 class="pn-enable-btn"
344 id="pn-subscribe-btn"
345 data-vapid={vapidPublicKey}
346 >
347 <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
348 <path d="M8 16a2 2 0 002-2H6a2 2 0 002 2zm.995-14.901a1 1 0 10-1.99 0A5.002 5.002 0 003 6c0 1.098-.5 6-2 7h14c-1.5-1-2-5.902-2-7 0-2.42-1.72-4.44-4.005-4.901z"/>
349 </svg>
350 Enable push on this browser
351 </button>
352 <button
353 type="button"
354 class="pn-secondary-btn"
355 id="pn-test-btn"
356 style="display:none"
357 >
358 Send test notification
359 </button>
360 </div>
361 <p id="pn-js-status" aria-live="polite"></p>
362 </div>
363
364 {/* Tracked events */}
365 <div class="pn-card">
366 <h2 class="pn-card-title">Tracked events</h2>
367 <p class="pn-card-sub">
368 These events will trigger a push notification when they occur.
369 Per-event toggles live in{" "}
370 <a href="/settings/notifications" style="color:var(--accent)">
371 Notification preferences
372 </a>
373 .
374 </p>
375 <ul class="pn-events-grid" aria-label="Push events">
376 <li class="pn-event-row">
377 <span class="pn-event-icon" aria-hidden="true">🚀</span>
378 <div>
379 <div class="pn-event-label">Deploy succeeded</div>
380 <div class="pn-event-hint">
381 Your push went live — includes a link to the push watch page.
382 </div>
383 </div>
384 </li>
385 <li class="pn-event-row">
386 <span class="pn-event-icon" aria-hidden="true">🚨</span>
387 <div>
388 <div class="pn-event-label">Gate failed</div>
389 <div class="pn-event-hint">
390 A security or quality gate rejected your push.
391 </div>
392 </div>
393 </li>
394 <li class="pn-event-row">
395 <span class="pn-event-icon" aria-hidden="true">✅</span>
396 <div>
397 <div class="pn-event-label">PR merged</div>
398 <div class="pn-event-hint">
399 One of your pull requests was merged.
400 </div>
401 </div>
402 </li>
403 <li class="pn-event-row">
404 <span class="pn-event-icon" aria-hidden="true">🤖</span>
405 <div>
406 <div class="pn-event-label">AI review posted</div>
407 <div class="pn-event-hint">
408 The AI reviewer completed a pass on your PR.
409 </div>
410 </div>
411 </li>
412 </ul>
413 </div>
414
415 {/* Active subscriptions */}
416 <div class="pn-card">
417 <h2 class="pn-card-title">Active subscriptions</h2>
418 <p class="pn-card-sub">
419 Each row is a browser/device that will receive notifications.
420 Stale endpoints are cleaned up automatically after a failed
421 delivery.
422 </p>
423 {subs.length === 0 ? (
424 <p class="pn-empty">No active push subscriptions yet.</p>
425 ) : (
426 <ul class="pn-sub-list" aria-label="Active subscriptions">
427 {subs.map((s) => (
428 <li class="pn-sub-item" key={s.id}>
429 <div class="pn-sub-meta">
430 <div class="pn-sub-endpoint" title={s.endpoint}>
431 {fmtEndpoint(s.endpoint)}
432 </div>
433 {s.userAgent && (
434 <div class="pn-sub-date" title={s.userAgent}>
435 {s.userAgent.slice(0, 60)}
436 </div>
437 )}
438 <div class="pn-sub-date">Added {fmtDate(s.createdAt)}</div>
439 </div>
440 <form method="post" action="/api/push/unsubscribe">
441 <input type="hidden" name="endpoint" value={s.endpoint} />
442 <button type="submit" class="pn-remove-btn" aria-label="Remove subscription">
443 Remove
444 </button>
445 </form>
446 </li>
447 ))}
448 </ul>
449 )}
450 </div>
451 </div>
452
453 {/* Client-side subscription logic */}
454 <script
455 dangerouslySetInnerHTML={{
456 __html: `
457(function () {
458 var btn = document.getElementById('pn-subscribe-btn');
459 var testBtn = document.getElementById('pn-test-btn');
460 var status = document.getElementById('pn-js-status');
461 var vapidKey = btn ? btn.getAttribute('data-vapid') : '';
462
463 function setStatus(msg, cls) {
464 if (!status) return;
465 status.textContent = msg;
466 status.className = cls || '';
467 }
468
469 function urlBase64ToUint8Array(base64String) {
470 var padding = '='.repeat((4 - (base64String.length % 4)) % 4);
471 var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
472 var raw = atob(base64);
473 var out = new Uint8Array(raw.length);
474 for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
475 return out;
476 }
477
478 // Check if already subscribed
479 if ('serviceWorker' in navigator && 'PushManager' in window) {
480 navigator.serviceWorker.ready.then(function(reg) {
481 reg.pushManager.getSubscription().then(function(sub) {
482 if (sub && testBtn) {
483 testBtn.style.display = 'inline-flex';
484 setStatus('This browser is subscribed.', 'ok');
485 }
486 }).catch(function() {});
487 }).catch(function() {});
488 } else {
489 setStatus('Push notifications are not supported in this browser.', 'err');
490 if (btn) btn.disabled = true;
491 }
492
493 if (btn) {
494 btn.addEventListener('click', function () {
495 if (!('serviceWorker' in navigator && 'PushManager' in window)) {
496 setStatus('Push notifications are not supported in this browser.', 'err');
497 return;
498 }
499 if (!vapidKey) {
500 setStatus('Push notifications require server configuration (VAPID keys missing).', 'err');
501 return;
502 }
503 btn.disabled = true;
504 setStatus('Requesting permission…');
505
506 Notification.requestPermission().then(function(perm) {
507 if (perm !== 'granted') {
508 setStatus('Permission denied. Allow notifications in your browser settings.', 'err');
509 btn.disabled = false;
510 return;
511 }
512 return navigator.serviceWorker.ready.then(function(reg) {
513 return reg.pushManager.subscribe({
514 userVisibleOnly: true,
515 applicationServerKey: urlBase64ToUint8Array(vapidKey),
516 });
517 }).then(function(sub) {
518 var raw = sub.toJSON();
519 setStatus('Registering…');
520 return fetch('/api/push/subscribe', {
521 method: 'POST',
522 headers: { 'content-type': 'application/json' },
523 body: JSON.stringify({
524 endpoint: raw.endpoint,
525 keys: { p256dh: raw.keys.p256dh, auth: raw.keys.auth },
526 }),
527 });
528 }).then(function(res) {
529 if (res.ok) {
530 setStatus('Subscribed! Reload to see this device in the list.', 'ok');
531 if (testBtn) testBtn.style.display = 'inline-flex';
532 btn.disabled = false;
533 } else {
534 return res.json().then(function(j) {
535 setStatus('Subscribe failed: ' + (j.error || res.status), 'err');
536 btn.disabled = false;
537 });
538 }
539 }).catch(function(err) {
540 setStatus('Subscribe failed: ' + (err.message || err), 'err');
541 btn.disabled = false;
542 });
543 }).catch(function(err) {
544 setStatus('Permission request failed: ' + (err.message || err), 'err');
545 btn.disabled = false;
546 });
547 });
548 }
549
550 if (testBtn) {
551 testBtn.addEventListener('click', function () {
552 testBtn.disabled = true;
553 setStatus('Sending test…');
554 fetch('/api/push/test', { method: 'POST' })
555 .then(function(res) { return res.json(); })
556 .then(function(j) {
557 if (j.sent > 0) {
558 setStatus('Test notification sent! Check your browser.', 'ok');
559 } else if (j.failed > 0) {
560 setStatus('Delivery failed (sent:0 failed:' + j.failed + '). Check VAPID keys.', 'err');
561 } else {
562 setStatus('No active subscriptions found — subscribe this browser first.', 'err');
563 }
564 testBtn.disabled = false;
565 })
566 .catch(function(err) {
567 setStatus('Test failed: ' + (err.message || err), 'err');
568 testBtn.disabled = false;
569 });
570 });
571 }
572})();
573`,
574 }}
575 />
576 </Layout>
577 );
578});
579
580// ---------------------------------------------------------------------------
581// POST /api/push/subscribe
582// ---------------------------------------------------------------------------
583
584pushNotifRoutes.post("/api/push/subscribe", async (c) => {
585 const user = c.get("user")!;
586
587 let body: unknown;
588 try {
589 body = await c.req.json();
590 } catch {
591 return c.json({ error: "invalid_json" }, 400);
592 }
593
594 const b = body as Record<string, unknown>;
595 const endpoint = typeof b?.endpoint === "string" ? b.endpoint.trim() : "";
596 const keys = b?.keys as Record<string, unknown> | undefined;
597 const p256dh = typeof keys?.p256dh === "string" ? keys.p256dh.trim() : "";
598 const auth = typeof keys?.auth === "string" ? keys.auth.trim() : "";
599
600 if (!endpoint || !p256dh || !auth) {
601 return c.json({ error: "invalid_subscription" }, 400);
602 }
603
604 const ua = c.req.header("user-agent") ?? undefined;
605
606 try {
607 await savePushSubscription(user.id, { endpoint, keys: { p256dh, auth } }, ua);
608 } catch {
609 return c.json({ error: "subscribe_failed" }, 500);
610 }
611
612 return c.json({ ok: true }, 201);
613});
614
615// ---------------------------------------------------------------------------
616// POST /api/push/unsubscribe
617// ---------------------------------------------------------------------------
618
619pushNotifRoutes.post("/api/push/unsubscribe", async (c) => {
620 // Accept both form data (from the HTML form) and JSON.
621 let endpoint = "";
622 const ct = c.req.header("content-type") ?? "";
623 if (ct.includes("application/json")) {
624 let body: unknown;
625 try {
626 body = await c.req.json();
627 } catch {
628 return c.json({ error: "invalid_json" }, 400);
629 }
630 endpoint =
631 typeof (body as Record<string, unknown>)?.endpoint === "string"
632 ? ((body as Record<string, unknown>).endpoint as string).trim()
633 : "";
634 } else {
635 // form submission
636 const form = await c.req.formData();
637 endpoint = (form.get("endpoint") as string | null)?.trim() ?? "";
638 }
639
640 if (!endpoint) {
641 return c.json({ error: "missing_endpoint" }, 400);
642 }
643
644 await deletePushSubscription(endpoint);
645
646 // If this was a form submission, redirect back.
647 if (!ct.includes("application/json")) {
648 return c.redirect(
649 "/settings/notifications/push?success=" +
650 encodeURIComponent("Subscription removed."),
651 303
652 );
653 }
654
655 return c.body(null, 204);
656});
657
658// ---------------------------------------------------------------------------
659// POST /api/push/test
660// ---------------------------------------------------------------------------
661
662pushNotifRoutes.post("/api/push/test", async (c) => {
663 const user = c.get("user")!;
664 const result = await sendPushNotification(user.id, {
665 title: "Gluecron test notification",
666 body: "If you can read this, push delivery is working on this device.",
667 url: "/settings/notifications/push",
668 });
669 return c.json(result);
670});
671
672export default pushNotifRoutes;