R9 Web Push in decePub: one subscription per persona, each with its own token and registration
Build / Build (push) Successful in 56s

Settings gets "Notifications on this device" (PushEditor): personas, alerts in four groups, policy, a test
notification, after a one-time notice of what the push service learns. Each persona ticked gets its own service
worker registration (push-worker.js at push/{slot}/), so its own endpoint and keys, subscribed with its own token
(IHttpService's *As); nothing PrivaPub stores ties two personas together and the worker never reads the payload's
token. PushService follows the tokens (re-subscribing after a renewal), reconciles at every session start, turns
off before sign-out and drops deleted or suspended personas. A click opens that persona's notifications; the app
badge counts the unread ones. Exchanges now ask for the push scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-18 09:50:46 +02:00
1 parent d33697e3fc
commit ab17959ed4
29 files changed
+2047 -67

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
// The service worker of one persona's Web Push subscription. wwwroot/js/push.js registers this script once per persona
// the reader turns notifications on for, at the scope push/{slot}/ (a random name this device keeps in localStorage's
// PushDevice, Services/PushService.cs), so each persona has a push subscription of its own: its own endpoint and keys,
// which PrivaPub stores with that persona's token alone. The same file serves development and published builds; the
// app's own workers (service-worker.js, service-worker.published.js) receive no pushes. It controls no page, so it has no
// fetch handler.
//
// A push shows the payload's title, body and icon, tagged with its notification id so a repeat replaces it. The payload
// is Mastodon's, so it also carries the subscription's access token: that is never read, kept, shown or passed on here.
// A click opens decePub on the notifications of the persona this registration is for: an open decePub tab is focused and
// told which (main.js, CascadingState.PushOpened), else a new one opens at ?notifications=1&push={slot}.
const appRoot = new URL("./", self.location).href
const slot = new URL(self.registration.scope).pathname.split("/").filter(part => part.length > 0).pop()
self.addEventListener("install", () => self.skipWaiting())
self.addEventListener("push", event => event.waitUntil(show(event.data)))
self.addEventListener("notificationclick", event => {
event.notification.close()
event.waitUntil(open())
})
// the push service changed the subscription: the same key subscribes again here, and the app tells PrivaPub the new
// endpoint the next time it starts (PushService.Reconcile), since this worker holds no token to tell it with
self.addEventListener("pushsubscriptionchange", event => event.waitUntil(renew(event)))
async function show(data) {
let title = "decePub"
const options = { icon: appRoot + "imgs/icon-192.png", timestamp: Date.now(), data: {} }
try {
const payload = data ? data.json() : null
if (payload && typeof payload == "object") {
if (typeof payload.title == "string" && payload.title.length > 0)
title = payload.title
if (typeof payload.body == "string")
options.body = payload.body
if (isWebAddress(payload.icon))
options.icon = payload.icon
if (payload.notification_id != null)
options.tag = String(payload.notification_id)
if (typeof payload.preferred_locale == "string")
options.lang = payload.preferred_locale
options.data = { notificationId: options.tag ?? null, type: typeof payload.notification_type == "string" ? payload.notification_type : null }
}
} catch (e) {
console.error(e)
}
// a push always shows something (userVisibleOnly): one that can't be read shows decePub's name alone
await self.registration.showNotification(title, options)
try {
// the count is the app's to say (Index.razor); until it opens, a dot says something waits
await self.navigator.setAppBadge?.()
} catch (e) {
console.debug(e)
}
}
function isWebAddress(value) {
if (typeof value != "string")
return false
try {
const url = new URL(value)
return url.protocol === "https:" || url.protocol === "http:"
} catch {
return false
}
}
async function open() {
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true })
const app = windows.filter(client => client.url.startsWith(appRoot))
const target = app.find(client => client.focused) ?? app.find(client => client.visibilityState === "visible") ?? app[0]
if (target) {
try {
await target.focus()
} catch (e) {
console.error(e)
}
// (navigate() is only for the worker that controls the tab: the app's own one does)
target.postMessage({ type: "decepub-push-open", slot })
return
}
const url = new URL(appRoot)
url.searchParams.set("notifications", "1")
url.searchParams.set("push", slot)
await self.clients.openWindow(url.href)
}
async function renew(event) {
try {
const key = event.oldSubscription?.options?.applicationServerKey
if (!key || event.newSubscription)
return
await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
} catch (e) {
console.error(e)
}
}