-
Notifications
You must be signed in to change notification settings - Fork 1
Web Push
Introduced in v0.46.0 as Phase 25. Implements payload-less Web Push using
only JDK 11+ stdlib (java.security for VAPID, java.net.http.HttpClient for
delivery). 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.
Real Web Push payloads must be encrypted with AES-128-GCM using a key derived from ECDH(vapidPrivate, subscription.p256dh) → HKDF, per RFC 8291. v0.46.0 does not implement that derivation chain — the POST body is empty, so the service worker shows a generic title/body:
self.addEventListener('push', (event) => {
let title = 'Vibe Coder';
let body = '서버에서 알림이 도착했습니다.';
// … try to parse event.data.json() if present (no-op without encryption)
event.waitUntil(self.registration.showNotification(title, { body, … }));
});The structured {title, body} JSON the server builds for each trigger is
still passed to HttpRequest.BodyPublishers.ofString(...) — once the
encryption layer lands, the service worker will be able to call
event.data.json() and surface the real content. The plumbing is done; only
the cipher remains.
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. Can a viewer subscribe?
No — POST /api/push/subscribe requires requireApiWrite. Only admin /
member 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.