Skip to content

Web Push

Sia edited this page May 31, 2026 · 3 revisions

Web Push Notifications

VAPID + RFC 8291 aes128gcm payload encryption, using only JDK 11+ stdlib (java.security + javax.crypto + java.net.http.HttpClient). No external dependency added.

When to use

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.

Quick start

  1. Open /settings/push as an admin.
  2. Click "이 브라우저에서 구독". The browser asks for notification permission; grant it.
  3. The page reloads showing your subscription.
  4. Click "테스트 알림 전송" — a notification should appear.

That's it. Every subsequent build / usage / disk alert reaches this browser through the service worker.

How it works

VAPID keypair

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.

Subscription flow

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.

VAPID JWT (RFC 8292)

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.

Payload encryption (RFC 8291 aes128gcm)

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):

  1. Generate ephemeral P-256 keypair (KeyPairGenerator("EC", secp256r1)).
  2. ECDH shared secret with the UA public key (subscription.p256dh) via KeyAgreement("ECDH") → 32 bytes.
  3. IKM = HKDF(salt = auth_secret, IKM = ECDH, info = "WebPush: info\0" + ua_pub + as_pub, L = 32).
  4. CEK = HKDF(salt = random16, IKM = IKM, info = "Content-Encoding: aes128gcm\0", L = 16).
  5. NONCE = HKDF(salt = same random16, IKM = IKM, info = "Content-Encoding: nonce\0", L = 12).
  6. plaintext_with_pad = payload || 0x02 || zeros filled to 4080 bytes (RECORD_SIZE = 4096, GCM tag = 16).
  7. AES-128-GCM (Cipher("AES/GCM/NoPadding"), 128-bit tag) → ciphertext.
  8. 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

Service worker (/static/sw.js)

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);
  }));
});

Trigger URL routing

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.

Schema

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

Routes

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

Trigger integration

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

Operator FAQ

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.

Related

Clone this wiki locally