-
Notifications
You must be signed in to change notification settings - Fork 1
Web Push
VAPID + RFC 8291 aes128gcm payload encryption, using only JDK 11+ stdlib
(java.security + javax.crypto + java.net.http.HttpClient). No external
dependency added.
Web Push complements Email and Webhook notifications. The same triggers fire on all three:
- Build success / failure
- Claude usage threshold (free tier almost exhausted)
- Disk usage threshold
If you have the dashboard open in a browser tab (or installed as a PWA on mobile), Web Push is the fastest signal — no SMTP server, no Slack workspace, no third-party API.
- Open
/settings/pushas an admin. - Click "이 브라우저에서 구독". The browser asks for notification permission; grant it.
- The page reloads showing your subscription.
- Click "테스트 알림 전송" — a notification should appear.
That's it. Every subsequent build / usage / disk alert reaches this browser through the service worker.
On first start the server generates a P-256 ECDSA keypair via
KeyPairGenerator.getInstance("EC") and persists it to
<workspace>/.vibecoder/vapid-keys.json:
{
"privateD": "<base64url 32 bytes>",
"publicX": "<base64url 32 bytes>",
"publicY": "<base64url 32 bytes>"
}Atomic write (.tmp + move REPLACE_EXISTING) survives crashes. Backup the
file alongside the rest of the workspace — losing it forces every browser to
re-subscribe.
Browser Server
─────── ──────
sw.register('/static/sw.js') → (static asset, no special handler)
GET /api/push/vapid-public-key returns base64url uncompressed point
(04||X||Y, 65 bytes)
←
pushManager.subscribe({
applicationServerKey:
b64UrlToUint8(publicKey)
})
POST /api/push/subscribe → PushSubscriptions.upsert(
{ userId, endpoint, p256dh, auth, ua
endpoint, p256dh, auth, ua )
} requireApiWrite()
← {"id":"…","ok":true}
The browser keeps the subscription in its own profile; the server keeps the
endpoint in the push_subscriptions table.
For every push the server builds a JWT signed with the VAPID private key:
header = base64url({"typ":"JWT","alg":"ES256"})
payload = base64url({
"aud": "<scheme>://<host>[:port]", // origin of the push endpoint
"exp": <unix + 12h>,
"sub": "mailto:noreply@vibecoder.local"
})
sig = base64url(ECDSA-P-256-SHA256(header.payload))
↑ converted from JDK's DER output to JOSE raw (R||S, 64 bytes)
jwt = "<header>.<payload>.<sig>"
Sent as:
POST <subscription.endpoint>
Authorization: vapid t=<jwt>, k=<vapid-public-base64url>
TTL: 60
Content-Type: application/json
Content-Length: 0 (payload-less — see below)
410 / 404 response → subscription is gone; the row is auto-deleted via
the onGoneSubscription(id) callback.
The encrypted payload path ships real title / body / url to the
browser. The service worker reads them via event.data.json() (the browser
handles decryption transparently).
Aes128GcmEncrypt.encrypt(payload, uaPublicRaw, authSecret):
- Generate ephemeral P-256 keypair (
KeyPairGenerator("EC", secp256r1)). - ECDH shared secret with the UA public key (subscription.p256dh) via
KeyAgreement("ECDH")→ 32 bytes. -
IKM = HKDF(salt = auth_secret, IKM = ECDH, info = "WebPush: info\0" + ua_pub + as_pub, L = 32). -
CEK = HKDF(salt = random16, IKM = IKM, info = "Content-Encoding: aes128gcm\0", L = 16). -
NONCE = HKDF(salt = same random16, IKM = IKM, info = "Content-Encoding: nonce\0", L = 12). - plaintext_with_pad =
payload || 0x02 || zerosfilled to 4080 bytes (RECORD_SIZE = 4096, GCM tag = 16). - AES-128-GCM (
Cipher("AES/GCM/NoPadding"), 128-bit tag) → ciphertext. - POST body =
salt(16) || record_size(4 BE) || keyid_len(1) || as_public(65) || ciphertext.
HKDF-SHA256 is collapsed into one Mac("HmacSHA256") extract + one
expand call (L ≤ 32 fits in a single HMAC block — the only sizes RFC
8291 needs).
The server picks the path per subscription:
| Subscription state | Path |
|---|---|
p256dh + auth present |
Content-Encoding: aes128gcm POST with encrypted body |
| missing keys (legacy row) | payload-less POST (Content-Length: 0) — service worker shows a generic notification |
self.addEventListener('push', (event) => {
let title = 'Vibe Coder';
let body = '서버에서 알림이 도착했습니다.';
let url = '/';
try {
if (event.data) {
const j = event.data.json(); // browser decrypts aes128gcm transparently
if (j.title) title = j.title;
if (j.body) body = j.body;
if (j.url) url = j.url;
}
} catch (_) {} // payload-less fallback
event.waitUntil(self.registration.showNotification(title, {
body, icon: '/static/icon.png', badge: '/static/icon.png',
tag: 'vibe-coder', data: { url },
}));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = (event.notification.data && event.notification.data.url) || '/';
event.waitUntil(self.clients.matchAll({ type: 'window' }).then((wins) => {
for (const c of wins) {
if ('focus' in c && c.url.endsWith(target)) return c.focus();
}
return self.clients.openWindow(target);
}));
});The Notifiers facade passes meaningful target URLs:
| Trigger | URL |
|---|---|
| Build result | /projects/{id}/builds/{buildId} |
| Claude usage warn | /usage |
| Disk usage warn | / |
Click the notification → service worker focuses the relevant tab if its URL already ends with the target, otherwise opens a new tab there.
CREATE TABLE push_subscriptions (
id varchar(64) PRIMARY KEY,
user_id varchar(64) REFERENCES admin_users(id),
endpoint text UNIQUE NOT NULL,
p256dh varchar(128),
auth varchar(64),
user_agent varchar(256),
created_at varchar(64),
last_used_at varchar(64)
);endpoint is unique — re-subscribing from the same browser updates the row
in place (upsert(... endpoint ...)).
| Path | Method | Auth | Purpose |
|---|---|---|---|
/api/push/vapid-public-key |
GET | Bearer | Returns {"publicKey":"<base64url>"}
|
/api/push/subscribe |
POST | Bearer + requireApiWrite
|
Upserts a subscription |
/api/push/subscriptions/{id} |
DELETE | Bearer + requireApiWrite
|
Removes a subscription |
/settings/push |
GET | Session cookie | Admin SSR — subscribe button + list + test send |
/settings/push/test |
POST | Session cookie + write | Broadcasts a test notification |
/settings/push/delete/{id} |
POST | Session cookie + write | Removes a subscription via SSR |
// notify/Notifiers.kt
fun buildResult(projectId, buildId, status, errorMessage) {
email ?.buildResult(projectId, buildId, status, errorMessage)
webhook ?.buildResult(projectId, buildId, status, errorMessage)
webPush ?.broadcast(
title = "빌드 $status",
body = "프로젝트 $projectId / 빌드 $buildId — ${errorMessage ?: "성공"}",
)
}So enabling Web Push doesn't replace email / webhook — it adds a parallel
channel. Disable each notifier independently in its /settings/* page.
Q. Can I lose subscriptions?
A subscription is bound to the browser's push service token. If the user
clears site data, the token vanishes — the server will get 410 Gone on
the next push and auto-delete. No manual cleanup needed.
Q. Multiple browsers?
Yes. Each (browser, vendor) combo registers a separate endpoint. Phone +
laptop both subscribed → both get the alert.
Q. Behind a corporate firewall that blocks *.fcm.googleapis.com?
The browser silently fails to register, or the push never reaches. Web Push
is best-effort — fall back to email/webhook for guaranteed delivery.
Q. What auth does subscribing need?
POST /api/push/subscribe requires requireApiWrite — the authenticated
admin can register a subscription.
- Email Notifications — same trigger matrix, SMTP.
- Webhook Notifications — Slack / Discord / Telegram.
-
PWA & VS Code — the same service worker that handles
PWA caching also handles
push/notificationclickevents.