aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-android/README.md9
-rw-r--r--packages/meshbay-android/app/build.gradle.kts5
-rw-r--r--packages/meshbay-android/app/src/main/AndroidManifest.xml18
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js11
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt25
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt3
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt91
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt65
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt29
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt76
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt41
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt126
-rw-r--r--packages/meshbay-android/app/src/main/res/drawable/ic_notify.xml11
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt108
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/notifications.py23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/push.py277
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e7f8a9b0c1d2_push_subscriptions.py35
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/push.js68
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/settings-page.js41
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/webpush.py194
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py30
-rw-r--r--packages/meshbay-hub/tests/test_push.py455
-rw-r--r--packages/meshbay-hub/tests/test_unauthenticated_surface.py1
38 files changed, 1884 insertions, 6 deletions
diff --git a/packages/meshbay-android/README.md b/packages/meshbay-android/README.md
index 5cb474e..69977ec 100644
--- a/packages/meshbay-android/README.md
+++ b/packages/meshbay-android/README.md
@@ -53,6 +53,15 @@ does not install over a debug build, or the reverse: the keys differ.
The security contract is also pinned from the Python suite by reading this
source: `packages/meshbay-hub/tests/test_android_shell.py`.
+Notifications while closed, with nothing to install: `notify/PollJob` (a
+system job, no library) fetches what is new from the hub every fifteen minutes
+with the phone's poll secret — never a session. When a UnifiedPush distributor
+is already on the phone (ntfy, …) it is used too: the hub pushes at once,
+encrypted to the phone (RFC 8291), `notify/PushReceiver` draws what the
+connector could decrypt, and the fetch slows to a four-hour net. The page turns
+it on in Settings and registers the phone with the hub; muting and "disable
+all" are decided on the hub, which then creates nothing (§11.3).
+
Not built yet: phone-specific behaviour (back button, network handover,
keeping a download alive with the screen off), updates through a store.
diff --git a/packages/meshbay-android/app/build.gradle.kts b/packages/meshbay-android/app/build.gradle.kts
index 8afbc1a..7f7ba3c 100644
--- a/packages/meshbay-android/app/build.gradle.kts
+++ b/packages/meshbay-android/app/build.gradle.kts
@@ -57,6 +57,11 @@ dependencies {
// run time; where they are absent the page is offered no cast at all.
implementation("com.google.android.gms:play-services-cast-framework:22.3.1")
implementation("androidx.mediarouter:mediarouter:1.8.1")
+ // Notifications are fetched with nothing to install; this is only for a
+ // phone that already has a UnifiedPush distributor (ntfy, …), which then
+ // makes them instant, encrypted to the phone (RFC 8291) — no vendor push
+ // service. Apache-2.0, as is Tink, which it brings for the decryption.
+ implementation("org.unifiedpush.android:connector:3.3.5")
testImplementation("junit:junit:4.13.2")
// Android's org.json is a stub on the JVM; the unit tests need the real one.
testImplementation("org.json:json:20260814")
diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml
index 2eae3ea..0c234aa 100644
--- a/packages/meshbay-android/app/src/main/AndroidManifest.xml
+++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml
@@ -8,6 +8,11 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
+ <!-- Notifications from the hub, fetched or pushed; asked for when the
+ person turns them on, never at start. -->
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+ <!-- The periodic fetch survives a reboot (JobInfo.setPersisted). -->
+ <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- No backup of any kind: the keys are wrapped by a Keystore key that a
restore cannot bring with it, so a backed-up store is one that silently
@@ -36,6 +41,19 @@
android:name=".cast.CastService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
+ <!-- Not exported: the connector's own receiver takes the distributor's
+ broadcasts and hands them here inside the application. -->
+ <service
+ android:name=".notify.PushReceiver"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
+ </intent-filter>
+ </service>
+ <service
+ android:name=".notify.PollJob"
+ android:exported="false"
+ android:permission="android.permission.BIND_JOB_SERVICE" />
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="org.meshbay.client.cast.CastOptionsProvider" />
diff --git a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js
index b71b5e8..82e556d 100644
--- a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js
+++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js
@@ -186,6 +186,17 @@
keepAlive: (on) => call('playback:keep-alive', on === true),
},
+ // Notifications while closed: fetched, or pushed through a UnifiedPush
+ // distributor when the phone has one. Phone-only: the
+ // desktop preload has no counterpart, so `platform.push` is absent there.
+ push: {
+ status: () => call('push:status'),
+ enable: () => call('push:enable'),
+ disable: () => call('push:disable'),
+ remember: (subscription, account, secret, since) =>
+ call('push:remember', subscription, account, secret, since),
+ },
+
// Where downloads go, chosen once. A display name comes back, never a URI.
folder: {
choose: () => call('folder:choose'),
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
index 0fa63d6..dad8462 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
@@ -32,6 +32,9 @@ import org.meshbay.client.cast.CastChannels
import org.meshbay.client.cast.CastService
import org.meshbay.client.hub.HubClient
import org.meshbay.client.keys.SecretStore
+import org.meshbay.client.notify.Notifier
+import org.meshbay.client.notify.PushChannels
+import org.meshbay.client.notify.PushState
import org.meshbay.client.save.SaveSinks
import org.meshbay.client.shell.Pickers
import org.meshbay.client.shell.ShellWebView
@@ -62,6 +65,7 @@ class MainActivity : Activity() {
private var playing = false
private var fullscreen: View? = null
private var fullscreenCallback: WebChromeClient.CustomViewCallback? = null
+ private var pendingLink: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -89,13 +93,28 @@ class MainActivity : Activity() {
tell = { m -> runOnUiThread { android.widget.Toast.makeText(this, m, android.widget.Toast.LENGTH_LONG).show() } })
channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } },
hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves,
- cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } })
+ cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } },
+ push = PushChannels(this, PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE)),
+ channelNames = { mapOf(
+ Notifier.CHANNEL_CHAT to text.get("push.channel_chat", channels.locale),
+ Notifier.CHANNEL_OTHER to text.get("push.channel_other", channels.locale)) }))
WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels))
cast.control.warmUp()
installShim()
+ // Opened from a notification: to what it was about, once the page is up.
+ pendingLink = Notifier.linkOf(intent)
web.loadUrl(UiAssets.START)
}
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ Notifier.linkOf(intent)?.let { if (::web.isInitialized) goTo(it) }
+ }
+
+ /** A route inside the page (`#/…`), checked by Notifier — never a URL to load. */
+ private fun goTo(link: String) = web.evaluateJavascript("location.hash = ${JSONObject.quote(link)};", null)
+
private fun configure(web: WebView) {
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
web.settings.apply {
@@ -116,6 +135,10 @@ class MainActivity : Activity() {
.addPathHandler(UiAssets.PREFIX, UiAssets(this))
.build()
web.webViewClient = object : WebViewClientCompat() {
+ override fun onPageFinished(view: WebView, url: String) {
+ pendingLink?.let { pendingLink = null; goTo(it) }
+ }
+
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
val url = request.url
if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused()
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
index f7094a0..5f202ba 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
@@ -3,6 +3,7 @@ package org.meshbay.client.bridge
import org.json.JSONArray
import org.meshbay.client.cast.CastChannels
import org.meshbay.client.hub.HubClient
+import org.meshbay.client.notify.PushChannels
import org.meshbay.client.save.BinaryFrame
import org.meshbay.client.save.SaveSinks
import java.net.Inet4Address
@@ -26,6 +27,7 @@ class Channels(
private val saves: SaveSinks? = null,
private val cast: CastChannels? = null,
private val onPlayback: (Boolean) -> Unit = {},
+ private val push: PushChannels? = null,
) {
@Volatile var locale = "en"
private set
@@ -53,6 +55,7 @@ class Channels(
else -> when {
keys != null && keys.handles(channel) -> keys.call(channel, args)
cast != null && cast.handles(channel) -> cast.call(channel, args)
+ push != null && push.handles(channel) -> push.call(channel, args)
else -> throw Refused("Refused: no such channel")
}
}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt
new file mode 100644
index 0000000..7e6fce6
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt
@@ -0,0 +1,91 @@
+package org.meshbay.client.notify
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import org.json.JSONObject
+import org.meshbay.client.MainActivity
+import org.meshbay.client.R
+
+/**
+ * A notification from the hub, pushed or fetched, drawn by the system.
+ *
+ * The text is the hub's own line ("… posted in …") — the hub never holds a
+ * message, so neither does a push. A conversation is one entry per group,
+ * replaced as it goes on, as it is one row on the hub.
+ */
+object Notifier {
+ const val CHANNEL_CHAT = "chat"
+ const val CHANNEL_OTHER = "account"
+ const val EXTRA_LINK = "org.meshbay.client.LINK"
+ private val LINK = Regex("^#/[A-Za-z0-9/_-]{0,200}$")
+
+ data class Shown(val channel: String, val tag: String, val title: String, val link: String?,
+ val id: Long, val createdAt: String)
+
+ /**
+ * What to draw for a payload, or null for one that is not ours to draw.
+ * It was decrypted with this phone's key or fetched from the signed-in hub
+ * with this phone's secret; it is still checked like any input.
+ */
+ fun parse(content: ByteArray): Shown? {
+ val o = try { JSONObject(String(content, Charsets.UTF_8)) } catch (e: Exception) { return null }
+ if (o.optInt("v", 0) != 1) return null
+ val kind = o.optString("kind", "").take(32)
+ val title = o.optString("title", "").take(256).ifBlank { return null }
+ val group = if (o.isNull("group_id")) "" else o.optString("group_id", "").take(64)
+ val link = (if (o.isNull("link")) null else o.optString("link", null))?.takeIf { LINK.matches(it) }
+ val chat = kind == "chat_message" && group.isNotEmpty()
+ val id = o.optLong("id", 0)
+ val createdAt = o.optString("created_at", "").take(40)
+ val tag = if (chat) "chat:$group" else "n:$id"
+ return Shown(if (chat) CHANNEL_CHAT else CHANNEL_OTHER, tag, title, link, id, createdAt)
+ }
+
+ /** Draw it unless it was already drawn — pushed, then fetched, is one line. */
+ fun deliver(context: Context, state: PushState, shown: Shown) {
+ if (state.firstSight(shown.id, shown.createdAt)) show(context, shown)
+ }
+
+ /**
+ * The two channels, named in the person's language. Called with names when
+ * the page turns push on; from the receiver with none, only to make sure a
+ * channel exists, so a localized name is never overwritten by the fallback.
+ */
+ fun ensureChannels(context: Context, names: Map<String, String>? = null) {
+ val nm = context.getSystemService(NotificationManager::class.java)
+ for ((id, fallback, importance) in listOf(
+ Triple(CHANNEL_CHAT, "Messages", NotificationManager.IMPORTANCE_DEFAULT),
+ Triple(CHANNEL_OTHER, "Invitations and account", NotificationManager.IMPORTANCE_DEFAULT),
+ )) {
+ if (names == null && nm.getNotificationChannel(id) != null) continue
+ nm.createNotificationChannel(NotificationChannel(id, names?.get(id) ?: fallback, importance))
+ }
+ }
+
+ fun show(context: Context, shown: Shown) {
+ ensureChannels(context)
+ val open = Intent(context, MainActivity::class.java)
+ .setAction(Intent.ACTION_VIEW)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ shown.link?.let { open.putExtra(EXTRA_LINK, it) }
+ val pending = PendingIntent.getActivity(context, shown.tag.hashCode(), open,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
+ val n = Notification.Builder(context, shown.channel)
+ .setSmallIcon(R.drawable.ic_notify)
+ .setContentTitle("MeshBay")
+ .setContentText(shown.title)
+ .setStyle(Notification.BigTextStyle().bigText(shown.title))
+ .setContentIntent(pending)
+ .setAutoCancel(true)
+ .setCategory(if (shown.channel == CHANNEL_CHAT) Notification.CATEGORY_MESSAGE else Notification.CATEGORY_SOCIAL)
+ .build()
+ context.getSystemService(NotificationManager::class.java).notify(shown.tag, 1, n)
+ }
+
+ /** A link from a notification the shell itself drew, checked again on the way in. */
+ fun linkOf(intent: Intent?): String? = intent?.getStringExtra(EXTRA_LINK)?.takeIf { LINK.matches(it) }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt
new file mode 100644
index 0000000..0998d00
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt
@@ -0,0 +1,65 @@
+package org.meshbay.client.notify
+
+import android.app.job.JobParameters
+import android.app.job.JobService
+import android.content.Context
+import android.util.Log
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import org.meshbay.client.bridge.Bridge
+import org.meshbay.client.hub.HubClient
+import java.util.concurrent.TimeUnit
+
+/**
+ * One fetch of what is new (`POST /v1/push/poll`), page running or not.
+ *
+ * Authenticated by the row's poll secret, never a session: what this keeps for
+ * running in the background reads notification lines and nothing else. It goes
+ * to the hub the application is signed in to, and only there.
+ */
+class PollJob : JobService() {
+ @Volatile private var worker: Thread? = null
+
+ override fun onStartJob(params: JobParameters): Boolean {
+ worker = Thread {
+ try { fetch() } catch (e: Exception) { Log.w(Bridge.TAG, "poll failed: ${e.javaClass.simpleName}") }
+ jobFinished(params, false)
+ }.apply { start() }
+ return true
+ }
+
+ override fun onStopJob(params: JobParameters): Boolean {
+ worker?.interrupt()
+ return false
+ }
+
+ private fun fetch() {
+ val state = PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE))
+ val target = state.pollTarget() ?: return
+ val base = HubClient(getSharedPreferences("shell", Context.MODE_PRIVATE)).base
+ val url = base.toHttpUrlOrNull()?.newBuilder()?.encodedPath("/v1/push/poll")?.build() ?: return
+ val body = JSONObject().put("id", target.id).put("secret", target.secret).put("since", target.since)
+ .toString().toRequestBody("application/json".toMediaType())
+ val http = OkHttpClient.Builder().callTimeout(30, TimeUnit.SECONDS).followRedirects(false).build()
+ http.newCall(Request.Builder().url(url).post(body).build()).execute().use { r ->
+ when {
+ // The row is gone — signed out elsewhere, or deleted: the page
+ // registers again when it next runs, if push is still on.
+ r.code == 404 -> state.forgetSubscription()
+ r.isSuccessful -> {
+ val list = JSONObject(r.body.string()).optJSONArray("notifications") ?: return
+ for (i in 0 until list.length()) {
+ val raw = list.optJSONObject(i)?.toString()?.toByteArray() ?: continue
+ val shown = Notifier.parse(raw) ?: continue
+ Notifier.deliver(this, state, shown)
+ state.advance(shown.createdAt)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt
new file mode 100644
index 0000000..8f58512
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt
@@ -0,0 +1,29 @@
+package org.meshbay.client.notify
+
+import android.app.job.JobInfo
+import android.app.job.JobScheduler
+import android.content.ComponentName
+import android.content.Context
+
+/**
+ * The periodic fetch, through the system's own job scheduler: no library and
+ * nothing the platform does not already run. Android decides the exact moment
+ * (fifteen minutes is the shortest period it allows, and Doze stretches it);
+ * a phone that also gets pushes keeps a slow fetch as a net under them.
+ */
+object Poller {
+ private const val JOB_ID = 4208
+ private const val FETCHING_MS = 15L * 60 * 1000
+ private const val UNDER_PUSH_MS = 4L * 3600 * 1000
+
+ fun schedule(context: Context, pushed: Boolean) {
+ val job = JobInfo.Builder(JOB_ID, ComponentName(context, PollJob::class.java))
+ .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
+ .setPeriodic(if (pushed) UNDER_PUSH_MS else FETCHING_MS)
+ .setPersisted(true)
+ .build()
+ context.getSystemService(JobScheduler::class.java).schedule(job)
+ }
+
+ fun cancel(context: Context) = context.getSystemService(JobScheduler::class.java).cancel(JOB_ID)
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt
new file mode 100644
index 0000000..e8619a5
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt
@@ -0,0 +1,76 @@
+package org.meshbay.client.notify
+
+import android.app.Activity
+import android.app.NotificationManager
+import android.os.Build
+import org.json.JSONArray
+import org.json.JSONObject
+import org.unifiedpush.android.connector.UnifiedPush
+
+/**
+ * Notifications on this phone (§11.3), with nothing to install.
+ *
+ * Turned on, the phone fetches what is new every quarter of an hour (`PollJob`).
+ * If a UnifiedPush distributor is already on the phone — ntfy, or an application
+ * that carries one — it is used as well, and notifications arrive at once; if it
+ * refuses or goes away, the phone simply goes back to fetching. The page gives
+ * the hub whatever this side ends up with. Phone-only: the desktop has no
+ * counterpart, and its preload has no such channels.
+ *
+ * Whether a notification is wanted at all — every one turned off, or one group
+ * muted — is decided on the hub, which then creates nothing, so nothing is
+ * pushed or fetched. Nothing here second-guesses it.
+ */
+class PushChannels(
+ private val activity: Activity,
+ private val state: PushState,
+ private val channelNames: () -> Map<String, String>,
+) {
+ fun handles(channel: String) = channel.startsWith("push:")
+
+ fun call(channel: String, args: JSONArray): Any? = when (channel) {
+ "push:status" -> status()
+ "push:enable" -> enable()
+ "push:disable" -> { disable(); status() }
+ "push:remember" -> {
+ state.remember(args.optString(0, ""), args.optString(1, ""), args.optString(2, ""), args.optString(3, ""))
+ Poller.schedule(activity, pushed = state.endpoint != null)
+ status()
+ }
+ else -> throw org.meshbay.client.bridge.Refused("Refused: no such channel")
+ }
+
+ private fun distributors() = UnifiedPush.getDistributors(activity).any { it != activity.packageName }
+
+ private fun status(): JSONObject = state.status()
+ .put("distributors", distributors())
+ .put("permitted", activity.getSystemService(NotificationManager::class.java).areNotificationsEnabled())
+
+ private fun enable(): JSONObject {
+ state.requested()
+ Notifier.ensureChannels(activity, channelNames())
+ if (!distributors()) state.fellBack("NO_DISTRIBUTOR")
+ activity.runOnUiThread {
+ if (Build.VERSION.SDK_INT >= 33) {
+ activity.requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), PERMISSION_REQUEST)
+ }
+ // Only when one is installed: the system's chooser for a person who
+ // has none would be a screen about something they never asked for.
+ if (distributors()) UnifiedPush.tryUseDefaultDistributor(activity) { found ->
+ if (found) UnifiedPush.register(activity, messageForDistributor = "MeshBay")
+ else state.fellBack("NO_DISTRIBUTOR")
+ }
+ }
+ return status()
+ }
+
+ private fun disable() {
+ Poller.cancel(activity)
+ try { UnifiedPush.removeDistributor(activity) } catch (e: Exception) { /* none was saved */ }
+ state.cleared()
+ }
+
+ companion object {
+ private const val PERMISSION_REQUEST = 4207
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt
new file mode 100644
index 0000000..64ea7a7
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt
@@ -0,0 +1,41 @@
+package org.meshbay.client.notify
+
+import android.content.Context
+import android.util.Log
+import org.meshbay.client.bridge.Bridge
+import org.unifiedpush.android.connector.FailedReason
+import org.unifiedpush.android.connector.PushService
+import org.unifiedpush.android.connector.data.PushEndpoint
+import org.unifiedpush.android.connector.data.PushMessage
+
+/**
+ * What a distributor tells this application, page running or not — when the
+ * phone has one. Losing it is not an error: the phone goes back to fetching
+ * (`PollJob`), and the page tells the hub at its next start.
+ *
+ * A message is drawn only if the connector decrypted it with this phone's
+ * key: anything else did not come from a hub holding the subscription.
+ */
+class PushReceiver : PushService() {
+ private fun state() = PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE))
+
+ override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
+ state().endpoint(endpoint.url, endpoint.pubKeySet?.pubKey, endpoint.pubKeySet?.auth)
+ }
+
+ override fun onMessage(message: PushMessage, instance: String) {
+ val state = state()
+ if (!message.decrypted || !state.enabled) { Log.w(Bridge.TAG, "push message dropped"); return }
+ Notifier.parse(message.content)?.let { Notifier.deliver(this, state, it) }
+ }
+
+ override fun onRegistrationFailed(reason: FailedReason, instance: String) = fallBack(reason.name)
+
+ override fun onUnregistered(instance: String) = fallBack("UNREGISTERED")
+
+ private fun fallBack(reason: String) {
+ val state = state()
+ state.fellBack(reason)
+ if (state.enabled) Poller.schedule(this, pushed = false)
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt
new file mode 100644
index 0000000..3905d58
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt
@@ -0,0 +1,126 @@
+package org.meshbay.client.notify
+
+import android.content.SharedPreferences
+import org.json.JSONObject
+
+/**
+ * Where this phone stands with the hub and, when it has one, with a push
+ * distributor — kept across restarts, because both answer whenever they like,
+ * often with the page not running.
+ *
+ * Turned on, it is always `ready` in the end: with an endpoint when a
+ * distributor gave one (pushed, instantly), without one otherwise (fetched,
+ * every quarter of an hour). Nothing to install is the default; a distributor
+ * is a bonus, and losing it falls back rather than failing.
+ *
+ * `registered` is the endpoint the hub was last given (null for none). When it
+ * differs from `endpoint` the page registers again; that is the whole of what
+ * keeps the hub's row current.
+ */
+class PushState(private val prefs: SharedPreferences) {
+
+ fun status(): JSONObject = JSONObject()
+ .put("enabled", enabled)
+ .put("state", prefs.getString(STATE, OFF))
+ .put("reason", prefs.getString(REASON, null) ?: JSONObject.NULL)
+ .put("endpoint", endpoint ?: JSONObject.NULL)
+ .put("p256dh", prefs.getString(P256DH, null) ?: JSONObject.NULL)
+ .put("auth", prefs.getString(AUTH, null) ?: JSONObject.NULL)
+ .put("subscription", prefs.getString(SUBSCRIPTION, null) ?: JSONObject.NULL)
+ .put("account", prefs.getString(ACCOUNT, null) ?: JSONObject.NULL)
+ .put("registered", prefs.getString(REGISTERED, null) ?: JSONObject.NULL)
+
+ val enabled get() = prefs.getBoolean(ENABLED, false)
+ val endpoint: String? get() = prefs.getString(ENDPOINT, null)
+
+ fun requested() = prefs.edit().putBoolean(ENABLED, true).putString(STATE, PENDING).remove(REASON).apply()
+
+ fun endpoint(url: String, p256dh: String?, auth: String?) {
+ if (!enabled) return
+ // A distributor speaking only the old protocol gives no keys, and
+ // nothing could be encrypted to it: fetch instead.
+ if (p256dh == null || auth == null) { fellBack("NO_KEYS"); return }
+ prefs.edit().putString(STATE, READY).remove(REASON)
+ .putString(ENDPOINT, url).putString(P256DH, p256dh).putString(AUTH, auth).apply()
+ }
+
+ /** No distributor, or it refused or dropped us: fetch, and say why. */
+ fun fellBack(reason: String) {
+ if (!enabled) return
+ prefs.edit().putString(STATE, READY).putString(REASON, reason)
+ .remove(ENDPOINT).remove(P256DH).remove(AUTH).apply()
+ }
+
+ /** What the hub answered a registration: its row, the account, the poll secret, its clock. */
+ fun remember(subscription: String, account: String, secret: String, since: String) {
+ require(SUBSCRIPTION_ID.matches(subscription) && ACCOUNT_ID.matches(account) &&
+ SECRET.matches(secret) && SINCE.matches(since)) { "bad subscription" }
+ prefs.edit().putString(SUBSCRIPTION, subscription).putString(ACCOUNT, account)
+ .putString(SECRET_KEY, secret).putString(SINCE_KEY, since)
+ .putString(REGISTERED, endpoint).apply()
+ }
+
+ /** The hub no longer knows this row: the page registers again when it next runs. */
+ fun forgetSubscription() = prefs.edit().remove(SUBSCRIPTION).remove(SECRET_KEY).remove(REGISTERED).apply()
+
+ data class PollTarget(val id: String, val secret: String, val since: String)
+
+ fun pollTarget(): PollTarget? {
+ if (!enabled) return null
+ return PollTarget(prefs.getString(SUBSCRIPTION, null) ?: return null,
+ prefs.getString(SECRET_KEY, null) ?: return null,
+ prefs.getString(SINCE_KEY, null) ?: return null)
+ }
+
+ /** Fetch from here next time. ISO-8601 from the hub's own clock, so they compare as text. */
+ fun advance(cursor: String) {
+ if (SINCE.matches(cursor) && cursor > (prefs.getString(SINCE_KEY, "") ?: "")) {
+ prefs.edit().putString(SINCE_KEY, cursor).apply()
+ }
+ }
+
+ /**
+ * True the first time this line is seen at this date — pushed and then
+ * fetched, it is drawn once. A conversation keeps its id and moves its
+ * date, so a newer date is news again.
+ */
+ @Synchronized
+ fun firstSight(id: Long, createdAt: String): Boolean {
+ val seen = try { JSONObject(prefs.getString(SEEN, "{}") ?: "{}") } catch (e: Exception) { JSONObject() }
+ val key = id.toString()
+ if (seen.optString(key, "") >= createdAt) return false
+ seen.put(key, createdAt)
+ if (seen.length() > SEEN_MAX) {
+ seen.keys().asSequence().toList().sortedBy { seen.optString(it) }
+ .take(seen.length() - SEEN_MAX).forEach { seen.remove(it) }
+ }
+ prefs.edit().putString(SEEN, seen.toString()).apply()
+ return true
+ }
+
+ fun cleared() = prefs.edit().clear().apply()
+
+ companion object {
+ const val OFF = "off"
+ const val PENDING = "pending"
+ const val READY = "ready"
+ private const val ENABLED = "enabled"
+ private const val STATE = "state"
+ private const val REASON = "reason"
+ private const val ENDPOINT = "endpoint"
+ private const val P256DH = "p256dh"
+ private const val AUTH = "auth"
+ private const val SUBSCRIPTION = "subscription"
+ private const val ACCOUNT = "account"
+ private const val REGISTERED = "registered"
+ private const val SECRET_KEY = "pollSecret"
+ private const val SINCE_KEY = "since"
+ private const val SEEN = "seen"
+ private const val SEEN_MAX = 100
+ private val SUBSCRIPTION_ID = Regex("^[0-9a-f-]{36}$")
+ private val ACCOUNT_ID = Regex("^[0-9A-Za-z-]{1,64}$")
+ private val SECRET = Regex("^[A-Za-z0-9_-]{20,64}$")
+ private val SINCE = Regex("^\\d{4}-\\d\\d-\\d\\dT[0-9:.]+(\\+00:00|Z)$")
+ const val PREFS = "push"
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/res/drawable/ic_notify.xml b/packages/meshbay-android/app/src/main/res/drawable/ic_notify.xml
new file mode 100644
index 0000000..ec26359
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/res/drawable/ic_notify.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- The status-bar icon: the system keeps only its alpha, so a plain M. -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="#FFFFFFFF"
+ android:pathData="M3,20V4h3l6,8 6,-8h3v16h-3V9l-6,8 -6,-8v11z" />
+</vector>
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt
new file mode 100644
index 0000000..c575906
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt
@@ -0,0 +1,108 @@
+package org.meshbay.client
+
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.meshbay.client.notify.Notifier
+import org.meshbay.client.notify.PushState
+
+class PushTest {
+ private fun payload(vararg pairs: Pair<String, Any?>) =
+ JSONObject().apply { put("v", 1); pairs.forEach { (k, v) -> put(k, v ?: JSONObject.NULL) } }
+ .toString().toByteArray()
+
+ @Test fun `a conversation is one entry per group, anything else one per notification`() {
+ val chat = Notifier.parse(payload("id" to 7, "kind" to "chat_message", "title" to "a posted in b",
+ "group_id" to "g-1", "link" to "#/group/g-1"))!!
+ assertEquals(Notifier.CHANNEL_CHAT, chat.channel)
+ assertEquals("chat:g-1", chat.tag)
+ assertEquals("#/group/g-1", chat.link)
+ val invite = Notifier.parse(payload("id" to 8, "kind" to "group_invite", "title" to "x invited you",
+ "group_id" to null, "link" to "#/"))!!
+ assertEquals(Notifier.CHANNEL_OTHER, invite.channel)
+ assertEquals("n:8", invite.tag)
+ }
+
+ @Test fun `a link that is not a route inside the page is dropped, not followed`() {
+ for (bad in listOf("https://elsewhere.example/", "javascript:alert(1)", "#/x\";alert(1)//", "//host/#/")) {
+ val shown = Notifier.parse(payload("id" to 1, "kind" to "k", "title" to "t", "link" to bad))!!
+ assertNull(bad, shown.link)
+ }
+ }
+
+ @Test fun `what is not ours to draw is not drawn`() {
+ assertNull(Notifier.parse("not json".toByteArray()))
+ assertNull(Notifier.parse(JSONObject().put("v", 2).put("title", "t").toString().toByteArray()))
+ assertNull(Notifier.parse(payload("id" to 1, "kind" to "k", "title" to " ")))
+ }
+
+ private val sub = "0f8fad5b-d9cb-469f-a165-70867728950e"
+ private val account = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
+ private val secret = "Zm9vYmFyYmF6cXV4X3NlY3JldF92YWx1ZQ"
+ private val since = "2026-10-09T10:00:00.123456+00:00"
+
+ @Test fun `the hub is registered again when the distributor hands out a new endpoint`() {
+ val state = PushState(FakePrefs())
+ state.endpoint("https://push.example.net/1", "k", "a") // not asked for: ignored
+ assertEquals(PushState.OFF, state.status().getString("state"))
+ state.requested()
+ state.endpoint("https://push.example.net/1", "k", "a")
+ state.remember(sub, account, secret, since)
+ val s = state.status()
+ assertEquals(s.getString("endpoint"), s.getString("registered"))
+ state.endpoint("https://push.example.net/2", "k", "a")
+ val moved = state.status()
+ assertEquals("https://push.example.net/1", moved.getString("registered"))
+ assertEquals("https://push.example.net/2", moved.getString("endpoint"))
+ }
+
+ @Test fun `no distributor, or one that gives no keys, means fetching and not failing`() {
+ val state = PushState(FakePrefs())
+ state.requested()
+ state.endpoint("https://push.example.net/1", null, null)
+ val s = state.status()
+ assertEquals(PushState.READY, s.getString("state"))
+ assertEquals("NO_KEYS", s.getString("reason"))
+ assertTrue(s.isNull("endpoint"))
+ state.remember(sub, account, secret, since)
+ assertEquals(PushState.PollTarget(sub, secret, since), state.pollTarget())
+ }
+
+ @Test fun `the fetch cursor only moves forward`() {
+ val state = PushState(FakePrefs())
+ state.requested()
+ state.remember(sub, account, secret, since)
+ state.advance("2026-10-09T09:00:00+00:00")
+ assertEquals(since, state.pollTarget()!!.since)
+ state.advance("2026-10-09T11:00:00+00:00")
+ assertEquals("2026-10-09T11:00:00+00:00", state.pollTarget()!!.since)
+ }
+
+ @Test fun `a line pushed then fetched is drawn once, and a conversation moving on is news`() {
+ val state = PushState(FakePrefs())
+ assertTrue(state.firstSight(7, "2026-10-09T10:00:00+00:00"))
+ assertFalse(state.firstSight(7, "2026-10-09T10:00:00+00:00"))
+ assertTrue(state.firstSight(7, "2026-10-09T10:05:00+00:00"))
+ assertFalse(state.firstSight(7, "2026-10-09T10:01:00+00:00"))
+ }
+
+ @Test fun `a row the hub forgot stops the fetch until the page registers again`() {
+ val state = PushState(FakePrefs())
+ state.requested()
+ state.remember(sub, account, secret, since)
+ state.forgetSubscription()
+ assertNull(state.pollTarget())
+ }
+
+ @Test fun `only ids are remembered`() {
+ val state = PushState(FakePrefs())
+ assertThrows(IllegalArgumentException::class.java) { state.remember("../x", account, secret, since) }
+ assertThrows(IllegalArgumentException::class.java) { state.remember(sub, "", secret, since) }
+ assertThrows(IllegalArgumentException::class.java) { state.remember(sub, account, "short", since) }
+ assertThrows(IllegalArgumentException::class.java) { state.remember(sub, account, secret, "yesterday") }
+ }
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/notifications.py b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py
index e4bac2e..128c0d1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/notifications.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py
@@ -26,8 +26,9 @@ from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.api.push import push_notification
from meshbay_hub.db.engine import get_db
-from meshbay_hub.db.models import GroupMember, Notification, User
+from meshbay_hub.db.models import GroupMember, Notification, User, UserPreference
router = APIRouter(prefix="/v1/notifications", tags=["notifications"])
@@ -157,9 +158,23 @@ async def create_notification(
conversation is a single line saying when it last spoke rather than forty
saying that it spoke.
- Returns None when the person muted this group: the point of muting is that
- nothing is created, not that something is created and hidden.
+ Returns None when the person muted this group, or turned every notification
+ off: the point of muting is that nothing is created, not that something is
+ created and hidden — and nothing created is nothing pushed to a phone.
+
+ The account-wide switch used to be read by the interface alone, which hid
+ the list while rows went on accumulating; with a phone that is told about
+ each row, a switch only the interface honours is a switch that does nothing.
"""
+ disabled = await db.execute(
+ select(UserPreference.value).where(
+ UserPreference.user_id == user_id,
+ UserPreference.key == "notifications_disabled",
+ )
+ )
+ if disabled.scalar() == "true":
+ return None
+
if group_id is not None:
muted = await db.execute(
select(GroupMember.muted).where(
@@ -185,6 +200,7 @@ async def create_notification(
existing.read = False
existing.created_at = datetime.now(UTC)
await db.flush()
+ await push_notification(db, existing)
return existing
notif = Notification(
@@ -193,4 +209,5 @@ async def create_notification(
)
db.add(notif)
await db.flush()
+ await push_notification(db, notif)
return notif
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/push.py b/packages/meshbay-hub/src/meshbay_hub/api/push.py
new file mode 100644
index 0000000..0c4a411
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/push.py
@@ -0,0 +1,277 @@
+"""
+Notifications on a phone — /v1/push/*: pushed when it can be, fetched when not.
+
+A phone registers once and gets a row here. **With a UnifiedPush distributor**
+it gives an endpoint and a P-256 key, and every notification
+`create_notification` lets through is sent there, encrypted to the phone
+(`webpush.py`). **Without one** — nothing to install is the default — the row has
+no endpoint, and the phone fetches what is new with `POST /v1/push/poll` every
+quarter of an hour or so. Both are the same rows and the same payload, so a phone
+can move between them (a distributor installed, removed, refusing) without the
+hub caring which.
+
+**Nothing reaches a phone that was not created**: a muted group and an account
+with every notification turned off stop at `create_notification`, before this
+module is reached, so the two switches the person sees are the only two there are.
+
+The poll is authenticated by a secret issued with the row, not by a session. It
+reads notification lines and nothing else, so a phone running in the background
+holds no token that could do anything more — and a sign-out, which deletes the
+row, ends it.
+
+Who pays (§13.5b): a member's chat costs every other member's phones a push.
+That fan-out is already bounded where it starts — `chat_notify` is budgeted per
+node — and here a conversation reaches each phone at most once per
+`CHAT_COALESCE` seconds: the phone shows one line per group, so the pushes in
+between would only have replaced it. An account holds `MAX_SUBSCRIPTIONS` rows
+at most, because each is one outbound request per notification; a row is polled
+at most once per `POLL_MIN_INTERVAL`.
+"""
+
+import asyncio
+import hashlib
+import hmac
+import logging
+import math
+import secrets
+import time
+from datetime import UTC, datetime
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, Field
+from sqlalchemy import func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from meshbay_hub import webpush
+from meshbay_hub.api.deps import require_user_scope
+from meshbay_hub.db.engine import get_db
+from meshbay_hub.db.models import Notification, PushSubscription, User
+
+log = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/v1/push", tags=["push"])
+
+MAX_SUBSCRIPTIONS = 10
+CHAT_COALESCE = 30.0
+_COALESCE_ENTRIES = 10_000
+POLL_MIN_INTERVAL = 60.0
+POLL_LIMIT = 20
+
+# Strong references: asyncio holds a task weakly, and a collected one is a push
+# that silently never went (CLAUDE.md, "a background task nobody holds").
+_tasks: set[asyncio.Task] = set()
+_last_chat: dict[tuple[str, str], float] = {}
+_last_poll: dict[str, float] = {}
+# Replaced by the tests; the real one never raises.
+_send = webpush.send
+
+
+class SubscriptionIn(BaseModel):
+ # The row this phone already has, to update rather than add one: a phone
+ # with no endpoint has nothing else to be recognised by.
+ id: str | None = Field(default=None, max_length=36)
+ endpoint: str | None = Field(default=None, max_length=webpush.MAX_ENDPOINT)
+ # Lengths bounded before decoding: base64 decoding skips characters outside
+ # its alphabet, so an unbounded string could still decode to 65 bytes.
+ p256dh: str | None = Field(default=None, max_length=128)
+ auth: str | None = Field(default=None, max_length=32)
+
+
+def _hash(secret: str) -> str:
+ return hashlib.sha256(secret.encode()).hexdigest()
+
+
+def _payload(notif: Notification) -> dict:
+ """What a phone is told, pushed or fetched: the hub's own line, never a message."""
+ return {
+ "v": 1,
+ "id": notif.id,
+ "kind": notif.kind,
+ "title": notif.title,
+ "link": notif.link,
+ "group_id": notif.group_id,
+ "created_at": _iso(notif.created_at),
+ }
+
+
+def _iso(at: datetime) -> str:
+ # SQLite hands back naive datetimes; every one stored here is UTC.
+ return (at if at.tzinfo else at.replace(tzinfo=UTC)).isoformat()
+
+
+@router.post("/subscriptions")
+async def subscribe(
+ body: SubscriptionIn,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Register this phone, or update its row: with an endpoint and keys when it
+ has a push distributor, without them when it will fetch instead.
+
+ Answers the row's id, a fresh secret for `POST /v1/push/poll` (the previous
+ one stops working) and the hub's time, from which the phone counts what is
+ new — what was there before it registered is not news.
+ """
+ if body.endpoint is not None:
+ if body.p256dh is None or body.auth is None:
+ raise HTTPException(status_code=422, detail="an endpoint needs its keys")
+ try:
+ webpush.check_endpoint(body.endpoint)
+ webpush.check_keys(body.p256dh, body.auth)
+ except ValueError as e:
+ raise HTTPException(status_code=422, detail=str(e)) from e
+
+ sub = None
+ if body.id is not None:
+ sub = await db.get(PushSubscription, body.id)
+ if sub is not None and sub.user_id != current_user.id:
+ sub = None
+ if body.endpoint is not None:
+ same = (await db.execute(
+ select(PushSubscription).where(
+ PushSubscription.user_id == current_user.id,
+ PushSubscription.endpoint == body.endpoint))).scalar_one_or_none()
+ if sub is None:
+ sub = same
+ elif same is not None and same.id != sub.id:
+ # The endpoint moved to this row; the old one would only repeat it.
+ await db.delete(same)
+ await db.flush()
+ if sub is None:
+ held = (await db.execute(
+ select(func.count()).select_from(PushSubscription)
+ .where(PushSubscription.user_id == current_user.id))).scalar() or 0
+ if held >= MAX_SUBSCRIPTIONS:
+ raise HTTPException(status_code=429, detail="Too many push subscriptions")
+ sub = PushSubscription(user_id=current_user.id)
+ db.add(sub)
+ secret = secrets.token_urlsafe(32)
+ sub.endpoint, sub.p256dh, sub.auth = body.endpoint, body.p256dh, body.auth
+ sub.poll_hash = _hash(secret)
+ await db.commit()
+ return {"id": sub.id, "poll_secret": secret, "now": datetime.now(UTC).isoformat()}
+
+
+class PollIn(BaseModel):
+ id: str = Field(max_length=36)
+ secret: str = Field(max_length=64)
+ since: datetime
+
+
+@router.post("/poll")
+async def poll(body: PollIn, db: AsyncSession = Depends(get_db)):
+ """
+ What is new for this phone since `since`: the same payloads a push carries,
+ oldest first, at most twenty.
+
+ Authenticated by the row's secret rather than a session, so what a phone
+ keeps for running in the background reads notification lines and nothing
+ else. A wrong secret and an unknown row answer the same 404.
+ """
+ sub = await db.get(PushSubscription, body.id)
+ if (sub is None or sub.poll_hash is None
+ or not hmac.compare_digest(sub.poll_hash, _hash(body.secret))):
+ raise HTTPException(status_code=404, detail="Subscription not found")
+ now = time.monotonic()
+ last = _last_poll.get(sub.id)
+ if last is not None and now - last < POLL_MIN_INTERVAL:
+ raise HTTPException(status_code=429, detail="Polled too often",
+ headers={"Retry-After": str(int(POLL_MIN_INTERVAL - (now - last)) + 1)})
+ if len(_last_poll) >= _COALESCE_ENTRIES:
+ for k in [k for k, at in _last_poll.items() if now - at >= POLL_MIN_INTERVAL]:
+ del _last_poll[k]
+ _last_poll[sub.id] = now
+
+ since = body.since if body.since.tzinfo else body.since.replace(tzinfo=UTC)
+ rows = (await db.execute(
+ select(Notification).where(
+ Notification.user_id == sub.user_id,
+ Notification.created_at > since.astimezone(UTC),
+ ).order_by(Notification.created_at.desc()).limit(POLL_LIMIT)
+ )).scalars().all()
+ return {"notifications": [_payload(n) for n in reversed(rows)]}
+
+
+@router.delete("/subscriptions/{subscription_id}")
+async def unsubscribe(
+ subscription_id: str,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """Stop telling one phone anything: turned off there, or signed out of."""
+ sub = await db.get(PushSubscription, subscription_id)
+ if sub is None or sub.user_id != current_user.id:
+ raise HTTPException(status_code=404, detail="Subscription not found")
+ await db.delete(sub)
+ await db.commit()
+ return {"status": "ok"}
+
+
+def _coalesced(sub_id: str, group_id: str, now: float) -> bool:
+ key = (sub_id, group_id)
+ if now - _last_chat.get(key, -math.inf) < CHAT_COALESCE:
+ return True
+ if len(_last_chat) >= _COALESCE_ENTRIES:
+ for k in [k for k, at in _last_chat.items() if now - at >= CHAT_COALESCE]:
+ del _last_chat[k]
+ _last_chat[key] = now
+ return False
+
+
+async def push_notification(db: AsyncSession, notif: Notification) -> None:
+ """
+ Send `notif` to the person's phones, off the caller's path.
+
+ Called by `create_notification` once the row exists, inside the caller's
+ transaction: the subscriptions are read there, the requests leave in a task
+ of their own, so a slow push server delays nobody's request.
+ """
+ subs = (await db.execute(
+ select(PushSubscription).where(PushSubscription.user_id == notif.user_id,
+ PushSubscription.endpoint.is_not(None))
+ )).scalars().all()
+ if not subs:
+ return
+ payload = _payload(notif)
+ now = time.monotonic()
+ targets = [
+ (s.id, webpush.Target(s.endpoint, s.p256dh, s.auth)) for s in subs
+ if not (notif.kind == "chat_message" and notif.group_id
+ and _coalesced(s.id, notif.group_id, now))
+ ]
+ if not targets:
+ return
+ ttl = webpush.TTL_CHAT if notif.kind == "chat_message" else webpush.TTL_OTHER
+ task = asyncio.get_running_loop().create_task(_deliver(targets, payload, ttl))
+ _tasks.add(task)
+ task.add_done_callback(_tasks.discard)
+
+
+async def _deliver(targets: list[tuple[str, webpush.Target]], payload: dict,
+ ttl: int) -> None:
+ results = await asyncio.gather(*(_send(t, payload, ttl=ttl) for _, t in targets),
+ return_exceptions=True)
+ gone = [sid for (sid, _), r in zip(targets, results, strict=True) if r == webpush.GONE]
+ if not gone:
+ return
+ # The distributor dropped the registration: pushing there would cost a
+ # request per notification for ever and reach nothing. The row stays, without
+ # its endpoint — the phone still fetches, and registers again when it can.
+ try:
+ from meshbay_hub.db.engine import get_session_factory
+ async with get_session_factory()() as db:
+ await db.execute(
+ update(PushSubscription).where(PushSubscription.id.in_(gone))
+ .values(endpoint=None, p256dh=None, auth=None))
+ await db.commit()
+ except Exception as e:
+ log.warning("Could not drop %d gone push subscription(s): %s", len(gone), e)
+
+
+async def drain() -> None:
+ """Wait for every push in flight — for the tests, and for a clean shutdown."""
+ # Done tasks leave the set from a callback the loop has not run yet, and
+ # awaiting a finished gather never yields to it: wait on the unfinished only.
+ while pending := [t for t in _tasks if not t.done()]:
+ await asyncio.gather(*pending, return_exceptions=True)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 795a902..130240e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -46,6 +46,7 @@ from meshbay_hub.db.models import (
KnownBrowser,
Node,
Notification,
+ PushSubscription,
RefreshToken,
User,
UserDevice,
@@ -1361,6 +1362,7 @@ async def password_reset(
.values(revoked=True))
await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id))
await db.execute(delete(KnownBrowser).where(KnownBrowser.user_id == user.id))
+ await db.execute(delete(PushSubscription).where(PushSubscription.user_id == user.id))
# A code sent to the address on file is a stronger proof than a passphrase,
# and it is the way out of a lockout somebody else caused.
await login_throttle.clear(db, user.username)
@@ -1579,6 +1581,7 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus
await db.execute(delete(Node).where(Node.user_id == user.id))
await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id))
await db.execute(delete(KnownBrowser).where(KnownBrowser.user_id == user.id))
+ await db.execute(delete(PushSubscription).where(PushSubscription.user_id == user.id))
await db.execute(delete(EmailVerification).where(EmailVerification.user_id == user.id))
# Links this account issued for a group it no longer owns; the ones for its
# own groups went with them above. A used link keeps pointing at the
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index ca05fd8..2ad71bd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -30,6 +30,7 @@ from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.moderation import router as moderation_router
from meshbay_hub.api.nodes import router as nodes_router
from meshbay_hub.api.notifications import router as notifications_router
+from meshbay_hub.api.push import router as push_router
from meshbay_hub.api.revocation import router as revocation_router
from meshbay_hub.api.signaling import router as signaling_router
from meshbay_hub.api.users import router as users_router
@@ -233,6 +234,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
app.include_router(signaling_router)
app.include_router(admin_router)
app.include_router(notifications_router)
+ app.include_router(push_router)
app.include_router(webapp_router)
from starlette.staticfiles import StaticFiles
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e7f8a9b0c1d2_push_subscriptions.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e7f8a9b0c1d2_push_subscriptions.py
new file mode 100644
index 0000000..4311448
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e7f8a9b0c1d2_push_subscriptions.py
@@ -0,0 +1,35 @@
+"""phones told about notifications: a Web Push endpoint, or a poll secret
+
+Revision ID: e7f8a9b0c1d2
+Revises: d4e5f6a7b8ca
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "e7f8a9b0c1d2"
+down_revision: str | Sequence[str] | None = "d4e5f6a7b8ca"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "push_subscriptions",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
+ sa.Column("endpoint", sa.String(1024), nullable=True),
+ sa.Column("p256dh", sa.String(128), nullable=True),
+ sa.Column("auth", sa.String(32), nullable=True),
+ sa.Column("poll_hash", sa.String(64), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True)),
+ )
+ op.create_index("ix_push_subscriptions_user_endpoint", "push_subscriptions",
+ ["user_id", "endpoint"], unique=True)
+
+
+def downgrade() -> None:
+ op.drop_index("ix_push_subscriptions_user_endpoint", table_name="push_subscriptions")
+ op.drop_table("push_subscriptions")
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index dbc0f10..7291485 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -398,6 +398,34 @@ class ContentReview(Base):
decided_by: Mapped[str | None] = mapped_column(String(64))
+class PushSubscription(Base):
+ """A phone told about this account's notifications (§11.3): pushed to its
+ Web Push endpoint when it has one, fetched with its poll secret when not.
+
+ The endpoint is a capability — whoever holds the URL can wake the phone —
+ and the keys are what the hub encrypts to, so the push server relays bytes
+ it cannot read. One account holds a handful at most
+ (`api/push.MAX_SUBSCRIPTIONS`): the rows are shared, and every notification
+ costs one outbound request per row.
+ """
+
+ __tablename__ = "push_subscriptions"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
+ user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
+ # All three empty for a phone with no push distributor, which fetches instead.
+ endpoint: Mapped[str | None] = mapped_column(String(1024), nullable=True)
+ p256dh: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ auth: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ # sha256 of the secret `POST /v1/push/poll` is answered for; never the secret.
+ poll_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+
+ __table_args__ = (
+ Index("ix_push_subscriptions_user_endpoint", "user_id", "endpoint", unique=True),
+ )
+
+
class UserPreference(Base):
__tablename__ = "user_preferences"
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index b9466d0..2254a11 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -31,6 +31,7 @@ import {
} from './playlists.js';
import { IndexingDock } from './index-dock.js';
import { SettingsPage } from './settings-page.js';
+import { syncPush, disablePush } from './push.js';
import { ProfilePage } from './profile-page.js';
import { ExplorePage } from './explore-page.js';
import { GroupName } from './group-name.js';
@@ -1042,6 +1043,7 @@ function App() {
.catch(() => {});
refreshNodeKey();
fetchNotifications();
+ syncPush(user).catch(() => {});
}, [user]);
// The node this application ships, set up, started and linked for whoever
@@ -1233,6 +1235,13 @@ function App() {
// Navigating away leaves transfers running; signing out does not. They
// are moving data on tokens that are about to stop being ours.
transfers.reset();
+ // This phone stops being told about the account it is leaving. Its
+ // access token is still good for that request; the refresh token's
+ // revocation below does not touch it.
+ // The stored session, read now: React's copy can be a renewal behind,
+ // and once the session is cleared nothing could renew it.
+ disablePush(user && { ...user, token: (loadAuth() || {}).token || user.token })
+ .catch(() => {});
// Revoked on the hub too, so a copy of the refresh token is worth
// nothing. Read before the next line clears it.
logoutOnHub();
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 4dff605..d8b7b5c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -436,6 +436,15 @@ export default {
'settings.email_verified': 'E-Mail-Adresse erfolgreich geändert.',
'settings.notif_global_disable': 'Disable all notifications',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.push_label': 'Benachrichtigungen auf diesem Telefon',
+ 'settings.push_off': 'Aus. Einschalten, um bei geschlossenem MeshBay über neue Nachrichten und Einladungen informiert zu werden.',
+ 'settings.push_pending': 'Warte auf die Antwort des Verteilers…',
+ 'settings.push_blocked': 'Android blockiert die Benachrichtigungen von MeshBay: Erlauben Sie sie in den Systemeinstellungen.',
+ 'settings.push_ready': 'An. Neue Benachrichtigungen erreichen dieses Telefon über den Verteiler, verschlüsselt, sodass er sie nicht lesen kann.',
+ 'settings.push_poll': 'An. Dieses Telefon sieht etwa alle fünfzehn Minuten nach neuen Benachrichtigungen. Damit sie sofort ankommen, können Sie eine UnifiedPush-Verteiler-App wie ntfy installieren (optional).',
+ 'settings.push_global_off': 'Solange alle Benachrichtigungen ausgeschaltet sind, wird nichts gesendet.',
+ 'push.channel_chat': 'Nachrichten',
+ 'push.channel_other': 'Einladungen und Konto',
'settings.defaults': 'Standardwerte',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 90c043e..a4a8215 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -433,6 +433,15 @@ export default {
'settings.email_verified': 'Email address changed successfully.',
'settings.notif_global_disable': 'Disable all notifications',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.push_label': 'Notifications on this phone',
+ 'settings.push_off': 'Off. Turn on to be told about new messages and invitations while MeshBay is closed.',
+ 'settings.push_pending': 'Waiting for the distributor’s answer…',
+ 'settings.push_blocked': 'Android is blocking MeshBay’s notifications: allow them in the system settings.',
+ 'settings.push_ready': 'On. New notifications reach this phone through the distributor, encrypted so it cannot read them.',
+ 'settings.push_poll': 'On. This phone checks for new notifications about every fifteen minutes. For them to arrive at once, you can install a UnifiedPush distributor app such as ntfy (optional).',
+ 'settings.push_global_off': 'Nothing is sent while every notification is turned off.',
+ 'push.channel_chat': 'Messages',
+ 'push.channel_other': 'Invitations and account',
'settings.defaults': 'Defaults',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 7b46b17..78160d4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -434,6 +434,15 @@ export default {
'settings.email_verified': 'Dirección de correo cambiada con éxito.',
'settings.notif_global_disable': 'Disable all notifications',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.push_label': 'Notificaciones en este teléfono',
+ 'settings.push_off': 'Desactivadas. Actívalas para enterarte de nuevos mensajes e invitaciones con MeshBay cerrado.',
+ 'settings.push_pending': 'Esperando la respuesta del distribuidor…',
+ 'settings.push_blocked': 'Android bloquea las notificaciones de MeshBay: permítelas en los ajustes del sistema.',
+ 'settings.push_ready': 'Activadas. Las nuevas notificaciones llegan a este teléfono a través del distribuidor, cifradas para que no pueda leerlas.',
+ 'settings.push_poll': 'Activadas. Este teléfono busca notificaciones nuevas cada quince minutos aproximadamente. Para recibirlas al instante, puedes instalar una app distribuidora de UnifiedPush como ntfy (opcional).',
+ 'settings.push_global_off': 'No se envía nada mientras todas las notificaciones estén desactivadas.',
+ 'push.channel_chat': 'Mensajes',
+ 'push.channel_other': 'Invitaciones y cuenta',
'settings.defaults': 'Valores predeterminados',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index d3f9200..1fbb6cf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -435,6 +435,15 @@ export default {
'settings.email_verified': 'Adresse e-mail modifiée avec succès.',
'settings.notif_global_disable': 'Désactiver toutes les notifications',
'settings.notif_global_hint': 'Quand activé, aucune notification n\x27est créée pour aucun groupe.',
+ 'settings.push_label': 'Notifications sur ce téléphone',
+ 'settings.push_off': 'Désactivées. Activez-les pour être prévenu des nouveaux messages et invitations quand MeshBay est fermé.',
+ 'settings.push_pending': 'En attente de la réponse du distributeur…',
+ 'settings.push_blocked': 'Android bloque les notifications de MeshBay : autorisez-les dans les paramètres du système.',
+ 'settings.push_ready': 'Activées. Les nouvelles notifications arrivent sur ce téléphone par le distributeur, chiffrées pour qu’il ne puisse pas les lire.',
+ 'settings.push_poll': 'Activées. Ce téléphone vérifie les nouvelles notifications environ toutes les quinze minutes. Pour les recevoir immédiatement, vous pouvez installer une application de distribution UnifiedPush comme ntfy (facultatif).',
+ 'settings.push_global_off': 'Rien n’est envoyé tant que toutes les notifications sont désactivées.',
+ 'push.channel_chat': 'Messages',
+ 'push.channel_other': 'Invitations et compte',
'settings.defaults': 'Valeurs par défaut',
'settings.default_tab': 'Onglet par défaut',
'settings.default_tab_hint': 'L\'onglet qui s\'ouvre en premier quand vous entrez dans un groupe.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 715738e..250e769 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -435,6 +435,15 @@ export default {
'settings.email_verified': 'Indirizzo e-mail modificato con successo.',
'settings.notif_global_disable': 'Disable all notifications',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.push_label': 'Notifiche su questo telefono',
+ 'settings.push_off': 'Disattivate. Attivale per essere avvisato di nuovi messaggi e inviti quando MeshBay è chiuso.',
+ 'settings.push_pending': 'In attesa della risposta del distributore…',
+ 'settings.push_blocked': 'Android blocca le notifiche di MeshBay: consentile nelle impostazioni di sistema.',
+ 'settings.push_ready': 'Attivate. Le nuove notifiche arrivano su questo telefono tramite il distributore, cifrate in modo che non possa leggerle.',
+ 'settings.push_poll': 'Attivate. Questo telefono controlla le nuove notifiche circa ogni quindici minuti. Per riceverle subito, puoi installare un’app distributore UnifiedPush come ntfy (facoltativo).',
+ 'settings.push_global_off': 'Non viene inviato nulla finché tutte le notifiche sono disattivate.',
+ 'push.channel_chat': 'Messaggi',
+ 'push.channel_other': 'Inviti e account',
'settings.defaults': 'Valori predefiniti',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 23358c2..ad4ba25 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -432,6 +432,15 @@ export default {
'settings.email_verified': 'メールアドレスが正常に変更されました。',
'settings.notif_global_disable': 'Disable all notifications',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.push_label': 'この端末の通知',
+ 'settings.push_off': 'オフ。オンにすると、MeshBay を閉じていても新着メッセージや招待が通知されます。',
+ 'settings.push_pending': 'ディストリビューターの応答を待っています…',
+ 'settings.push_blocked': 'Android が MeshBay の通知をブロックしています。システム設定で許可してください。',
+ 'settings.push_ready': 'オン。新しい通知はディストリビューター経由でこの端末に届きます。暗号化されているため、ディストリビューターは内容を読めません。',
+ 'settings.push_poll': 'オン。この端末は約15分ごとに新しい通知を確認します。すぐに受け取りたい場合は、ntfy などの UnifiedPush ディストリビューターアプリをインストールできます(任意)。',
+ 'settings.push_global_off': 'すべての通知がオフの間は何も送信されません。',
+ 'push.channel_chat': 'メッセージ',
+ 'push.channel_other': '招待とアカウント',
'settings.defaults': 'デフォルト',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 5e53084..78bada9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -436,6 +436,15 @@ export default {
'settings.email_verified': 'E-mailadres succesvol gewijzigd.',
'settings.notif_global_disable': 'Alle meldingen uitschakelen',
'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.push_label': 'Meldingen op deze telefoon',
+ 'settings.push_off': 'Uit. Zet aan om over nieuwe berichten en uitnodigingen te horen terwijl MeshBay gesloten is.',
+ 'settings.push_pending': 'Wachten op het antwoord van de distributor…',
+ 'settings.push_blocked': 'Android blokkeert de meldingen van MeshBay: sta ze toe in de systeeminstellingen.',
+ 'settings.push_ready': 'Aan. Nieuwe meldingen bereiken deze telefoon via de distributor, versleuteld zodat die ze niet kan lezen.',
+ 'settings.push_poll': 'Aan. Deze telefoon kijkt ongeveer elk kwartier of er nieuwe meldingen zijn. Wil je ze meteen ontvangen, installeer dan een UnifiedPush-distributor-app zoals ntfy (optioneel).',
+ 'settings.push_global_off': 'Er wordt niets verzonden zolang alle meldingen uit staan.',
+ 'push.channel_chat': 'Berichten',
+ 'push.channel_other': 'Uitnodigingen en account',
'settings.defaults': 'Standaardwaarden',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 5d6d852..fd26974 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -447,6 +447,15 @@ export default {
'settings.email_verified': 'Adres e-mail został pomyślnie zmieniony.',
'settings.notif_global_disable': 'Wyłącz wszystkie powiadomienia',
'settings.notif_global_hint': 'Po włączeniu nie będą tworzone żadne powiadomienia dla żadnej grupy.',
+ 'settings.push_label': 'Powiadomienia na tym telefonie',
+ 'settings.push_off': 'Wyłączone. Włącz, aby dowiadywać się o nowych wiadomościach i zaproszeniach, gdy MeshBay jest zamknięty.',
+ 'settings.push_pending': 'Oczekiwanie na odpowiedź dystrybutora…',
+ 'settings.push_blocked': 'Android blokuje powiadomienia MeshBay: zezwól na nie w ustawieniach systemu.',
+ 'settings.push_ready': 'Włączone. Nowe powiadomienia docierają na ten telefon przez dystrybutora, zaszyfrowane tak, że nie może ich odczytać.',
+ 'settings.push_poll': 'Włączone. Ten telefon sprawdza nowe powiadomienia mniej więcej co piętnaście minut. Aby otrzymywać je od razu, możesz zainstalować aplikację dystrybutora UnifiedPush, np. ntfy (opcjonalnie).',
+ 'settings.push_global_off': 'Nic nie jest wysyłane, dopóki wszystkie powiadomienia są wyłączone.',
+ 'push.channel_chat': 'Wiadomości',
+ 'push.channel_other': 'Zaproszenia i konto',
'settings.defaults': 'Wartości domyślne',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 6fe50f6..e91ab10 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -436,6 +436,15 @@ export default {
'settings.email_verified': 'Endereço de e-mail alterado com sucesso.',
'settings.notif_global_disable': 'Desativar todas as notificações',
'settings.notif_global_hint': 'Quando ativado, nenhuma notificação é criada para nenhum grupo.',
+ 'settings.push_label': 'Notificações neste telefone',
+ 'settings.push_off': 'Desativadas. Ative para saber de novas mensagens e convites com o MeshBay fechado.',
+ 'settings.push_pending': 'Aguardando a resposta do distribuidor…',
+ 'settings.push_blocked': 'O Android está bloqueando as notificações do MeshBay: permita-as nas configurações do sistema.',
+ 'settings.push_ready': 'Ativadas. Novas notificações chegam a este telefone pelo distribuidor, criptografadas para que ele não possa lê-las.',
+ 'settings.push_poll': 'Ativadas. Este telefone verifica novas notificações a cada quinze minutos, aproximadamente. Para recebê-las na hora, você pode instalar um app distribuidor UnifiedPush como o ntfy (opcional).',
+ 'settings.push_global_off': 'Nada é enviado enquanto todas as notificações estiverem desativadas.',
+ 'push.channel_chat': 'Mensagens',
+ 'push.channel_other': 'Convites e conta',
'settings.defaults': 'Padrões',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index b3886ff..c030523 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -428,6 +428,15 @@ export default {
'settings.email_verified': '邮箱地址修改成功。',
'settings.notif_global_disable': '关闭所有通知',
'settings.notif_global_hint': '启用后,所有群组都不会创建通知。',
+ 'settings.push_label': '此手机上的通知',
+ 'settings.push_off': '已关闭。开启后,即使 MeshBay 已关闭,也会收到新消息和邀请的通知。',
+ 'settings.push_pending': '正在等待分发应用的响应…',
+ 'settings.push_blocked': 'Android 正在阻止 MeshBay 的通知:请在系统设置中允许。',
+ 'settings.push_ready': '已开启。新通知通过分发应用送达此手机,并经过加密,分发应用无法读取。',
+ 'settings.push_poll': '已开启。此手机大约每十五分钟检查一次新通知。如需即时收到,可以安装 UnifiedPush 分发应用,例如 ntfy(可选)。',
+ 'settings.push_global_off': '所有通知关闭期间不会发送任何内容。',
+ 'push.channel_chat': '消息',
+ 'push.channel_other': '邀请与账户',
'settings.defaults': '默认值',
'settings.default_tab': 'Default tab',
'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index e3b3d2d..c70b03a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -214,6 +214,22 @@ export const playback = {
},
};
+/**
+ * Notifications on this device while the application is closed — fetched, or
+ * pushed through a UnifiedPush distributor when the phone has one. Only the
+ * Android application does this; `available` is false in a browser and on a
+ * desktop, and nothing here is then called. See push.js.
+ */
+export const push = {
+ available: Boolean(bridge && bridge.push),
+ status() { return bridge.push.status(); },
+ enable() { return bridge.push.enable(); },
+ disable() { return bridge.push.disable(); },
+ remember(subscription, account, secret, since) {
+ return bridge.push.remember(subscription, account, secret, since);
+ },
+};
+
/** Pick a directory to add as a group root. Returns { path, name } or null. */
export const rootPicker = {
available: Boolean(bridge && bridge.rootPicker),
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/push.js b/packages/meshbay-hub/src/meshbay_hub/static/push.js
new file mode 100644
index 0000000..a075aa4
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/push.js
@@ -0,0 +1,68 @@
+/**
+ * Notifications on this phone: the page's half.
+ *
+ * Nothing to install: the shell fetches what is new on its own every quarter
+ * of an hour, and when the phone already has a UnifiedPush distributor it is
+ * used too, so they arrive at once. The page gives the hub whatever the shell
+ * ends up with, because the page holds the session. Whether a notification is
+ * wanted — every one turned off, or a group muted — is decided on the hub,
+ * which then creates nothing (docs/MESHBAY_DESIGN.md §11.3).
+ */
+import * as platform from './platform.js';
+import { hubFetch } from './hub-client.js';
+
+// How long turning it on waits for a distributor before registering without
+// one; when the endpoint comes later, the next sync hands it over.
+const WAIT_MS = 10_000;
+const POLL_MS = 500;
+
+/**
+ * Make the hub's row match what this phone has: at every start and sign-in,
+ * and after turning it on. A distributor may hand out a new endpoint, or go
+ * away, at any time, page running or not; this is where the hub hears it.
+ */
+export async function syncPush(user) {
+ if (!platform.push.available || !user) return null;
+ const s = await platform.push.status();
+ if (!s.enabled) return s;
+ const mine = s.subscription && s.account === user.userId;
+ if (mine && s.registered === s.endpoint) return s;
+ const body = s.endpoint
+ ? { endpoint: s.endpoint, p256dh: s.p256dh, auth: s.auth }
+ : {};
+ if (mine) body.id = s.subscription;
+ const r = await hubFetch('/v1/push/subscriptions', {
+ method: 'POST', token: user.token, body,
+ });
+ return platform.push.remember(r.id, user.userId, r.poll_secret, r.now);
+}
+
+/** Turn it on: a moment for a distributor to answer, then register either way. */
+export async function enablePush(user, onProgress) {
+ let s = await platform.push.enable();
+ const until = Date.now() + WAIT_MS;
+ while (s.state === 'pending' && Date.now() < until) {
+ if (onProgress) onProgress(s);
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
+ s = await platform.push.status();
+ }
+ return syncPush(user).then((synced) => synced || s);
+}
+
+/**
+ * Turn it off: the hub forgets this phone first, then the phone stops. The hub
+ * half is the one that can fail; the phone half happens regardless, so a
+ * refused request never leaves it on here.
+ */
+export async function disablePush(user) {
+ if (!platform.push.available) return null;
+ const s = await platform.push.status();
+ if (s.subscription && user && s.account === user.userId) {
+ try {
+ await hubFetch(`/v1/push/subscriptions/${s.subscription}`, {
+ method: 'DELETE', token: user.token,
+ });
+ } catch (e) { /* already gone, or unreachable: the phone half still happens */ }
+ }
+ return platform.push.disable();
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
index 8755556..985b219 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
@@ -5,6 +5,7 @@ import { t, getLocale, setLocale, LOCALES } from './i18n.js';
import * as downloads from './downloads.js';
import * as platform from './platform.js';
import { hubFetch } from './hub-client.js';
+import { syncPush, enablePush, disablePush } from './push.js';
import { APPS } from './apps.js';
import {
PAGE_SIZE_PREF, PAGE_SIZE_DEFAULT, PAGE_SIZE_STEP, PAGE_SIZE_MAX, pageSizeFrom,
@@ -81,6 +82,35 @@ export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange
}
}, [globalMute, user.token, onPrefsChange]);
+ // Notifications on this phone (Android only: `platform.push.available`).
+ const [push, setPush] = useState(null);
+ const [pushBusy, setPushBusy] = useState(false);
+ useEffect(() => {
+ if (!platform.push.available) return;
+ syncPush(user).catch(() => platform.push.status()).then(setPush).catch(() => {});
+ }, [user]);
+
+ const togglePush = useCallback(async () => {
+ if (pushBusy) return;
+ setPushBusy(true);
+ try {
+ setPush(push && push.enabled ? await disablePush(user) : await enablePush(user, setPush));
+ } catch (err) {
+ setPush(await platform.push.status().catch(() => null));
+ } finally {
+ setPushBusy(false);
+ }
+ }, [push, pushBusy, user]);
+
+ const pushHint = () => {
+ if (!push) return null;
+ if (globalMute) return t('settings.push_global_off');
+ if (!push.enabled) return t('settings.push_off');
+ if (!push.permitted) return t('settings.push_blocked');
+ if (push.state === 'pending') return t('settings.push_pending');
+ return push.endpoint ? t('settings.push_ready') : t('settings.push_poll');
+ };
+
const toggleMute = useCallback(async (gid) => {
const next = !muted[gid];
setMuted(prev => ({ ...prev, [gid]: next }));
@@ -240,6 +270,17 @@ export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange
<span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span>
</label>
</div>
+ ${push && html`
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.push_label')}</span>
+ <label class="toggle-switch">
+ <input type="checkbox" checked=${!!push.enabled} disabled=${pushBusy}
+ onChange=${togglePush} />
+ <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span>
+ </label>
+ </div>
+ <p class="settings-hint">${pushHint()}</p>
+ `}
${!globalMute && html`
<p class="settings-hint" style="margin-bottom:8px">${t('settings.notif_global_hint')}</p>
${groups.map(g => html`
diff --git a/packages/meshbay-hub/src/meshbay_hub/webpush.py b/packages/meshbay-hub/src/meshbay_hub/webpush.py
new file mode 100644
index 0000000..301e191
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/webpush.py
@@ -0,0 +1,194 @@
+"""
+Web Push to a phone: RFC 8291 encryption and the one outbound request.
+
+The Android application registers with a UnifiedPush distributor (ntfy, or any
+other) and hands the hub an endpoint URL and a P-256 key; the hub POSTs each
+notification there, encrypted to that key (`docs/MESHBAY_DESIGN.md` §7.6). The
+push server relays bytes it cannot read; what it does learn is *when* this
+person is notified, which is the same metadata the hub already holds.
+
+**The endpoint is a URL a member supplied, and the hub fetches it.** That is
+the shape of an SSRF, so a send resolves the host itself, refuses unless every
+address is public, and connects to the address it checked — the hostname rides
+only as the TLS server name and the Host header, so a second resolution cannot
+point the request somewhere else. No redirect is followed.
+"""
+
+import asyncio
+import base64
+import ipaddress
+import json
+import logging
+import os
+import socket
+from dataclasses import dataclass
+from urllib.parse import urlsplit, urlunsplit
+
+import httpx
+from cryptography.hazmat.primitives import hashes, hmac, serialization
+from cryptography.hazmat.primitives.asymmetric import ec
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+
+log = logging.getLogger(__name__)
+
+RECORD_SIZE = 4096
+SEND_TIMEOUT = 5.0
+MAX_ENDPOINT = 1024
+# RFC 8030 §5.2: a push service may keep a message this long for a phone that is
+# off. A chat line an hour old is still worth seeing; one a day old is not.
+TTL_CHAT = 3600
+TTL_OTHER = 86400
+
+
+class EndpointRefused(ValueError):
+ """The endpoint is not one the hub will send to."""
+
+
+def b64url_decode(value: str) -> bytes:
+ return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
+
+
+def _hmac(key: bytes, data: bytes) -> bytes:
+ h = hmac.HMAC(key, hashes.SHA256())
+ h.update(data)
+ return h.finalize()
+
+
+def check_keys(p256dh: str, auth: str) -> tuple[bytes, bytes]:
+ """Decode and validate a subscription's keys; ValueError when they are not."""
+ try:
+ ua_public = b64url_decode(p256dh)
+ auth_secret = b64url_decode(auth)
+ except (ValueError, TypeError) as e:
+ raise ValueError("keys are not base64url") from e
+ if len(ua_public) != 65 or ua_public[0] != 4:
+ raise ValueError("p256dh is not an uncompressed P-256 point")
+ if len(auth_secret) != 16:
+ raise ValueError("auth is not 16 bytes")
+ # Raises ValueError for a point that is not on the curve.
+ ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), ua_public)
+ return ua_public, auth_secret
+
+
+def encrypt(plaintext: bytes, ua_public: bytes, auth_secret: bytes, *,
+ as_private: ec.EllipticCurvePrivateKey | None = None,
+ salt: bytes | None = None) -> bytes:
+ """One aes128gcm record (RFC 8188) keyed as RFC 8291 §3.4 says.
+
+ `as_private` and `salt` are parameters only so the RFC's own example can be
+ replayed; a send always draws both fresh.
+ """
+ if len(plaintext) > RECORD_SIZE - 16 - 1 - 86:
+ raise ValueError("push payload too large for one record")
+ as_private = as_private or ec.generate_private_key(ec.SECP256R1())
+ salt = salt or os.urandom(16)
+ as_public = as_private.public_key().public_bytes(
+ serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
+ ua_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), ua_public)
+ ecdh_secret = as_private.exchange(ec.ECDH(), ua_key)
+
+ prk_key = _hmac(auth_secret, ecdh_secret)
+ key_info = b"WebPush: info\x00" + ua_public + as_public
+ ikm = _hmac(prk_key, key_info + b"\x01")
+ prk = _hmac(salt, ikm)
+ cek = _hmac(prk, b"Content-Encoding: aes128gcm\x00\x01")[:16]
+ nonce = _hmac(prk, b"Content-Encoding: nonce\x00\x01")[:12]
+
+ header = salt + RECORD_SIZE.to_bytes(4, "big") + bytes([len(as_public)]) + as_public
+ return header + AESGCM(cek).encrypt(nonce, plaintext + b"\x02", None)
+
+
+def check_endpoint(url: str) -> tuple[str, int]:
+ """The endpoint's shape, checked when it is registered: https, a host, no
+ credentials, not an address that is private on its face. Where its name
+ resolves is checked at every send, because that can change."""
+ if len(url) > MAX_ENDPOINT:
+ raise EndpointRefused("endpoint too long")
+ parts = urlsplit(url)
+ if parts.scheme != "https" or not parts.hostname:
+ raise EndpointRefused("endpoint must be an https URL")
+ if parts.username or parts.password:
+ raise EndpointRefused("endpoint must not carry credentials")
+ try:
+ port = parts.port or 443
+ except ValueError as e:
+ raise EndpointRefused("endpoint port is not a number") from e
+ host = parts.hostname
+ try:
+ literal = ipaddress.ip_address(host)
+ except ValueError:
+ literal = None
+ if literal is not None and not literal.is_global:
+ raise EndpointRefused("endpoint is not a public address")
+ return host, port
+
+
+async def _resolve_public(host: str, port: int) -> str:
+ infos = await asyncio.get_running_loop().getaddrinfo(
+ host, port, type=socket.SOCK_STREAM)
+ addresses = {info[4][0] for info in infos}
+ if not addresses:
+ raise EndpointRefused("endpoint does not resolve")
+ for a in addresses:
+ if not ipaddress.ip_address(a.split("%", 1)[0]).is_global:
+ raise EndpointRefused("endpoint resolves to a non-public address")
+ # IPv4 first: a hub with an AAAA answer and no IPv6 route is common.
+ return sorted(addresses, key=lambda a: (":" in a, a))[0]
+
+
+@dataclass
+class Target:
+ endpoint: str
+ p256dh: str
+ auth: str
+
+
+# Outcomes of one send, for the caller to act on.
+DELIVERED = "delivered"
+GONE = "gone" # 404/410: the registration no longer exists (RFC 8030 §7.3)
+FAILED = "failed"
+
+
+async def send(target: Target, payload: dict, *, ttl: int,
+ client: httpx.AsyncClient | None = None) -> str:
+ """Encrypt `payload` for one subscription and POST it. Never raises."""
+ try:
+ host, port = check_endpoint(target.endpoint)
+ ua_public, auth_secret = check_keys(target.p256dh, target.auth)
+ body = encrypt(json.dumps(payload, separators=(",", ":")).encode(),
+ ua_public, auth_secret)
+ address = await _resolve_public(host, port)
+ except (EndpointRefused, ValueError, OSError) as e:
+ log.info("push refused before sending: %s", e)
+ return FAILED
+
+ parts = urlsplit(target.endpoint)
+ netloc = f"[{address}]" if ":" in address else address
+ if parts.port:
+ netloc += f":{parts.port}"
+ pinned = urlunsplit((parts.scheme, netloc, parts.path or "/", parts.query, ""))
+ headers = {
+ "Host": parts.netloc,
+ "Content-Encoding": "aes128gcm",
+ "Content-Type": "application/octet-stream",
+ "TTL": str(ttl),
+ "Urgency": "normal",
+ }
+ own = client is None
+ client = client or httpx.AsyncClient(timeout=SEND_TIMEOUT, follow_redirects=False)
+ try:
+ resp = await client.post(pinned, content=body, headers=headers,
+ extensions={"sni_hostname": host})
+ except httpx.HTTPError as e:
+ # The URL path is a bearer capability for this phone: never logged.
+ log.info("push to %s failed: %s", host, type(e).__name__)
+ return FAILED
+ finally:
+ if own:
+ await client.aclose()
+ if resp.status_code in (404, 410):
+ return GONE
+ if 200 <= resp.status_code < 300:
+ return DELIVERED
+ log.info("push to %s answered %d", host, resp.status_code)
+ return FAILED
diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py
index 71832f2..129732c 100644
--- a/packages/meshbay-hub/tests/test_android_shell.py
+++ b/packages/meshbay-hub/tests/test_android_shell.py
@@ -135,11 +135,15 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each():
preload = set(re.findall(r"ipcRenderer\.invoke\('([\w:-]+)'", preload_js))
native = set()
for path in (SRC / "bridge" / "Channels.kt", SRC / "bridge" / "KeyChannels.kt",
- SRC / "cast" / "CastChannels.kt"):
+ SRC / "cast" / "CastChannels.kt", SRC / "notify" / "PushChannels.kt"):
native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(path), flags=re.M))
shim = _shim_channels()
assert shim, "no channel found in the shim"
- assert shim <= preload, f"channels the desktop does not have: {shim - preload}"
+ # A phone has a push distributor to talk to and a desktop does not; that
+ # family is the one the desktop lacks rather than the one it shares.
+ phone_only = {c for c in shim if c.startswith("push:")}
+ assert phone_only, "the push channels are gone from the shim"
+ assert shim - phone_only <= preload, f"channels the desktop does not have: {shim - preload}"
assert shim == native, f"shim and native disagree: {shim ^ native}"
@@ -196,6 +200,28 @@ def test_nothing_is_granted_and_video_may_go_fullscreen():
assert "override fun onHideCustomView" in activity
+def test_a_notification_is_drawn_only_when_it_opened_and_leads_inside_the_page():
+ """The distributor is another application: its receiver must not be ours
+ to call, a message nobody encrypted to this phone is not drawn, and the
+ link a notification carries is a route in the page, never a URL."""
+ manifest = _read(APP / "src" / "main" / "AndroidManifest.xml")
+ service = manifest.split('android:name=".notify.PushReceiver"', 1)[1].split("</service>", 1)[0]
+ assert 'android:exported="false"' in service
+ job = manifest.split('android:name=".notify.PollJob"', 1)[1].split("/>", 1)[0]
+ assert 'android:exported="false"' in job and "BIND_JOB_SERVICE" in job
+ # The background fetch carries the poll secret, never a session token.
+ poll = _read(SRC / "notify" / "PollJob.kt")
+ assert "/v1/push/poll" in poll and "Authorization" not in poll
+ receiver = _read(SRC / "notify" / "PushReceiver.kt")
+ assert "!message.decrypted" in receiver
+ notifier = _read(SRC / "notify" / "Notifier.kt")
+ assert 'Regex("^#/[A-Za-z0-9/_-]{0,200}$")' in notifier
+ assert "FLAG_IMMUTABLE" in notifier
+ activity = _read(SRC / "MainActivity.kt")
+ assert activity.count("Notifier.linkOf(intent)") == 2
+ assert 'location.hash = ${JSONObject.quote(link)}' in activity
+
+
def test_no_backup_carries_the_keys_away():
manifest = _read(APP / "src" / "main" / "AndroidManifest.xml")
assert 'android:allowBackup="false"' in manifest
diff --git a/packages/meshbay-hub/tests/test_push.py b/packages/meshbay-hub/tests/test_push.py
new file mode 100644
index 0000000..c3bca4f
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_push.py
@@ -0,0 +1,455 @@
+"""
+Push to a phone: the encryption, the endpoint a member supplies, and — the
+point of the whole feature — that the two switches a person sees are honoured.
+
+Every flow test is two accounts, the one causing a notification and the one
+receiving it, because a one-member test proves a one-member property.
+"""
+
+import base64
+import hashlib
+import json
+
+import httpx
+import pytest
+from cryptography.hazmat.primitives import hashes, hmac, serialization
+from cryptography.hazmat.primitives.asymmetric import ec
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from membership import add_member
+from meshbay_hub import webpush
+from meshbay_hub.api import push
+from meshbay_hub.db.models import Notification, PushSubscription, User
+from sqlalchemy import select
+
+
+def _b64(s: str) -> bytes:
+ s = "".join(s.split())
+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
+
+
+def _b64e(b: bytes) -> str:
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
+
+
+def _auth_key(password: str, username: str) -> str:
+ salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest()
+ return base64.b64encode(
+ hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode()
+
+
+async def _user(client, username, password="a-long-enough-passphrase"):
+ await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@example.com",
+ "auth_key": _auth_key(password, username)})
+ r = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": _auth_key(password, username)})
+ return r.json()["access_token"]
+
+
+def _phone():
+ """A user agent's keys, as a distributor's connector would generate them."""
+ sk = ec.generate_private_key(ec.SECP256R1())
+ pk = sk.public_key().public_bytes(
+ serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
+ auth = b"0123456789abcdef"
+ return sk, _b64e(pk), _b64e(auth)
+
+
+def _hm(key, data):
+ h = hmac.HMAC(key, hashes.SHA256())
+ h.update(data)
+ return h.finalize()
+
+
+def _decrypt(body: bytes, ua_private, auth_secret: bytes) -> bytes:
+ """RFC 8291 from the receiving side, written from the RFC and not from webpush.py."""
+ salt, idlen = body[:16], body[20]
+ as_public = body[21:21 + idlen]
+ ua_public = ua_private.public_key().public_bytes(
+ serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
+ ecdh = ua_private.exchange(
+ ec.ECDH(), ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), as_public))
+ ikm = _hm(_hm(auth_secret, ecdh), b"WebPush: info\x00" + ua_public + as_public + b"\x01")
+ prk = _hm(salt, ikm)
+ cek = _hm(prk, b"Content-Encoding: aes128gcm\x00\x01")[:16]
+ nonce = _hm(prk, b"Content-Encoding: nonce\x00\x01")[:12]
+ plain = AESGCM(cek).decrypt(nonce, body[21 + idlen:], None)
+ assert plain.endswith(b"\x02")
+ return plain[:-1]
+
+
+@pytest.fixture
+def sent(monkeypatch):
+ """Every push the hub would have sent, instead of sending it."""
+ calls = []
+
+ async def fake_send(target, payload, *, ttl, client=None):
+ calls.append((target, payload, ttl))
+ return webpush.DELIVERED
+
+ monkeypatch.setattr(push, "_send", fake_send)
+ push._last_chat.clear()
+ return calls
+
+
+async def _subscribe(client, token, endpoint="https://push.example.net/up/abc"):
+ _, p256dh, auth = _phone()
+ r = await client.post("/v1/push/subscriptions",
+ json={"endpoint": endpoint, "p256dh": p256dh, "auth": auth},
+ headers={"Authorization": f"Bearer {token}"})
+ return r
+
+
+async def _two_in_a_group(client, db_session, name):
+ member = await _user(client, f"{name}_member")
+ owner = await _user(client, f"{name}_owner")
+ g = await client.post("/v1/groups", json={"name": name},
+ headers={"Authorization": f"Bearer {owner}"})
+ gid = g.json()["group_id"]
+ await add_member(client, gid, f"{name}_member", {"Authorization": f"Bearer {owner}"})
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == f"{name}_member"))).scalar_one()
+ return member, uid, gid
+
+
+# ── Encryption ───────────────────────────────────────────────────────────────
+
+def test_encryption_reproduces_the_rfc_8291_example():
+ as_private = ec.derive_private_key(
+ int.from_bytes(_b64("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"), "big"),
+ ec.SECP256R1())
+ out = webpush.encrypt(
+ _b64("V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24"),
+ _b64("BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx"
+ "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"),
+ _b64("BTBZMqHH6r4Tts7J_aSIgg"),
+ as_private=as_private, salt=_b64("DGv6ra1nlYgDCS1FRnbzlw"))
+ header = _b64("DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml"
+ "mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8")
+ ciphertext = _b64("8pfeW0KbunFT06SuDKoJH9Ql87S1QUrdirN6GcG7sFz1y1sqLgVi1VhjVkHsUoEs"
+ "bI_0LpXMuGvnzQ")
+ assert out == header + ciphertext
+
+
+def test_a_fresh_encryption_opens_on_the_phone():
+ sk, p256dh, auth = _phone()
+ body = webpush.encrypt(b'{"kind":"x"}', _b64(p256dh), _b64(auth))
+ assert _decrypt(body, sk, _b64(auth)) == b'{"kind":"x"}'
+
+
+# ── The endpoint is a URL a member chose, and the hub fetches it ─────────────
+
+@pytest.mark.parametrize("endpoint", [
+ "http://push.example.net/up/abc",
+ "https://127.0.0.1/up",
+ "https://10.1.2.3/up",
+ "https://[::1]/up",
+ "https://169.254.169.254/latest",
+ "https://user:pw@push.example.net/up",
+ "ftp://push.example.net/up",
+])
+@pytest.mark.asyncio
+async def test_an_endpoint_the_hub_should_not_fetch_is_refused(client, endpoint):
+ token = await _user(client, "ssrf_shape")
+ r = await _subscribe(client, token, endpoint)
+ assert r.status_code == 422, (endpoint, r.text)
+
+
+@pytest.mark.asyncio
+async def test_a_name_resolving_to_a_private_address_is_never_sent_to():
+ _, p256dh, auth = _phone()
+ seen = []
+ mock = httpx.AsyncClient(transport=httpx.MockTransport(
+ lambda req: seen.append(req) or httpx.Response(201)))
+ result = await webpush.send(webpush.Target("https://localhost/up", p256dh, auth),
+ {"v": 1}, ttl=60, client=mock)
+ assert result == webpush.FAILED and seen == []
+
+
+@pytest.mark.asyncio
+async def test_the_request_goes_to_the_address_that_was_checked(monkeypatch):
+ """The hostname is the TLS name and the Host header only: a second lookup
+ returning something else would never be made."""
+ sk, p256dh, auth = _phone()
+
+ async def resolved(host, port):
+ assert host == "push.example.net"
+ return "93.184.215.14"
+
+ monkeypatch.setattr(webpush, "_resolve_public", resolved)
+ seen = []
+ mock = httpx.AsyncClient(transport=httpx.MockTransport(
+ lambda req: seen.append(req) or httpx.Response(201)))
+ result = await webpush.send(
+ webpush.Target("https://push.example.net/up/abc?up=1", p256dh, auth),
+ {"title": "hello"}, ttl=60, client=mock)
+ assert result == webpush.DELIVERED
+ (req,) = seen
+ assert req.url.host == "93.184.215.14" and req.url.path == "/up/abc"
+ assert req.headers["host"] == "push.example.net"
+ assert req.extensions["sni_hostname"] == "push.example.net"
+ assert req.headers["content-encoding"] == "aes128gcm" and req.headers["ttl"] == "60"
+ assert json.loads(_decrypt(req.content, sk, _b64(auth))) == {"title": "hello"}
+
+
+@pytest.mark.asyncio
+async def test_bad_keys_are_refused(client):
+ token = await _user(client, "bad_keys_user")
+ r = await client.post("/v1/push/subscriptions", json={
+ "endpoint": "https://push.example.net/up/abc",
+ "p256dh": _b64e(b"\x04" + b"\x01" * 64), "auth": _b64e(b"0" * 16)},
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 422
+
+
+# ── Rows ─────────────────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_registering_again_updates_the_same_row(client):
+ token = await _user(client, "registers_again")
+ first = (await _subscribe(client, token)).json()["id"]
+ second = (await _subscribe(client, token)).json()["id"]
+ assert first == second
+
+
+@pytest.mark.asyncio
+async def test_an_account_holds_a_bounded_number_of_subscriptions(client):
+ token = await _user(client, "many_phones")
+ for i in range(push.MAX_SUBSCRIPTIONS):
+ r = await _subscribe(client, token, f"https://push.example.net/up/{i}")
+ assert r.status_code == 200, r.text
+ r = await _subscribe(client, token, "https://push.example.net/up/one-more")
+ assert r.status_code == 429
+
+
+@pytest.mark.asyncio
+async def test_only_the_owner_can_remove_a_subscription(client):
+ mine = await _user(client, "sub_owner")
+ other = await _user(client, "sub_other")
+ sid = (await _subscribe(client, mine)).json()["id"]
+ r = await client.delete(f"/v1/push/subscriptions/{sid}",
+ headers={"Authorization": f"Bearer {other}"})
+ assert r.status_code == 404
+ r = await client.delete(f"/v1/push/subscriptions/{sid}",
+ headers={"Authorization": f"Bearer {mine}"})
+ assert r.status_code == 200
+
+
+# ── What is pushed, and what is not ──────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_a_notification_reaches_the_phone(client, db_session, sent):
+ from meshbay_hub.api.notifications import create_notification
+
+ member, uid, gid = await _two_in_a_group(client, db_session, "pushed")
+ assert (await _subscribe(client, member)).status_code == 200
+ await create_notification(db_session, uid, "chat_message", "owner posted in pushed",
+ link=f"#/group/{gid}", group_id=gid, aggregate=True)
+ await db_session.commit()
+ await push.drain()
+ (target, payload, ttl) = sent[0]
+ assert payload["kind"] == "chat_message" and payload["group_id"] == gid
+ assert payload["title"] == "owner posted in pushed" and ttl == webpush.TTL_CHAT
+
+
+@pytest.mark.asyncio
+async def test_a_muted_group_pushes_nothing(client, db_session, sent):
+ from meshbay_hub.api.notifications import create_notification
+
+ member, uid, gid = await _two_in_a_group(client, db_session, "hushed")
+ await _subscribe(client, member)
+ r = await client.post(f"/v1/groups/{gid}/mute", json={"muted": True},
+ headers={"Authorization": f"Bearer {member}"})
+ assert r.status_code == 200
+ await create_notification(db_session, uid, "chat_message", "owner posted in hushed",
+ group_id=gid, aggregate=True)
+ await db_session.commit()
+ await push.drain()
+ assert sent == []
+
+
+@pytest.mark.asyncio
+async def test_every_notification_turned_off_pushes_nothing_and_stores_nothing(
+ client, db_session, sent):
+ """The account-wide switch was read by the interface only: rows went on
+ being created and hidden. With a phone told about each row, that would have
+ been a switch that did nothing."""
+ from meshbay_hub.api.notifications import create_notification
+
+ member, uid, gid = await _two_in_a_group(client, db_session, "silenced")
+ await _subscribe(client, member)
+ r = await client.put("/v1/users/me/preferences/notifications_disabled",
+ json={"value": "true"},
+ headers={"Authorization": f"Bearer {member}"})
+ assert r.status_code == 200
+ for kind, group in (("chat_message", gid), ("group_invite", None)):
+ made = await create_notification(db_session, uid, kind, "something",
+ group_id=group, aggregate=group is not None)
+ assert made is None, kind
+ await db_session.commit()
+ await push.drain()
+ assert sent == []
+ rows = (await db_session.execute(
+ select(Notification).where(Notification.user_id == uid,
+ Notification.title == "something"))).scalars().all()
+ assert rows == []
+
+ await client.put("/v1/users/me/preferences/notifications_disabled",
+ json={"value": "false"},
+ headers={"Authorization": f"Bearer {member}"})
+ await create_notification(db_session, uid, "group_invite", "back on")
+ await db_session.commit()
+ await push.drain()
+ assert [p["title"] for _, p, _ in sent] == ["back on"]
+
+
+@pytest.mark.asyncio
+async def test_a_busy_conversation_reaches_a_phone_once_per_window(client, db_session, sent):
+ from meshbay_hub.api.notifications import create_notification
+
+ member, uid, gid = await _two_in_a_group(client, db_session, "chatty")
+ await _subscribe(client, member)
+ for i in range(5):
+ await create_notification(db_session, uid, "chat_message", f"line {i}",
+ group_id=gid, aggregate=True)
+ await create_notification(db_session, uid, "group_invite", "not chat")
+ await db_session.commit()
+ await push.drain()
+ assert [p["title"] for _, p, _ in sent] == ["line 0", "not chat"]
+
+
+@pytest.mark.asyncio
+async def test_a_registration_the_push_server_dropped_falls_back_to_fetching(
+ client, db_session, monkeypatch):
+ """The row loses its endpoint, not its existence: the phone keeps fetching."""
+ from meshbay_hub.api.notifications import create_notification
+
+ async def gone(target, payload, *, ttl, client=None):
+ return webpush.GONE
+
+ monkeypatch.setattr(push, "_send", gone)
+ push._last_chat.clear()
+ member, uid, gid = await _two_in_a_group(client, db_session, "dropped")
+ await _subscribe(client, member)
+ await create_notification(db_session, uid, "group_invite", "anyone there")
+ await db_session.commit()
+ await push.drain()
+ db_session.expire_all()
+ (row,) = (await db_session.execute(
+ select(PushSubscription).where(PushSubscription.user_id == uid))).scalars().all()
+ assert row.endpoint is None and row.p256dh is None and row.poll_hash is not None
+
+
+# ── Fetching, for a phone with no distributor ───────────────────────────────
+
+async def _poll(client, reg, since=None):
+ return await client.post("/v1/push/poll", json={
+ "id": reg["id"], "secret": reg["poll_secret"], "since": since or reg["now"]})
+
+
+@pytest.fixture
+def unthrottled(monkeypatch):
+ monkeypatch.setattr(push, "POLL_MIN_INTERVAL", 0.0)
+ push._last_poll.clear()
+
+
+@pytest.mark.asyncio
+async def test_a_phone_without_a_distributor_fetches_what_is_new(
+ client, db_session, sent, unthrottled):
+ from meshbay_hub.api.notifications import create_notification
+
+ member, uid, gid = await _two_in_a_group(client, db_session, "fetched")
+ r = await client.post("/v1/push/subscriptions", json={},
+ headers={"Authorization": f"Bearer {member}"})
+ assert r.status_code == 200, r.text
+ reg = r.json()
+ # The invitation that made them a member predates the registration: not news.
+ assert (await _poll(client, reg)).json()["notifications"] == []
+
+ await create_notification(db_session, uid, "chat_message", "owner posted in fetched",
+ link=f"#/group/{gid}", group_id=gid, aggregate=True)
+ await db_session.commit()
+ await push.drain()
+ assert sent == [], "a row with no endpoint is never pushed to"
+ (got,) = (await _poll(client, reg)).json()["notifications"]
+ assert got["kind"] == "chat_message" and got["group_id"] == gid
+ assert got["title"] == "owner posted in fetched"
+ assert (await _poll(client, reg, got["created_at"])).json()["notifications"] == []
+
+
+@pytest.mark.asyncio
+async def test_fetching_honours_both_switches(client, db_session, unthrottled):
+ from meshbay_hub.api.notifications import create_notification
+
+ member, uid, gid = await _two_in_a_group(client, db_session, "fetchmute")
+ reg = (await client.post("/v1/push/subscriptions", json={},
+ headers={"Authorization": f"Bearer {member}"})).json()
+ await client.post(f"/v1/groups/{gid}/mute", json={"muted": True},
+ headers={"Authorization": f"Bearer {member}"})
+ await create_notification(db_session, uid, "chat_message", "muted line",
+ group_id=gid, aggregate=True)
+ await client.put("/v1/users/me/preferences/notifications_disabled",
+ json={"value": "true"}, headers={"Authorization": f"Bearer {member}"})
+ await create_notification(db_session, uid, "group_invite", "all off")
+ await db_session.commit()
+ assert (await _poll(client, reg)).json()["notifications"] == []
+
+
+@pytest.mark.asyncio
+async def test_the_poll_secret_is_the_only_way_in(client, unthrottled):
+ mine = await _user(client, "poll_owner")
+ reg = (await client.post("/v1/push/subscriptions", json={},
+ headers={"Authorization": f"Bearer {mine}"})).json()
+ assert (await _poll(client, {**reg, "poll_secret": "x" * 43})).status_code == 404
+ assert (await _poll(client, {**reg, "id": "0" * 36})).status_code == 404
+ again = (await client.post("/v1/push/subscriptions", json={"id": reg["id"]},
+ headers={"Authorization": f"Bearer {mine}"})).json()
+ assert again["id"] == reg["id"]
+ assert (await _poll(client, reg)).status_code == 404, "a new secret retires the old"
+ assert (await _poll(client, again)).status_code == 200
+ await client.delete(f"/v1/push/subscriptions/{reg['id']}",
+ headers={"Authorization": f"Bearer {mine}"})
+ assert (await _poll(client, again)).status_code == 404, "signed out: the row is gone"
+
+
+@pytest.mark.asyncio
+async def test_another_account_cannot_take_over_a_row(client):
+ mine = await _user(client, "row_owner")
+ other = await _user(client, "row_taker")
+ reg = (await client.post("/v1/push/subscriptions", json={},
+ headers={"Authorization": f"Bearer {mine}"})).json()
+ theirs = (await client.post("/v1/push/subscriptions", json={"id": reg["id"]},
+ headers={"Authorization": f"Bearer {other}"})).json()
+ assert theirs["id"] != reg["id"]
+
+
+@pytest.mark.asyncio
+async def test_a_phone_cannot_poll_faster_than_the_floor(client):
+ push._last_poll.clear()
+ mine = await _user(client, "eager_poller")
+ reg = (await client.post("/v1/push/subscriptions", json={},
+ headers={"Authorization": f"Bearer {mine}"})).json()
+ assert (await _poll(client, reg)).status_code == 200
+ r = await _poll(client, reg)
+ assert r.status_code == 429 and int(r.headers["retry-after"]) > 0
+
+
+@pytest.mark.asyncio
+async def test_a_phone_gaining_a_distributor_keeps_its_row(client):
+ mine = await _user(client, "upgrading_phone")
+ reg = (await client.post("/v1/push/subscriptions", json={},
+ headers={"Authorization": f"Bearer {mine}"})).json()
+ _, p256dh, auth = _phone()
+ r = await client.post("/v1/push/subscriptions", json={
+ "id": reg["id"], "endpoint": "https://push.example.net/up/new",
+ "p256dh": p256dh, "auth": auth}, headers={"Authorization": f"Bearer {mine}"})
+ assert r.json()["id"] == reg["id"]
+
+
+@pytest.mark.asyncio
+async def test_an_endpoint_without_its_keys_is_refused(client):
+ mine = await _user(client, "keyless_phone")
+ r = await client.post("/v1/push/subscriptions",
+ json={"endpoint": "https://push.example.net/up/k"},
+ headers={"Authorization": f"Bearer {mine}"})
+ assert r.status_code == 422
diff --git a/packages/meshbay-hub/tests/test_unauthenticated_surface.py b/packages/meshbay-hub/tests/test_unauthenticated_surface.py
index 563dfa8..aabcf45 100644
--- a/packages/meshbay-hub/tests/test_unauthenticated_surface.py
+++ b/packages/meshbay-hub/tests/test_unauthenticated_surface.py
@@ -39,6 +39,7 @@ PUBLIC = {
("POST", "/v1/users/verify-email"): "the e-mailed code is the credential, attempts capped",
("POST", "/v1/users/password/reset-request"): "captcha, per-IP and per-account limits",
("POST", "/v1/users/password/reset"): "the e-mailed code is the credential, attempts capped",
+ ("POST", "/v1/push/poll"): "a phone's own poll secret; reads notification lines, one a minute",
("POST", "/v1/nodes/auth"): "node sign-in — Ed25519 signature over a fresh timestamp",
("WS", "/v1/nodes/ws"): "a node-scoped JWT in the first message, within a timeout",
("GET", "/v1/groups"): "the public directory — empty when public groups are off",