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.ts

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.tsBlame572 lines · 1 contributor
534f04aClaude1/**
2 * Block M2 — Web Push delivery.
3 *
4 * Pure Web Crypto + Bun fetch — no `web-push` npm dependency. Implements
5 * RFC 8291 (Message Encryption for Web Push) + RFC 8188 (aes128gcm
6 * content encoding) + RFC 8292 (VAPID JWT).
7 *
8 * Public surface:
9 * - getVapidPublicKey(): base64url public key (process-stable)
10 * - subscribeUser, unsubscribeUser: persistence helpers
11 * - sendPushToUser: fan-out delivery with stale-endpoint cleanup
12 * - pushFromNotification: hook callers can fire alongside notify()
13 *
14 * Stale endpoints (HTTP 404/410) are deleted on next send. Other failures
15 * are swallowed per-subscription. The outbound transport is replaceable
16 * via `__setSendTransport` so unit tests never hit the real network.
17 */
18
19import { and, eq } from "drizzle-orm";
20import { db } from "../db";
21import { pushSubscriptions, users } from "../db/schema";
22import type { NotificationKind } from "./notify";
23
24// ---------------------------------------------------------------------------
25// base64url helpers
26// ---------------------------------------------------------------------------
27
28export function b64urlEncode(bytes: Uint8Array | ArrayBuffer): string {
29 const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
30 let bin = "";
31 for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i]!);
32 return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
33}
34
35export function b64urlDecode(s: string): Uint8Array {
36 const norm = s.replace(/-/g, "+").replace(/_/g, "/");
37 const pad = norm.length % 4 === 0 ? "" : "=".repeat(4 - (norm.length % 4));
38 const bin = atob(norm + pad);
39 const out = new Uint8Array(bin.length);
40 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
41 return out;
42}
43
44// ---------------------------------------------------------------------------
45// VAPID keypair — env-first, process-cached fallback
46// ---------------------------------------------------------------------------
47
48type VapidKeypair = {
49 /** Uncompressed P-256 public key, 65 bytes, base64url. */
50 publicKey: string;
51 /** Raw 32-byte private scalar, base64url. */
52 privateKey: string;
53};
54
55let _cachedKeys: VapidKeypair | null = null;
56let _warnedAboutGenerated = false;
57
58async function generateVapidKeypair(): Promise<VapidKeypair> {
59 const kp = await crypto.subtle.generateKey(
60 { name: "ECDSA", namedCurve: "P-256" },
61 true,
62 ["sign", "verify"]
63 );
64 const rawPub = await crypto.subtle.exportKey("raw", kp.publicKey);
65 const jwk = (await crypto.subtle.exportKey(
66 "jwk",
67 kp.privateKey
68 )) as JsonWebKey;
69 if (!jwk.d) throw new Error("vapid keygen: missing private scalar");
70 return {
71 publicKey: b64urlEncode(rawPub),
72 privateKey: jwk.d,
73 };
74}
75
76export async function getVapidKeypair(): Promise<VapidKeypair> {
77 if (_cachedKeys) return _cachedKeys;
78 const envPub = process.env.VAPID_PUBLIC_KEY?.trim();
79 const envPriv = process.env.VAPID_PRIVATE_KEY?.trim();
80 if (envPub && envPriv) {
81 _cachedKeys = { publicKey: envPub, privateKey: envPriv };
82 return _cachedKeys;
83 }
84 _cachedKeys = await generateVapidKeypair();
85 if (!_warnedAboutGenerated) {
86 _warnedAboutGenerated = true;
87 console.warn(
88 "[push] Using a generated VAPID keypair — subscriptions will break on " +
89 "process restart. Set VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY in env to " +
90 "persist across restarts. Generated public key: " +
91 _cachedKeys.publicKey
92 );
93 }
94 return _cachedKeys;
95}
96
97export async function getVapidPublicKey(): Promise<string> {
98 const kp = await getVapidKeypair();
99 return kp.publicKey;
100}
101
102export function __resetVapidCacheForTests(): void {
103 _cachedKeys = null;
104 _warnedAboutGenerated = false;
105}
106
107// ---------------------------------------------------------------------------
108// Subscription persistence
109// ---------------------------------------------------------------------------
110
111export type SubscribeInput = {
112 endpoint: string;
113 keys: { p256dh: string; auth: string };
114};
115
116export async function subscribeUser(
117 userId: string,
118 sub: SubscribeInput,
119 userAgent?: string
120): Promise<void> {
121 if (!sub?.endpoint || !sub?.keys?.p256dh || !sub?.keys?.auth) {
122 throw new Error("invalid subscription payload");
123 }
124 try {
125 await db
126 .insert(pushSubscriptions)
127 .values({
128 userId,
129 endpoint: sub.endpoint,
130 p256dh: sub.keys.p256dh,
131 auth: sub.keys.auth,
132 userAgent: userAgent ?? null,
133 })
134 .onConflictDoUpdate({
135 target: [pushSubscriptions.userId, pushSubscriptions.endpoint],
136 set: {
137 p256dh: sub.keys.p256dh,
138 auth: sub.keys.auth,
139 userAgent: userAgent ?? null,
140 },
141 });
142 } catch (err) {
143 console.error("[push] subscribeUser failed:", err);
144 throw err;
145 }
146}
147
148export async function unsubscribeUser(
149 userId: string,
150 endpoint: string
151): Promise<void> {
152 try {
153 await db
154 .delete(pushSubscriptions)
155 .where(
156 and(
157 eq(pushSubscriptions.userId, userId),
158 eq(pushSubscriptions.endpoint, endpoint)
159 )
160 );
161 } catch (err) {
162 console.error("[push] unsubscribeUser failed:", err);
163 }
164}
165
166// ---------------------------------------------------------------------------
167// Web Push HTTP encryption (RFC 8291 + RFC 8188 aes128gcm)
168// ---------------------------------------------------------------------------
169
170type SubscriptionRow = {
171 endpoint: string;
172 p256dh: string;
173 auth: string;
174};
175
176const TEXT = new TextEncoder();
177
178async function hkdf(
179 ikm: Uint8Array,
180 salt: Uint8Array,
181 info: Uint8Array,
182 length: number
183): Promise<Uint8Array> {
184 const key = await crypto.subtle.importKey(
185 "raw",
186 ikm as BufferSource,
187 "HKDF",
188 false,
189 ["deriveBits"]
190 );
191 const bits = await crypto.subtle.deriveBits(
192 {
193 name: "HKDF",
194 hash: "SHA-256",
195 salt: salt as BufferSource,
196 info: info as BufferSource,
197 },
198 key,
199 length * 8
200 );
201 return new Uint8Array(bits);
202}
203
204export async function encryptPayload(
205 payload: Uint8Array,
206 recipient: { p256dh: string; auth: string }
207): Promise<Uint8Array> {
208 const uaPublicRaw = b64urlDecode(recipient.p256dh);
209 const authSecret = b64urlDecode(recipient.auth);
210
211 const ephemeralKp = await crypto.subtle.generateKey(
212 { name: "ECDH", namedCurve: "P-256" },
213 true,
214 ["deriveBits"]
215 );
216 const ephemeralPubRaw = new Uint8Array(
217 await crypto.subtle.exportKey("raw", ephemeralKp.publicKey)
218 );
219
220 const uaPubKey = await crypto.subtle.importKey(
221 "raw",
222 uaPublicRaw as BufferSource,
223 { name: "ECDH", namedCurve: "P-256" },
224 false,
225 []
226 );
227 const ecdhSecret = new Uint8Array(
228 await crypto.subtle.deriveBits(
229 { name: "ECDH", public: uaPubKey },
230 ephemeralKp.privateKey,
231 256
232 )
233 );
234
235 const keyInfo = concat(
236 TEXT.encode("WebPush: info\0"),
237 uaPublicRaw,
238 ephemeralPubRaw
239 );
240 const ikm = await hkdf(ecdhSecret, authSecret, keyInfo, 32);
241
242 const salt = crypto.getRandomValues(new Uint8Array(16));
243 const cek = await hkdf(
244 ikm,
245 salt,
246 TEXT.encode("Content-Encoding: aes128gcm\0"),
247 16
248 );
249 const nonce = await hkdf(
250 ikm,
251 salt,
252 TEXT.encode("Content-Encoding: nonce\0"),
253 12
254 );
255
256 const plain = concat(payload, new Uint8Array([0x02]));
257
258 const aesKey = await crypto.subtle.importKey(
259 "raw",
260 cek as BufferSource,
261 "AES-GCM",
262 false,
263 ["encrypt"]
264 );
265 const cipher = new Uint8Array(
266 await crypto.subtle.encrypt(
267 { name: "AES-GCM", iv: nonce as BufferSource },
268 aesKey,
269 plain as BufferSource
270 )
271 );
272
273 const rs = 4096;
274 const header = new Uint8Array(16 + 4 + 1 + 65);
275 header.set(salt, 0);
276 header[16] = (rs >>> 24) & 0xff;
277 header[17] = (rs >>> 16) & 0xff;
278 header[18] = (rs >>> 8) & 0xff;
279 header[19] = rs & 0xff;
280 header[20] = 65;
281 header.set(ephemeralPubRaw, 21);
282
283 return concat(header, cipher);
284}
285
286function concat(...parts: Uint8Array[]): Uint8Array {
287 let total = 0;
288 for (const p of parts) total += p.length;
289 const out = new Uint8Array(total);
290 let off = 0;
291 for (const p of parts) {
292 out.set(p, off);
293 off += p.length;
294 }
295 return out;
296}
297
298// ---------------------------------------------------------------------------
299// VAPID JWT (RFC 8292)
300// ---------------------------------------------------------------------------
301
302function endpointOrigin(endpoint: string): string {
303 try {
304 const u = new URL(endpoint);
305 return `${u.protocol}//${u.host}`;
306 } catch {
307 return "";
308 }
309}
310
311export async function signVapidJwt(
312 audience: string,
313 subject: string
314): Promise<string> {
315 const header = { typ: "JWT", alg: "ES256" };
316 const claims = {
317 aud: audience,
318 exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60,
319 sub: subject,
320 };
321 const encHeader = b64urlEncode(TEXT.encode(JSON.stringify(header)));
322 const encClaims = b64urlEncode(TEXT.encode(JSON.stringify(claims)));
323 const signingInput = `${encHeader}.${encClaims}`;
324
325 const kp = await getVapidKeypair();
326 const pubRaw = b64urlDecode(kp.publicKey);
327 if (pubRaw[0] !== 0x04 || pubRaw.length !== 65) {
328 throw new Error("vapid public key must be 65-byte uncompressed P-256");
329 }
330 const x = pubRaw.slice(1, 33);
331 const y = pubRaw.slice(33, 65);
332
333 const jwk: JsonWebKey = {
334 kty: "EC",
335 crv: "P-256",
336 x: b64urlEncode(x),
337 y: b64urlEncode(y),
338 d: kp.privateKey,
339 ext: true,
340 };
341 const key = await crypto.subtle.importKey(
342 "jwk",
343 jwk,
344 { name: "ECDSA", namedCurve: "P-256" },
345 false,
346 ["sign"]
347 );
348 const sig = new Uint8Array(
349 await crypto.subtle.sign(
350 { name: "ECDSA", hash: "SHA-256" },
351 key,
352 TEXT.encode(signingInput) as BufferSource
353 )
354 );
355 return `${signingInput}.${b64urlEncode(sig)}`;
356}
357
358// ---------------------------------------------------------------------------
359// Transport seam
360// ---------------------------------------------------------------------------
361
362export type SendTransport = (
363 url: string,
364 init: { method: string; headers: Record<string, string>; body: Uint8Array }
365) => Promise<{ status: number }>;
366
367let _transport: SendTransport = async (url, init) => {
368 const res = await fetch(url, {
369 method: init.method,
370 headers: init.headers,
371 body: init.body as BodyInit,
372 });
373 return { status: res.status };
374};
375
376export function __setSendTransport(fn: SendTransport): SendTransport {
377 const prev = _transport;
378 _transport = fn;
379 return prev;
380}
381
382// ---------------------------------------------------------------------------
383// Top-level send
384// ---------------------------------------------------------------------------
385
386export type PushPayload = {
387 title: string;
388 body: string;
389 url?: string;
390 tag?: string;
391 icon?: string;
392};
393
394function payloadJson(p: PushPayload): string {
395 return JSON.stringify({
396 title: p.title,
397 body: p.body,
398 url: p.url ?? "/",
399 tag: p.tag ?? "gluecron",
400 icon: p.icon ?? "/icon.svg",
401 });
402}
403
404async function deliverOne(
405 sub: SubscriptionRow,
406 payload: PushPayload
407): Promise<{ ok: boolean; status: number }> {
408 try {
409 const kp = await getVapidKeypair();
410 const aud = endpointOrigin(sub.endpoint);
411 if (!aud) return { ok: false, status: 0 };
412 const sub_email =
413 process.env.VAPID_SUBJECT?.trim() || "mailto:ops@gluecron.com";
414 const jwt = await signVapidJwt(aud, sub_email);
415
416 const body = await encryptPayload(TEXT.encode(payloadJson(payload)), {
417 p256dh: sub.p256dh,
418 auth: sub.auth,
419 });
420
421 const headers: Record<string, string> = {
422 authorization: `vapid t=${jwt}, k=${kp.publicKey}`,
423 "content-encoding": "aes128gcm",
424 "content-type": "application/octet-stream",
425 ttl: "86400",
426 };
427 const res = await _transport(sub.endpoint, {
428 method: "POST",
429 headers,
430 body,
431 });
432 return { ok: res.status >= 200 && res.status < 300, status: res.status };
433 } catch (err) {
434 console.error("[push] deliver failed:", err);
435 return { ok: false, status: 0 };
436 }
437}
438
439export async function sendPushToUser(
440 userId: string,
441 payload: PushPayload
442): Promise<{ sent: number; failed: number }> {
443 let rows: SubscriptionRow[] = [];
444 try {
445 rows = await db
446 .select({
447 endpoint: pushSubscriptions.endpoint,
448 p256dh: pushSubscriptions.p256dh,
449 auth: pushSubscriptions.auth,
450 })
451 .from(pushSubscriptions)
452 .where(eq(pushSubscriptions.userId, userId));
453 } catch (err) {
454 console.error("[push] sendPushToUser select failed:", err);
455 return { sent: 0, failed: 0 };
456 }
457
458 let sent = 0;
459 let failed = 0;
460 for (const row of rows) {
461 const res = await deliverOne(row, payload);
462 if (res.ok) {
463 sent++;
464 try {
465 await db
466 .update(pushSubscriptions)
467 .set({ lastUsedAt: new Date() })
468 .where(
469 and(
470 eq(pushSubscriptions.userId, userId),
471 eq(pushSubscriptions.endpoint, row.endpoint)
472 )
473 );
474 } catch (_) {
475 // swallow
476 }
477 } else {
478 failed++;
479 if (res.status === 404 || res.status === 410) {
480 try {
481 await db
482 .delete(pushSubscriptions)
483 .where(
484 and(
485 eq(pushSubscriptions.userId, userId),
486 eq(pushSubscriptions.endpoint, row.endpoint)
487 )
488 );
489 } catch (_) {
490 // swallow
491 }
492 }
493 }
494 }
495 return { sent, failed };
496}
497
498// ---------------------------------------------------------------------------
499// notify() bridge
500// ---------------------------------------------------------------------------
501
502function pushPrefColumnFor(
503 kind: NotificationKind
504):
505 | "notifyPushOnMention"
506 | "notifyPushOnAssign"
507 | "notifyPushOnReviewRequest"
508 | "notifyPushOnDeployFailed"
509 | null {
510 switch (kind) {
511 case "mention":
512 return "notifyPushOnMention";
513 case "assigned":
514 return "notifyPushOnAssign";
515 case "review_requested":
516 return "notifyPushOnReviewRequest";
517 case "deploy_failed":
518 return "notifyPushOnDeployFailed";
519 default:
520 return null;
521 }
522}
523
524/**
525 * Optional hook callers can fire ALONGSIDE notify() to fan out a Web Push.
526 * Looks up the user's per-event preference and silently no-ops if the user
527 * opted out, the kind isn't push-eligible, or the user has no subscriptions.
528 */
529export async function pushFromNotification(
530 userId: string,
531 kind: NotificationKind,
532 opts: { title: string; body?: string; url?: string }
533): Promise<{ sent: number; failed: number } | null> {
534 const col = pushPrefColumnFor(kind);
535 if (!col) return null;
536 try {
537 const rows = await db
538 .select({
539 mention: users.notifyPushOnMention,
540 assign: users.notifyPushOnAssign,
541 review: users.notifyPushOnReviewRequest,
542 deploy: users.notifyPushOnDeployFailed,
543 })
544 .from(users)
545 .where(eq(users.id, userId))
546 .limit(1);
547 const u = rows[0];
548 if (!u) return null;
549 const allowed =
550 col === "notifyPushOnMention"
551 ? u.mention
552 : col === "notifyPushOnAssign"
553 ? u.assign
554 : col === "notifyPushOnReviewRequest"
555 ? u.review
556 : u.deploy;
557 if (!allowed) return null;
558 } catch (err) {
559 console.error("[push] pushFromNotification pref lookup failed:", err);
560 return null;
561 }
562 return sendPushToUser(userId, {
563 title: opts.title,
564 body: opts.body ?? "",
565 url: opts.url,
566 tag: `${kind}:${opts.url ?? ""}`,
567 });
568}
569
570export const __internal = {
571 pushPrefColumnFor,
572};