Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commita2b3e99unknown_key

feat(M3+M4): cross-repo code search + browser push notifications

feat(M3+M4): cross-repo code search + browser push notifications

- /search/code: paginated keyword search across all accessible repos using
  code_chunks index with git-grep fallback for unindexed repos
- /api/search/code: JSON API for the same, results grouped by repo
- /settings/notifications/push: opt-in UI with subscription management,
  per-event toggles (deploy, gate fail, PR merge, AI review)
- /api/push/subscribe|unsubscribe|test: push subscription CRUD
- src/lib/push-notify.ts: typed fan-out helpers (notifyDeploySuccess, etc.)
- drizzle/0076_push_subscriptions.sql: user prefs columns for push events
- connect.tsx: POST /api/claude/push curl snippet in onboarding guide
- All new routers use path-specific middleware (never use("*", ...))

https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y
Claude committed on May 28, 2026Parent: da24dfe
6 files changed+19534a2b3e998af5fe771b2ad5c6a673d8920c9f8309e
6 changed files+1953−4
Addeddrizzle/0076_push_subscriptions.sql+15−0View fileUnifiedSplit
1-- Block M2 addendum — extra push-preference columns for the four new
2-- notification kinds surfaced by src/routes/push-notifications.tsx:
3-- deploy_success → notify_push_on_deploy_success
4-- pr_merged → notify_push_on_pr_merged
5-- ai_review → notify_push_on_ai_review
6-- gate_failed → notify_push_on_gate_failed
7--
8-- Strictly additive. No existing table or column is touched.
9-- The push_subscriptions table itself already exists (drizzle/0043).
10
11ALTER TABLE "users"
12 ADD COLUMN IF NOT EXISTS "notify_push_on_deploy_success" boolean NOT NULL DEFAULT true,
13 ADD COLUMN IF NOT EXISTS "notify_push_on_pr_merged" boolean NOT NULL DEFAULT true,
14 ADD COLUMN IF NOT EXISTS "notify_push_on_ai_review" boolean NOT NULL DEFAULT true,
15 ADD COLUMN IF NOT EXISTS "notify_push_on_gate_failed" boolean NOT NULL DEFAULT true;
Modifiedsrc/app.tsx+6−0View fileUnifiedSplit
160160import vsGithubRoutes from "./routes/vs-github";
161161import voiceRoutes from "./routes/voice-to-pr";
162162import playgroundRoutes from "./routes/playground";
163import crossRepoSearchRoutes from "./routes/cross-repo-search";
164import pushNotifRoutes from "./routes/push-notifications";
163165import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
164166import { csrfToken, csrfProtect } from "./middleware/csrf";
165167import { noCache } from "./middleware/no-cache";
634636app.route("/", pushWatchRoutes);
635637// Org Secrets Manager — BLOCK M2 — /orgs/:slug/settings/secrets
636638app.route("/", orgSecretsRoutes);
639// Cross-repo code search — BLOCK M3 — /search/code + /api/search/code
640app.route("/", crossRepoSearchRoutes);
641// Browser push notifications — BLOCK M4 — /settings/notifications/push + /api/push/*
642app.route("/", pushNotifRoutes);
637643// Hosted Claude tool-use loops — paste loop, get endpoint, billing meter.
638644// See src/routes/claude-deploy.tsx + src/lib/hosted-claude-loop.ts.
639645app.route("/", claudeDeployRoutes);
Addedsrc/lib/push-notify.ts+197−0View fileUnifiedSplit
1/**
2 * push-notify.ts — Block M2 addendum.
3 *
4 * Higher-level push notification helpers for the four developer-facing events:
5 * deploy_success, gate_failed, pr_merged, ai_review
6 *
7 * This module re-exports the lower-level subscription CRUD from src/lib/push.ts
8 * under a clean, event-oriented surface so route handlers and post-receive hooks
9 * can call a single function without importing from two places.
10 *
11 * The Drizzle table definition is declared inline here (schema.ts is locked)
12 * and mirrors the table already created by drizzle/0076_push_subscriptions.sql.
13 * Both definitions share the same underlying table name so the DB only sees one
14 * physical table.
15 *
16 * VAPID is handled transparently by push.ts (env-first, process-cached fallback).
17 * No `web-push` npm package is required — the implementation uses pure Web Crypto
18 * (RFC 8291 / RFC 8188 / RFC 8292) via Bun's built-in crypto.subtle.
19 */
20
21import { eq } from "drizzle-orm";
22import {
23 pgTable,
24 uuid,
25 text,
26 timestamp,
27 uniqueIndex,
28 index,
29} from "drizzle-orm/pg-core";
30import { db } from "../db";
31import {
32 subscribeUser as _subscribeUser,
33 unsubscribeUser as _unsubscribeUser,
34 sendPushToUser,
35} from "./push";
36
37// ---------------------------------------------------------------------------
38// Inline table definition (mirrors schema.ts — locked)
39// ---------------------------------------------------------------------------
40
41export const pushSubscriptions = pgTable(
42 "push_subscriptions",
43 {
44 id: uuid("id").primaryKey().defaultRandom(),
45 userId: uuid("user_id").notNull(),
46 endpoint: text("endpoint").notNull(),
47 p256dh: text("p256dh").notNull(),
48 auth: text("auth").notNull(),
49 userAgent: text("user_agent"),
50 createdAt: timestamp("created_at").defaultNow().notNull(),
51 lastUsedAt: timestamp("last_used_at"),
52 },
53 (t) => [
54 uniqueIndex("push_subs_endpoint_uq").on(t.endpoint),
55 index("push_subs_user_idx").on(t.userId),
56 ]
57);
58
59// ---------------------------------------------------------------------------
60// Types
61// ---------------------------------------------------------------------------
62
63export type PushSubscriptionInput = {
64 endpoint: string;
65 keys: { p256dh: string; auth: string };
66};
67
68export type PushSubscriptionRow = typeof pushSubscriptions.$inferSelect;
69
70export type PushPayload = {
71 title: string;
72 body: string;
73 url?: string;
74};
75
76// ---------------------------------------------------------------------------
77// CRUD helpers
78// ---------------------------------------------------------------------------
79
80/**
81 * Persist a push subscription for a user. Safe to call repeatedly — the
82 * underlying insert uses ON CONFLICT DO UPDATE so keys are refreshed on
83 * browser-side rotation.
84 */
85export async function savePushSubscription(
86 userId: string,
87 subscription: PushSubscriptionInput,
88 userAgent?: string
89): Promise<void> {
90 return _subscribeUser(userId, subscription, userAgent);
91}
92
93/**
94 * Remove a push subscription by endpoint. No-ops gracefully when the
95 * endpoint is not found.
96 */
97export async function deletePushSubscription(endpoint: string): Promise<void> {
98 try {
99 await db
100 .delete(pushSubscriptions)
101 .where(eq(pushSubscriptions.endpoint, endpoint));
102 } catch (err) {
103 console.error("[push-notify] deletePushSubscription failed:", err);
104 }
105}
106
107/**
108 * List all push subscriptions for a user, newest first.
109 */
110export async function listPushSubscriptions(
111 userId: string
112): Promise<PushSubscriptionRow[]> {
113 try {
114 return await db
115 .select()
116 .from(pushSubscriptions)
117 .where(eq(pushSubscriptions.userId, userId))
118 .orderBy(pushSubscriptions.createdAt);
119 } catch (err) {
120 console.error("[push-notify] listPushSubscriptions failed:", err);
121 return [];
122 }
123}
124
125// ---------------------------------------------------------------------------
126// High-level fan-out
127// ---------------------------------------------------------------------------
128
129/**
130 * Send a push notification to every subscription owned by a user.
131 * Stale endpoints (HTTP 410/404) are cleaned up automatically by the
132 * underlying sendPushToUser transport layer.
133 *
134 * Returns { sent, failed } counts; never throws.
135 */
136export async function sendPushNotification(
137 userId: string,
138 payload: PushPayload
139): Promise<{ sent: number; failed: number }> {
140 return sendPushToUser(userId, {
141 title: payload.title,
142 body: payload.body,
143 url: payload.url,
144 });
145}
146
147// ---------------------------------------------------------------------------
148// Typed event helpers — thin wrappers for the four tracked developer events
149// ---------------------------------------------------------------------------
150
151/** Notify a developer that their push deployed successfully. */
152export async function notifyDeploySuccess(
153 userId: string,
154 opts: { repoFullName: string; sha: string; url?: string }
155): Promise<{ sent: number; failed: number }> {
156 return sendPushNotification(userId, {
157 title: "Deploy succeeded",
158 body: `${opts.repoFullName} @ ${opts.sha.slice(0, 7)} is live.`,
159 url: opts.url,
160 });
161}
162
163/** Notify a developer that a gate check failed on their push. */
164export async function notifyGateFailed(
165 userId: string,
166 opts: { repoFullName: string; sha: string; gate: string; url?: string }
167): Promise<{ sent: number; failed: number }> {
168 return sendPushNotification(userId, {
169 title: `Gate failed: ${opts.gate}`,
170 body: `${opts.repoFullName} @ ${opts.sha.slice(0, 7)}`,
171 url: opts.url,
172 });
173}
174
175/** Notify a developer that their PR was merged. */
176export async function notifyPrMerged(
177 userId: string,
178 opts: { repoFullName: string; prNumber: number; title: string; url?: string }
179): Promise<{ sent: number; failed: number }> {
180 return sendPushNotification(userId, {
181 title: `PR #${opts.prNumber} merged`,
182 body: `${opts.repoFullName}: ${opts.title}`,
183 url: opts.url,
184 });
185}
186
187/** Notify a developer that an AI review was posted on their PR. */
188export async function notifyAiReview(
189 userId: string,
190 opts: { repoFullName: string; prNumber: number; url?: string }
191): Promise<{ sent: number; failed: number }> {
192 return sendPushNotification(userId, {
193 title: "AI review posted",
194 body: `New AI review on ${opts.repoFullName} PR #${opts.prNumber}`,
195 url: opts.url,
196 });
197}
Modifiedsrc/routes/connect.tsx+16−4View fileUnifiedSplit
436436 </div>
437437 <div class="cg-step-body">
438438 <p class="cg-step-desc">
439 Push your code. The repository is created automatically if it
440 doesn't exist yet, and CI gates fire immediately.
439 Push your branch. CI gates fire immediately and a draft PR is
440 auto-created if you're pushing a feature branch.
441441 </p>
442442 <div class="cg-code-wrap">
443443 <pre id="cg-push" class="cg-code">git push gluecron main</pre>
445445 Copy
446446 </button>
447447 </div>
448 <p class="cg-step-desc" style="margin-top: var(--space-3);">
449 For feature branches, auto-create a draft PR in one call:
450 </p>
451 <div class="cg-code-wrap">
452 <pre id="cg-auto-pr" class="cg-code">{`curl -X POST ${host}/api/claude/push \\
453 -H "Authorization: Bearer $GLUECRON_TOKEN" \\
454 -H "Content-Type: application/json" \\
455 -d '{"repoName":"REPO","branch":"my-feature"}'`}</pre>
456 <button type="button" class="cg-copy-btn" data-cg-copy="cg-auto-pr">
457 Copy
458 </button>
459 </div>
448460 <p class="cg-step-desc" style="margin-top: var(--space-3); margin-bottom: 0;">
449 That's it. Your push triggers the full gate suite and Claude
450 AI review lands on any PR within seconds.
461 The response includes a <code>pushWatchUrl</code> — open it to watch gates
462 and deployment live. Claude AI review lands within seconds.
451463 </p>
452464 </div>
453465 </section>
Addedsrc/routes/cross-repo-search.tsx+1047−0View fileUnifiedSplit
Large file (1,047 lines). Load full file
Addedsrc/routes/push-notifications.tsx+672−0View fileUnifiedSplit
1/**
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;
0673