From 9de935b5aa5848eafd50a55e19a193c9fc6dcd15 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 15 May 2026 11:59:19 +0100 Subject: [PATCH 01/20] notifications: add push_subscribe / push_unsubscribe (RFC 0020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a `Notifications` trait that carries the existing `push_notification` (moved from `System`, wire id 4 preserved) plus two new methods: - `push_subscribe(rule)` — register a `(signer, topic)` whitelist rule so the user is woken up by a push whenever a matching signed statement appears on the Statement Store. - `push_unsubscribe(rule)` — remove a previously registered rule. The methods are wired through to the host's push backend (the backend-mediated v2 design); the product never sees push tokens. Both calls are idempotent and gated by `DevicePermission::Notifications`. Wire ids: `push_subscribe = 134`, `push_unsubscribe = 136`. `push_notification` keeps `request_id = 4` so the move is wire-stable. --- .../0020-push-notification-subscriptions.md | 324 ++++++++++++++++++ docs/rfcs/_index.md | 1 + rust/crates/truapi/src/api/mod.rs | 4 + rust/crates/truapi/src/api/notifications.rs | 83 +++++ rust/crates/truapi/src/api/system.rs | 27 +- rust/crates/truapi/src/v01/mod.rs | 2 + rust/crates/truapi/src/v01/notifications.rs | 72 ++++ rust/crates/truapi/src/v01/system.rs | 8 - rust/crates/truapi/src/versioned/mod.rs | 1 + .../truapi/src/versioned/notifications.rs | 15 + rust/crates/truapi/src/versioned/system.rs | 3 - 11 files changed, 505 insertions(+), 35 deletions(-) create mode 100644 docs/rfcs/0020-push-notification-subscriptions.md create mode 100644 rust/crates/truapi/src/api/notifications.rs create mode 100644 rust/crates/truapi/src/v01/notifications.rs create mode 100644 rust/crates/truapi/src/versioned/notifications.rs diff --git a/docs/rfcs/0020-push-notification-subscriptions.md b/docs/rfcs/0020-push-notification-subscriptions.md new file mode 100644 index 00000000..6e677b2d --- /dev/null +++ b/docs/rfcs/0020-push-notification-subscriptions.md @@ -0,0 +1,324 @@ +--- +title: "Push Notification Subscriptions" +type: rfc +status: draft +owner: "@pgherveou" +pr: +--- + +# RFC 0020 — Push Notification Subscriptions + +## Summary + +Adds two TrUAPI methods, `notifications.push_subscribe` and `notifications.push_unsubscribe`, that let a product declare which Statement Store events the user wants to be woken up for. A subscription is a `(signer, topic)` pair: the host's push backend will deliver a push to the user's device(s) whenever a signed statement matching that pair appears on the store. The product never sees push tokens; the host owns token registration and the integration with the backend described in the push-notifications v2 design. + +The RFC also moves the existing `system.push_notification` method onto the new `Notifications` trait so notification-related surface lives in one place. The wire id (`request_id = 4`) is preserved, so this is a Rust-side reorganization only; the wire protocol is unchanged for that method. + +## Related RFCs + +- RFC 0019 — Scheduled Push Notifications (locally scheduled reminders fired by the host's OS scheduler). + +This RFC is orthogonal: it covers **remote-event-driven** pushes delivered by the push backend, not locally originated ones. + +## Motivation + +The current `push_notification` method only handles **locally originated** notifications: the product itself, while running, asks the host to display one. It has no answer for **remote events the user wants to be woken up for when the product is not running** — the canonical reason push notifications exist on mobile platforms. + +The push-notifications v2 design assigns this job to a host-side push backend that tails the Statement Store, verifies signatures, and delivers pushes only for `(signer, topic)` pairs the user has whitelisted. To wire products into that model, TrUAPI needs a primitive that lets a product manipulate the user's whitelist. + +Use cases this unlocks: + +- **Festival announcement subscription.** A conference product publishes festival-wide announcements as signed statements on a well-known topic. When the user taps "notify me about announcements," the product calls `push_subscribe({ signer = festival_signer, topic = announcements_topic })`. The user is now woken up for new announcements even with the product closed. +- **Per-room / per-channel chat notifications.** A chat product subscribes the user to specific session topics so that direct messages from a particular contact wake the device. +- **On-chain event reminders.** A wallet product subscribes the user to a topic onto which an indexer publishes signed statements when relevant chain events fire. +- **Unsubscribe symmetry.** Tapping "stop notifying me" must cleanly retract the whitelist entry without forcing the product to re-derive any device-level state. + +Without this primitive, products must either ship their own background process (impractical on web/mobile sandboxes) or expose push tokens to peers (the v1 model the v2 redesign explicitly rejects). + +## Context: end-to-end flow + +The diagram below shows the publish/subscribe shape this RFC enables. A +**publisher app** signs an update and writes it to the Statement Store; a +**subscriber app** has previously declared interest via the new TrUAPI +methods; the host's push backend tails the store, matches the new statement +against the subscriber's rules, and pushes the same signed statement back to +the subscriber app. The publisher does not know its subscribers in advance, +and there is no pairwise key between them; authenticity comes from the +publisher's signature. + +``` +Publisher app Subscriber app +(organizer side) (attendee side) + | ^ | + | | | + | | | (1) pushSubscribe({ + | (6) push | | signer: pkPublisher, + | back to | | topic: T_announcements + | caller | | }) + | | | + | | v + | +------------------------------------+---+------+ + | | Push backend | + | | stores rule: | + | | (pkPublisher, T_announcements) | + | | -> this subscriber app | + | +-----------------------+-----------------------+ + | ^ + | | (4) tail / match rule + | | + | +-----------------------+-----------------------+ + | | Statement Store | + | +-----------------------+-----------------------+ + | ^ + | (2) compose statement | + | statement = { | + | sender_pk: pkPublisher, | + | topic: T_announcements, | + | data: encode(Announcement), | <-- PLAINTEXT + | sig: Sr25519_sign(skPublisher, body), + | } | + | | + |--- (3) statementStore.submit(statement) -+ +``` + +On receipt (step 6) the subscriber app: + +``` +stmt = decode(push.data) +require Sr25519_verify(stmt.sig, body, pkPublisher) +announcement = decode(stmt.data) +display(announcement) +``` + +The festival-announcement case is the canonical example; the same shape +applies to any one-to-many notification flow (on-chain event indexers, +broadcast channels, system-status alerts). See +[`push-notifications/v2-broadcast-pubsub.md`](https://github.com/paritytech/sdk-team/blob/main/docs/push-notifications/v2-broadcast-pubsub.md) +in the SDK-team docs for the longer write-up of this design. + +## Detailed Design + +### Trait reorganization + +A new trait `Notifications` is added at `rust/crates/truapi/src/api/notifications.rs` and joined into the `TrUApi` super-trait. The existing `push_notification` method moves from `System` onto this trait. Its wire id is preserved (`request_id = 4`), so no wire-protocol break occurs. + +```rust +pub trait Notifications: Send + Sync { + #[wire(request_id = 4)] + async fn push_notification( + &self, + cx: &CallContext, + request: HostPushNotificationRequest, + ) -> Result>; + + #[wire(request_id = 134)] + async fn push_subscribe( + &self, + cx: &CallContext, + request: HostPushSubscribeRequest, + ) -> Result>; + + #[wire(request_id = 136)] + async fn push_unsubscribe( + &self, + cx: &CallContext, + request: HostPushUnsubscribeRequest, + ) -> Result>; +} +``` + +Request ids `134` and `136` are the next free even ids after the existing highest allocation (`132`). + +### Type definitions (v0.1 wire types) + +New types live in `rust/crates/truapi/src/v01/notifications.rs`, with versioned wrappers in `rust/crates/truapi/src/versioned/notifications.rs`. `Topic` is reused from `v01::statement_store`. + +```rust +/// 32-byte statement signer (matches `StatementProof::Sr25519::signer` +/// and `StatementProof::Ed25519::signer`). +pub type StatementSigner = [u8; 32]; + +/// A single (signer, topic) pair the user wants to be woken up for. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct PushSubscriptionRule { + pub signer: StatementSigner, + pub topic: Topic, +} + +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPushSubscribeRequest { + pub rule: PushSubscriptionRule, +} + +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostPushSubscribeError { + PermissionDenied, + SubscriptionLimitReached, + BackendUnavailable, + Unknown { reason: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPushUnsubscribeRequest { + pub rule: PushSubscriptionRule, +} + +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostPushUnsubscribeError { + BackendUnavailable, + Unknown { reason: String }, +} +``` + +Both rule fields are required. There is no wildcard. A product that needs to be pushed for the same signer on three topics issues three `push_subscribe` calls. + +`PermissionDenied` is **not** modelled on the unsubscribe path: removing consent never requires consent. `HostPushSubscribeResponse` and `HostPushUnsubscribeResponse` are unit-shaped at the versioned layer (no v0.1 inner type needed). + +### Behavioural requirements + +1. **Per-product scope.** Each `(product_identity, signer, topic)` triple is independent. Product A subscribing to a rule has no effect on product B's rule set, even for the same `(signer, topic)`. The host MUST NOT leak one product's subscriptions to another, and MUST NOT count one product's pushes against another's quotas. + +2. **The product never sees the push token.** The host registers its own device token(s) with the push backend out-of-band (typically on first launch). `push_subscribe` only attaches a rule to those subscriptions; it never returns or accepts tokens. + +3. **Fan-out across the user's devices.** If the user is logged in on multiple devices that share this host identity, the rule is registered against each device's subscription. A matching statement wakes every eligible device. + +4. **Idempotency.** Registering the same rule twice is a no-op. Unsubscribing an unknown rule is a no-op. Products do not have to track local state to avoid duplicate calls. + +5. **Permission gating.** Both methods are gated by `DevicePermission::Notifications`. The host SHOULD request the permission lazily on the first `push_subscribe` call from a product, mirroring the existing `notifications.push_notification` behaviour. `push_unsubscribe` MUST work regardless of permission state. + +6. **Survives product not running.** Once a rule is registered, the user is pushed for matching statements until the rule is removed or the product is uninstalled. A push that wakes the device for a product that is not running is the expected outcome, not an error. + +7. **Cleanup on product disconnect.** When a product is uninstalled, force-removed, or otherwise reaches end-of-life on the host, the host MUST remove all of that product's subscription rules. Transient disconnects (host restart, network blip) MUST NOT trigger cleanup. Rationale: rules firing for a product the user can no longer reach are user-hostile. + +8. **Failure modes.** The host SHOULD treat `BackendUnavailable` as a soft failure — the product retains the option to retry. Local state on the host MAY queue the rule for delivery once the backend is reachable; if it does, the host MUST still return `BackendUnavailable` on the original call so the product is not lied to about the live state. + +9. **Limits.** A product MAY register up to **64** active rules. A call that would exceed the cap MUST return `SubscriptionLimitReached`. Unsubscribing a rule frees a slot. The cap is per-product so a noisy product cannot crowd out a quieter one. The protocol does not specify a global cap; hosts MAY impose one as a backend-protection measure. + +10. **Rule equality.** Two rules are equal iff both `signer` and `topic` are byte-equal. The host MUST de-duplicate on this equality. + +### Permission model + +No new permission variant is introduced. `DevicePermission::Notifications` (defined in `v01/permissions.rs`) covers both: + +- the existing `notifications.push_notification` (local, host-rendered), +- the new `notifications.push_subscribe` / `unsubscribe` (remote, backend-delivered). + +Rationale: from the user's perspective both are "the app may notify me." Splitting the prompt would add friction without a corresponding security gain. From the protocol's perspective the consent target is the user's *attention*, not the specific delivery mechanism. + +### Protocol Integration + +Per the action-derivation rules in `host-api-protocol.md` §"Interface (ABI)", the two new methods produce four actions: + +``` +host_push_subscribe_request: Versioned +host_push_subscribe_response: Versioned> +host_push_unsubscribe_request: Versioned +host_push_unsubscribe_response: Versioned> +``` + +Request ids `134` and `136` are appended after the existing highest allocation (`132`). The new methods are introduced as `Versioned::V1` payloads. The `push_notification` move preserves its existing `request_id = 4` and its existing `Versioned::V1` payload bytes, so the change is purely additive on the wire. + +### Worked example — festival announcements + +``` +Conference product Host (Notifications trait) Push backend Statement Store + | | | | + user taps "Notify me about announcements" | | | + | | | | + |--- notifications.pushSubscribe({ | | | + | signer: festivalSigner, | | | + | topic: announcementsTopic | | | + | }) ------------------------------> | | | + | |--- POST /v1/subscriptions/rules | + | | { rule: (signer, topic) } ----->| | + | |<-- 200 ----------------------------| | + |<-- Ok ---------------------------------| | + | | | + ... later, product is closed ... + | + festival publishes signed announcement -------------->| + | + | |<-- tail / subscribe --| + | | signed statement | + | | | + | | verify sig, match | + | | rule, push | + |<-- APNs/FCM/VoIP -------| | + |<-- user device wakes ------------------| | + | OS shows notification | | + | | + user taps "Stop notifying me" | + |--- notifications.pushUnsubscribe({ | | | + | signer: festivalSigner, | | | + | topic: announcementsTopic | | | + | }) ------------------------------> | | | + | |--- DELETE /v1/subscriptions/rules | + | | { rule: (signer, topic) } ----->| | + | |<-- 200 ----------------------------| | + |<-- Ok ---------------------------------| | +``` + +The product call site (TypeScript, illustrative): + +```ts +import { type Client } from "@parity/truapi"; + +export async function subscribeToFestivalAnnouncements( + truapi: Client, + festivalSigner: Uint8Array, + announcementsTopic: Uint8Array, +): Promise { + const result = await truapi.notifications.pushSubscribe({ + rule: { signer: festivalSigner, topic: announcementsTopic }, + }); + if (result.isErr()) throw result.error; +} + +export async function unsubscribeFromFestivalAnnouncements( + truapi: Client, + festivalSigner: Uint8Array, + announcementsTopic: Uint8Array, +): Promise { + const result = await truapi.notifications.pushUnsubscribe({ + rule: { signer: festivalSigner, topic: announcementsTopic }, + }); + if (result.isErr()) throw result.error; +} +``` + +## Drawbacks + +1. **State the host must persist.** Subscription rules belong to the user (not the product session) and must survive product restarts, app updates, and device reboots. Hosts now own a small but real piece of state per product. + +2. **Backend coupling.** The host implementation depends on a reachable push backend (an instance of the v2 backend design). Hosts that ship without one must return `BackendUnavailable` for every call, which is a degraded but well-defined state. + +3. **Per-product cap of 64 may be too tight for some products.** A chat product whose user has 200 contacts cannot subscribe per-contact. The intended workaround is to subscribe at a coarser topic granularity (e.g., one topic per app-defined channel) or to rely on a single signer covering many topics via wildcards added in a future RFC. + +4. **No introspection in v1.** This RFC deliberately omits a `push_list_subscriptions` method (see "Unresolved Questions"). Products that want to reconcile local UI state with server state must currently track it themselves. + +## Alternatives + +### Keep everything on `System` + +Add the two new methods to `System` and leave `push_notification` where it is. **Rejected**: `System` already mixes handshake, feature detection, and navigation. Adding three notification-related methods would make it the catch-all junk drawer. A dedicated `Notifications` trait keeps the surface coherent and gives a natural home for future additions (e.g. a list endpoint, bulk replace). The move is wire-stable because `push_notification` keeps its existing `request_id = 4`. + +### Bulk `push_set_subscriptions(rules: Vec)` instead of per-rule add/remove + +Mirror the backend's `PUT /v1/subscriptions/rules` atomic-replace endpoint at the TrUAPI surface. **Rejected for v1**: products overwhelmingly drive subscriptions from per-rule UI actions (tap toggle → one rule changes). A bulk replace forces every caller to fetch-modify-send, which is the wrong default. A bulk method can be added later without breaking the per-rule API. + +### Have the product write its own statement-store subscriptions and forward them to push + +Reuse `remote_statement_store_subscribe` (RFC 0008) and have the host detect that a product is interested and forward to push. **Rejected**: conflates two semantically different things (in-product live updates vs. wake-the-device-when-closed) and forces the host to introspect the running product's connection state to decide whether to push. The two have different reliability requirements, payload constraints, and permission stories; they deserve different surfaces. + +### Encode the rule as `(signer, channel)` instead of `(signer, topic)` + +`Statement.channel` is a single optional 32-byte field, while `Statement.topics` is a vector. Matching on `channel` would be a single equality check. **Rejected**: topic-based rules are what the v2 backend design specifies, and `topics` is the field products already use for fan-out via RFC 0008. Mixing `channel` would split the matching strategy for no benefit. + +## Unresolved Questions + +- **List endpoint.** Should we add `push_list_subscriptions() -> Vec` to let a product reconcile local UI state with the host's view (e.g., after a logout/login cycle)? Easy to add later. Skipped for v1 to keep the surface minimal. +- **Atomic bulk replace.** Whether and when to add `push_set_subscriptions(Vec)` mirroring the backend's `PUT /v1/subscriptions/rules`. +- **Signer schemes beyond Sr25519/Ed25519.** `StatementProof` also has `Ecdsa` (33-byte signer) and `OnChain` (32-byte `who`). The `[u8; 32]` shape covers Sr25519, Ed25519, and `OnChain`. ECDSA-signed statements (33-byte signer) cannot be expressed by the current rule type; if this becomes a real use case the rule should evolve into a tagged enum. +- **Topic-granularity wildcards.** A product that wants "all statements from signer S regardless of topic" must subscribe per-topic. Whether to add `MatchAnyTopic` semantics, and how that interacts with per-product caps, is left for a follow-up. +- **Backend selection.** A host MAY have multiple eligible push backends (e.g., one per network). How a rule binds to a specific backend, and what happens when the user's device set spans backends, is not specified here. +- **Rate-limit visibility.** The v2 backend rate-limits at 30 notifications per 60s per `(sender, receiver)` pair. The TrUAPI surface currently makes that invisible to the product. Whether to expose a "dropped due to rate limit" signal back to the product is open. diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index 6f45a32e..7fe94348 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -21,3 +21,4 @@ created: 2026-03-13 | 0011 | [Simple Group Chat](0011-simple-group-chat.md) | draft | @filvecchiato | [#131](https://github.com/paritytech/triangle-js-sdks/pull/131) | | 0015 | [Get User Primary DotNS Name](0015-get-user-id.md) | draft | @valentunn | [#144](https://github.com/paritytech/triangle-js-sdks/pull/144) | | 0019 | [Scheduled Push Notifications](0019-scheduled-notifications.md) | draft | @johnthecat | | +| 0020 | [Push Notification Subscriptions](0020-push-notification-subscriptions.md) | draft | @pgherveou | | diff --git a/rust/crates/truapi/src/api/mod.rs b/rust/crates/truapi/src/api/mod.rs index 9332e8ce..c08fbc0f 100644 --- a/rust/crates/truapi/src/api/mod.rs +++ b/rust/crates/truapi/src/api/mod.rs @@ -6,6 +6,7 @@ pub mod chat; pub mod entropy; pub mod jsonrpc; pub mod local_storage; +pub mod notifications; pub mod payment; pub mod permissions; pub mod preimage; @@ -21,6 +22,7 @@ pub use chat::Chat; pub use entropy::Entropy; pub use jsonrpc::JsonRpc; pub use local_storage::LocalStorage; +pub use notifications::Notifications; pub use payment::Payment; pub use permissions::Permissions; pub use preimage::Preimage; @@ -38,6 +40,7 @@ pub trait TrUApi: + Entropy + JsonRpc + LocalStorage + + Notifications + Payment + Permissions + Preimage @@ -58,6 +61,7 @@ impl TrUApi for T where + Entropy + JsonRpc + LocalStorage + + Notifications + Payment + Permissions + Preimage diff --git a/rust/crates/truapi/src/api/notifications.rs b/rust/crates/truapi/src/api/notifications.rs new file mode 100644 index 00000000..f7c42640 --- /dev/null +++ b/rust/crates/truapi/src/api/notifications.rs @@ -0,0 +1,83 @@ +//! Unified [`Notifications`] trait. + +use crate::versioned::notifications::{ + HostPushNotificationError, HostPushNotificationRequest, HostPushNotificationResponse, + HostPushSubscribeError, HostPushSubscribeRequest, HostPushSubscribeResponse, + HostPushUnsubscribeError, HostPushUnsubscribeRequest, HostPushUnsubscribeResponse, +}; +use crate::wire; +use crate::{CallContext, CallError}; + +/// Notification methods: locally-rendered notifications and Statement Store +/// subscription rules for backend-delivered pushes. +pub trait Notifications: Send + Sync { + /// Send a notification to the user, rendered immediately by the host. + /// + /// ```ts + /// import { type Client } from "@parity/truapi"; + /// + /// export async function pushNotification(truapi: Client): Promise { + /// const result = await truapi.notifications.pushNotification({ + /// text: "Hello!", + /// }); + /// + /// if (result.isErr()) throw result.error; + /// } + /// ``` + #[wire(request_id = 4)] + async fn push_notification( + &self, + cx: &CallContext, + request: HostPushNotificationRequest, + ) -> Result>; + + /// Register a `(signer, topic)` rule so the user is woken up by a push + /// when a signed statement matching the rule appears on the Statement + /// Store. + /// + /// ```ts + /// import { type Client } from "@parity/truapi"; + /// + /// export async function subscribeToAnnouncements( + /// truapi: Client, + /// signer: Uint8Array, + /// topic: Uint8Array, + /// ): Promise { + /// const result = await truapi.notifications.pushSubscribe({ + /// rule: { signer, topic }, + /// }); + /// + /// if (result.isErr()) throw result.error; + /// } + /// ``` + #[wire(request_id = 134)] + async fn push_subscribe( + &self, + cx: &CallContext, + request: HostPushSubscribeRequest, + ) -> Result>; + + /// Remove a previously registered subscription rule. + /// + /// ```ts + /// import { type Client } from "@parity/truapi"; + /// + /// export async function unsubscribeFromAnnouncements( + /// truapi: Client, + /// signer: Uint8Array, + /// topic: Uint8Array, + /// ): Promise { + /// const result = await truapi.notifications.pushUnsubscribe({ + /// rule: { signer, topic }, + /// }); + /// + /// if (result.isErr()) throw result.error; + /// } + /// ``` + #[wire(request_id = 136)] + async fn push_unsubscribe( + &self, + cx: &CallContext, + request: HostPushUnsubscribeRequest, + ) -> Result>; +} diff --git a/rust/crates/truapi/src/api/system.rs b/rust/crates/truapi/src/api/system.rs index 46d9bcda..46629664 100644 --- a/rust/crates/truapi/src/api/system.rs +++ b/rust/crates/truapi/src/api/system.rs @@ -3,14 +3,13 @@ use crate::versioned::system::{ HostFeatureSupportedError, HostFeatureSupportedRequest, HostFeatureSupportedResponse, HostHandshakeError, HostHandshakeRequest, HostHandshakeResponse, HostNavigateToError, - HostNavigateToRequest, HostNavigateToResponse, HostPushNotificationError, - HostPushNotificationRequest, HostPushNotificationResponse, + HostNavigateToRequest, HostNavigateToResponse, }; use crate::wire; use crate::{CallContext, CallError}; -/// General-purpose TrUAPI methods for handshake, feature detection, -/// navigation, and notifications. +/// General-purpose TrUAPI methods for handshake, feature detection, and +/// navigation. pub trait System: Send + Sync { /// Negotiate the wire codec version with the product. /// @@ -63,26 +62,6 @@ pub trait System: Send + Sync { request: HostFeatureSupportedRequest, ) -> Result>; - /// Send a push notification to the user. - /// - /// ```ts - /// import { type Client } from "@parity/truapi"; - /// - /// export async function pushNotification(truapi: Client): Promise { - /// const result = await truapi.system.pushNotification({ - /// text: "Hello!", - /// }); - /// - /// if (result.isErr()) throw result.error; - /// } - /// ``` - #[wire(request_id = 4)] - async fn push_notification( - &self, - cx: &CallContext, - request: HostPushNotificationRequest, - ) -> Result>; - /// Request the host to open a URL. /// /// ```ts diff --git a/rust/crates/truapi/src/v01/mod.rs b/rust/crates/truapi/src/v01/mod.rs index 73200903..d06e2e7d 100644 --- a/rust/crates/truapi/src/v01/mod.rs +++ b/rust/crates/truapi/src/v01/mod.rs @@ -7,6 +7,7 @@ mod common; mod entropy; mod jsonrpc; mod local_storage; +mod notifications; mod payment; mod permissions; mod preimage; @@ -24,6 +25,7 @@ pub use common::*; pub use entropy::*; pub use jsonrpc::*; pub use local_storage::*; +pub use notifications::*; pub use payment::*; pub use permissions::*; pub use preimage::*; diff --git a/rust/crates/truapi/src/v01/notifications.rs b/rust/crates/truapi/src/v01/notifications.rs new file mode 100644 index 00000000..a9f59bf7 --- /dev/null +++ b/rust/crates/truapi/src/v01/notifications.rs @@ -0,0 +1,72 @@ +use parity_scale_codec::{Decode, Encode}; + +use super::Topic; + +/// Notification text and tap target shown by the host. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPushNotificationRequest { + /// Notification text. + pub text: String, + /// Optional URL to open on tap. + pub deeplink: Option, +} + +/// 32-byte statement signer key. +/// +/// Matches the `signer` field of [`StatementProof::Sr25519`] and +/// [`StatementProof::Ed25519`]. +/// +/// [`StatementProof::Sr25519`]: super::StatementProof::Sr25519 +/// [`StatementProof::Ed25519`]: super::StatementProof::Ed25519 +pub type StatementSigner = [u8; 32]; + +/// A single `(signer, topic)` pair the user wants to be woken up for. +/// +/// The host's push backend delivers a push to the user's device(s) whenever +/// a signed statement appears on the Statement Store whose signer equals +/// `signer` and whose `topics` list contains `topic`. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct PushSubscriptionRule { + /// Signer the matching statement must be signed by. + pub signer: StatementSigner, + /// Topic the matching statement must carry in its `topics` list. + pub topic: Topic, +} + +/// Request to register a single subscription rule with the host. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPushSubscribeRequest { + /// Rule to register. + pub rule: PushSubscriptionRule, +} + +/// Failure modes for [`HostPushSubscribeRequest`]. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostPushSubscribeError { + /// The user has not granted `DevicePermission::Notifications`. + PermissionDenied, + /// The product has reached the maximum number of active rules. + SubscriptionLimitReached, + /// The host's push backend is currently unreachable; the rule was not + /// registered. The product MAY retry later. + BackendUnavailable, + /// Catch-all. + Unknown { reason: String }, +} + +/// Request to remove a previously registered subscription rule. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPushUnsubscribeRequest { + /// Rule to remove. + pub rule: PushSubscriptionRule, +} + +/// Failure modes for [`HostPushUnsubscribeRequest`]. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostPushUnsubscribeError { + /// The host's push backend is currently unreachable; the rule may still + /// be active. The product MAY retry later. + BackendUnavailable, + /// Catch-all. + Unknown { reason: String }, +} diff --git a/rust/crates/truapi/src/v01/system.rs b/rust/crates/truapi/src/v01/system.rs index f171af6d..4025ff40 100644 --- a/rust/crates/truapi/src/v01/system.rs +++ b/rust/crates/truapi/src/v01/system.rs @@ -16,14 +16,6 @@ pub enum HostNavigateToError { Unknown { reason: String }, } -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPushNotificationRequest { - /// Notification text. - pub text: String, - /// Optional URL to open on tap. - pub deeplink: Option, -} - #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum HostHandshakeError { Timeout, diff --git a/rust/crates/truapi/src/versioned/mod.rs b/rust/crates/truapi/src/versioned/mod.rs index 1e53a18a..ce7f22ef 100644 --- a/rust/crates/truapi/src/versioned/mod.rs +++ b/rust/crates/truapi/src/versioned/mod.rs @@ -86,6 +86,7 @@ pub mod chat; pub mod entropy; pub mod jsonrpc; pub mod local_storage; +pub mod notifications; pub mod payment; pub mod permissions; pub mod preimage; diff --git a/rust/crates/truapi/src/versioned/notifications.rs b/rust/crates/truapi/src/versioned/notifications.rs new file mode 100644 index 00000000..5cf8075b --- /dev/null +++ b/rust/crates/truapi/src/versioned/notifications.rs @@ -0,0 +1,15 @@ +//! Versioned wrappers for [`Notifications`](crate::api::Notifications) methods. + +use crate::v01; + +versioned_type! { + pub enum HostPushNotificationRequest { V1 => v01::HostPushNotificationRequest } + pub enum HostPushNotificationResponse { V1 } + pub enum HostPushNotificationError { V1 => v01::GenericError } + pub enum HostPushSubscribeRequest { V1 => v01::HostPushSubscribeRequest } + pub enum HostPushSubscribeResponse { V1 } + pub enum HostPushSubscribeError { V1 => v01::HostPushSubscribeError } + pub enum HostPushUnsubscribeRequest { V1 => v01::HostPushUnsubscribeRequest } + pub enum HostPushUnsubscribeResponse { V1 } + pub enum HostPushUnsubscribeError { V1 => v01::HostPushUnsubscribeError } +} diff --git a/rust/crates/truapi/src/versioned/system.rs b/rust/crates/truapi/src/versioned/system.rs index 06a46b93..404465d8 100644 --- a/rust/crates/truapi/src/versioned/system.rs +++ b/rust/crates/truapi/src/versioned/system.rs @@ -12,7 +12,4 @@ versioned_type! { pub enum HostNavigateToRequest { V1 => v01::HostNavigateToRequest } pub enum HostNavigateToResponse { V1 } pub enum HostNavigateToError { V1 => v01::HostNavigateToError } - pub enum HostPushNotificationRequest { V1 => v01::HostPushNotificationRequest } - pub enum HostPushNotificationResponse { V1 } - pub enum HostPushNotificationError { V1 => v01::GenericError } } From 2ddcc3b0bf3b7a6f44606a3eb86bd1cde2a82781 Mon Sep 17 00:00:00 2001 From: PG Herveou Date: Fri, 15 May 2026 12:03:43 +0100 Subject: [PATCH 02/20] Apply suggestion from @pgherveou --- docs/rfcs/0020-push-notification-subscriptions.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/rfcs/0020-push-notification-subscriptions.md b/docs/rfcs/0020-push-notification-subscriptions.md index 6e677b2d..02848437 100644 --- a/docs/rfcs/0020-push-notification-subscriptions.md +++ b/docs/rfcs/0020-push-notification-subscriptions.md @@ -12,7 +12,6 @@ pr: Adds two TrUAPI methods, `notifications.push_subscribe` and `notifications.push_unsubscribe`, that let a product declare which Statement Store events the user wants to be woken up for. A subscription is a `(signer, topic)` pair: the host's push backend will deliver a push to the user's device(s) whenever a signed statement matching that pair appears on the store. The product never sees push tokens; the host owns token registration and the integration with the backend described in the push-notifications v2 design. -The RFC also moves the existing `system.push_notification` method onto the new `Notifications` trait so notification-related surface lives in one place. The wire id (`request_id = 4`) is preserved, so this is a Rust-side reorganization only; the wire protocol is unchanged for that method. ## Related RFCs From b85b3d5afb8759593e6ba1f357e91b9ed8aa5fee Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 15 May 2026 15:06:18 +0100 Subject: [PATCH 03/20] update --- .../0020-push-notification-subscriptions.md | 321 ++++-------------- rust/crates/truapi/src/api/notifications.rs | 100 ++++-- rust/crates/truapi/src/v01/notifications.rs | 78 ++++- .../truapi/src/versioned/notifications.rs | 18 +- 4 files changed, 225 insertions(+), 292 deletions(-) diff --git a/docs/rfcs/0020-push-notification-subscriptions.md b/docs/rfcs/0020-push-notification-subscriptions.md index 02848437..f93f6fb4 100644 --- a/docs/rfcs/0020-push-notification-subscriptions.md +++ b/docs/rfcs/0020-push-notification-subscriptions.md @@ -10,50 +10,36 @@ pr: ## Summary -Adds two TrUAPI methods, `notifications.push_subscribe` and `notifications.push_unsubscribe`, that let a product declare which Statement Store events the user wants to be woken up for. A subscription is a `(signer, topic)` pair: the host's push backend will deliver a push to the user's device(s) whenever a signed statement matching that pair appears on the store. The product never sees push tokens; the host owns token registration and the integration with the backend described in the push-notifications v2 design. +Adds four TrUAPI methods — `push_add_rules`, `push_remove_rules`, `push_list_rules`, `push_set_rules` — that mirror the rule-management endpoints of the [v2 push backend spec](https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r16YTVg5Ze). A rule is a `(signer, topic)` pair: the host's push backend delivers a push to the user's device(s) whenever a signed statement matching that pair appears on the Statement Store. The product never sees push tokens. +The method names use `add` / `remove` rather than `subscribe` / `unsubscribe` because the `_subscribe` suffix is reserved for streaming TrUAPI methods (e.g. `statementStore.subscribe`). -## Related RFCs +## References -- RFC 0019 — Scheduled Push Notifications (locally scheduled reminders fired by the host's OS scheduler). +- Push notifications, original (v1, peer-to-peer): https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/SyPN2yV6lx +- Push notifications backend design (v2, backend-mediated): https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r16YTVg5Ze -This RFC is orthogonal: it covers **remote-event-driven** pushes delivered by the push backend, not locally originated ones. +This RFC exposes a TrUAPI-shaped surface over the rule-management API defined in the v2 spec. ## Motivation -The current `push_notification` method only handles **locally originated** notifications: the product itself, while running, asks the host to display one. It has no answer for **remote events the user wants to be woken up for when the product is not running** — the canonical reason push notifications exist on mobile platforms. +The push-notifications v2 design assigns delivery to a host-side push backend that tails the Statement Store, verifies signatures, and delivers pushes only for `(signer, topic)` pairs the user has whitelisted. TrUAPI needs a primitive that lets a product manipulate that whitelist. -The push-notifications v2 design assigns this job to a host-side push backend that tails the Statement Store, verifies signatures, and delivers pushes only for `(signer, topic)` pairs the user has whitelisted. To wire products into that model, TrUAPI needs a primitive that lets a product manipulate the user's whitelist. +### Worked example: festival announcements -Use cases this unlocks: - -- **Festival announcement subscription.** A conference product publishes festival-wide announcements as signed statements on a well-known topic. When the user taps "notify me about announcements," the product calls `push_subscribe({ signer = festival_signer, topic = announcements_topic })`. The user is now woken up for new announcements even with the product closed. -- **Per-room / per-channel chat notifications.** A chat product subscribes the user to specific session topics so that direct messages from a particular contact wake the device. -- **On-chain event reminders.** A wallet product subscribes the user to a topic onto which an indexer publishes signed statements when relevant chain events fire. -- **Unsubscribe symmetry.** Tapping "stop notifying me" must cleanly retract the whitelist entry without forcing the product to re-derive any device-level state. - -Without this primitive, products must either ship their own background process (impractical on web/mobile sandboxes) or expose push tokens to peers (the v1 model the v2 redesign explicitly rejects). - -## Context: end-to-end flow - -The diagram below shows the publish/subscribe shape this RFC enables. A -**publisher app** signs an update and writes it to the Statement Store; a -**subscriber app** has previously declared interest via the new TrUAPI -methods; the host's push backend tails the store, matches the new statement -against the subscriber's rules, and pushes the same signed statement back to -the subscriber app. The publisher does not know its subscribers in advance, -and there is no pairwise key between them; authenticity comes from the -publisher's signature. +A conference product publishes festival-wide announcements as signed statements on a well-known topic. When the user taps "notify me about announcements," the subscriber app calls `push_add_rules({ rules: [{ signer: festival_signer, topic: announcements_topic }] })`. From that point on, the user is woken up for new announcements even with the product closed: ``` Publisher app Subscriber app (organizer side) (attendee side) | ^ | | | | - | | | (1) pushSubscribe({ - | (6) push | | signer: pkPublisher, - | back to | | topic: T_announcements - | caller | | }) + | | | (1) pushAddRules({ + | (6) push | | rules: [{ + | back to | | signer: pkPublisher, + | caller | | topic: T_announcements + | | | }] + | | | }) | | | | | v | +------------------------------------+---+------+ @@ -69,255 +55,96 @@ Publisher app Subscriber app | | Statement Store | | +-----------------------+-----------------------+ | ^ - | (2) compose statement | - | statement = { | - | sender_pk: pkPublisher, | - | topic: T_announcements, | - | data: encode(Announcement), | <-- PLAINTEXT - | sig: Sr25519_sign(skPublisher, body), - | } | - | | + | (2) compose signed statement | |--- (3) statementStore.submit(statement) -+ ``` -On receipt (step 6) the subscriber app: - -``` -stmt = decode(push.data) -require Sr25519_verify(stmt.sig, body, pkPublisher) -announcement = decode(stmt.data) -display(announcement) -``` - -The festival-announcement case is the canonical example; the same shape -applies to any one-to-many notification flow (on-chain event indexers, -broadcast channels, system-status alerts). See -[`push-notifications/v2-broadcast-pubsub.md`](https://github.com/paritytech/sdk-team/blob/main/docs/push-notifications/v2-broadcast-pubsub.md) -in the SDK-team docs for the longer write-up of this design. - ## Detailed Design -### Trait reorganization - -A new trait `Notifications` is added at `rust/crates/truapi/src/api/notifications.rs` and joined into the `TrUApi` super-trait. The existing `push_notification` method moves from `System` onto this trait. Its wire id is preserved (`request_id = 4`), so no wire-protocol break occurs. +### API -```rust -pub trait Notifications: Send + Sync { - #[wire(request_id = 4)] - async fn push_notification( - &self, - cx: &CallContext, - request: HostPushNotificationRequest, - ) -> Result>; +Each TrUAPI method mirrors one backend endpoint: - #[wire(request_id = 134)] - async fn push_subscribe( - &self, - cx: &CallContext, - request: HostPushSubscribeRequest, - ) -> Result>; +| TrUAPI method | Backend endpoint | Purpose | +| ------------------- | -------------------------------- | -------------------------------- | +| `push_add_rules` | `POST /v1/subscriptions/rules` | add one or more rules | +| `push_remove_rules` | `DELETE /v1/subscriptions/rules` | remove one or more rules | +| `push_list_rules` | `GET /v1/subscriptions` | snapshot of currently active set | +| `push_set_rules` | `PUT /v1/subscriptions/rules` | atomic replace of the full set | - #[wire(request_id = 136)] - async fn push_unsubscribe( - &self, - cx: &CallContext, - request: HostPushUnsubscribeRequest, - ) -> Result>; -} +```rust +#[wire(request_id = 134)] +async fn push_add_rules( + &self, cx: &CallContext, request: HostPushAddRulesRequest, +) -> Result>; + +#[wire(request_id = 136)] +async fn push_remove_rules( + &self, cx: &CallContext, request: HostPushRemoveRulesRequest, +) -> Result>; + +#[wire(request_id = 138)] +async fn push_list_rules( + &self, cx: &CallContext, request: HostPushListRulesRequest, +) -> Result>; + +#[wire(request_id = 140)] +async fn push_set_rules( + &self, cx: &CallContext, request: HostPushSetRulesRequest, +) -> Result>; ``` -Request ids `134` and `136` are the next free even ids after the existing highest allocation (`132`). +### Types -### Type definitions (v0.1 wire types) - -New types live in `rust/crates/truapi/src/v01/notifications.rs`, with versioned wrappers in `rust/crates/truapi/src/versioned/notifications.rs`. `Topic` is reused from `v01::statement_store`. +`Topic` is reused from `v01::statement_store`. ```rust -/// 32-byte statement signer (matches `StatementProof::Sr25519::signer` -/// and `StatementProof::Ed25519::signer`). pub type StatementSigner = [u8; 32]; -/// A single (signer, topic) pair the user wants to be woken up for. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +/// A single (signer, topic) rule the user wants to be woken up for. +/// +/// At the host level the effective key is (product, signer, topic): rules +/// are scoped per calling product, so two products can register the same +/// (signer, topic) pair independently and never see each other's rules. pub struct PushSubscriptionRule { pub signer: StatementSigner, - pub topic: Topic, + pub topic: Topic, } -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPushSubscribeRequest { - pub rule: PushSubscriptionRule, +pub struct HostPushAddRulesRequest { pub rules: Vec } +pub struct HostPushRemoveRulesRequest { pub rules: Vec } +pub struct HostPushListRulesRequest; +pub struct HostPushSetRulesRequest { pub rules: Vec } + +pub struct HostPushListRulesResponse { + pub rules: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HostPushSubscribeError { +pub enum HostPushAddRulesError { + /// The user has not granted `DevicePermission::Notifications`. The host + /// SHOULD prompt for the permission lazily on the first such call from + /// a product; if the user dismisses or declines, this variant is + /// returned and no rules are stored. PermissionDenied, - SubscriptionLimitReached, + /// The host could not reach the push backend; no rules were stored. BackendUnavailable, + /// Catch-all. `reason` Unknown { reason: String }, } -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPushUnsubscribeRequest { - pub rule: PushSubscriptionRule, -} - -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HostPushUnsubscribeError { +pub enum HostPushRemoveRulsError { BackendUnavailable, Unknown { reason: String }, } -``` - -Both rule fields are required. There is no wildcard. A product that needs to be pushed for the same signer on three topics issues three `push_subscribe` calls. - -`PermissionDenied` is **not** modelled on the unsubscribe path: removing consent never requires consent. `HostPushSubscribeResponse` and `HostPushUnsubscribeResponse` are unit-shaped at the versioned layer (no v0.1 inner type needed). - -### Behavioural requirements - -1. **Per-product scope.** Each `(product_identity, signer, topic)` triple is independent. Product A subscribing to a rule has no effect on product B's rule set, even for the same `(signer, topic)`. The host MUST NOT leak one product's subscriptions to another, and MUST NOT count one product's pushes against another's quotas. - -2. **The product never sees the push token.** The host registers its own device token(s) with the push backend out-of-band (typically on first launch). `push_subscribe` only attaches a rule to those subscriptions; it never returns or accepts tokens. - -3. **Fan-out across the user's devices.** If the user is logged in on multiple devices that share this host identity, the rule is registered against each device's subscription. A matching statement wakes every eligible device. - -4. **Idempotency.** Registering the same rule twice is a no-op. Unsubscribing an unknown rule is a no-op. Products do not have to track local state to avoid duplicate calls. - -5. **Permission gating.** Both methods are gated by `DevicePermission::Notifications`. The host SHOULD request the permission lazily on the first `push_subscribe` call from a product, mirroring the existing `notifications.push_notification` behaviour. `push_unsubscribe` MUST work regardless of permission state. - -6. **Survives product not running.** Once a rule is registered, the user is pushed for matching statements until the rule is removed or the product is uninstalled. A push that wakes the device for a product that is not running is the expected outcome, not an error. - -7. **Cleanup on product disconnect.** When a product is uninstalled, force-removed, or otherwise reaches end-of-life on the host, the host MUST remove all of that product's subscription rules. Transient disconnects (host restart, network blip) MUST NOT trigger cleanup. Rationale: rules firing for a product the user can no longer reach are user-hostile. - -8. **Failure modes.** The host SHOULD treat `BackendUnavailable` as a soft failure — the product retains the option to retry. Local state on the host MAY queue the rule for delivery once the backend is reachable; if it does, the host MUST still return `BackendUnavailable` on the original call so the product is not lied to about the live state. - -9. **Limits.** A product MAY register up to **64** active rules. A call that would exceed the cap MUST return `SubscriptionLimitReached`. Unsubscribing a rule frees a slot. The cap is per-product so a noisy product cannot crowd out a quieter one. The protocol does not specify a global cap; hosts MAY impose one as a backend-protection measure. - -10. **Rule equality.** Two rules are equal iff both `signer` and `topic` are byte-equal. The host MUST de-duplicate on this equality. - -### Permission model - -No new permission variant is introduced. `DevicePermission::Notifications` (defined in `v01/permissions.rs`) covers both: - -- the existing `notifications.push_notification` (local, host-rendered), -- the new `notifications.push_subscribe` / `unsubscribe` (remote, backend-delivered). -Rationale: from the user's perspective both are "the app may notify me." Splitting the prompt would add friction without a corresponding security gain. From the protocol's perspective the consent target is the user's *attention*, not the specific delivery mechanism. - -### Protocol Integration - -Per the action-derivation rules in `host-api-protocol.md` §"Interface (ABI)", the two new methods produce four actions: - -``` -host_push_subscribe_request: Versioned -host_push_subscribe_response: Versioned> -host_push_unsubscribe_request: Versioned -host_push_unsubscribe_response: Versioned> -``` - -Request ids `134` and `136` are appended after the existing highest allocation (`132`). The new methods are introduced as `Versioned::V1` payloads. The `push_notification` move preserves its existing `request_id = 4` and its existing `Versioned::V1` payload bytes, so the change is purely additive on the wire. - -### Worked example — festival announcements - -``` -Conference product Host (Notifications trait) Push backend Statement Store - | | | | - user taps "Notify me about announcements" | | | - | | | | - |--- notifications.pushSubscribe({ | | | - | signer: festivalSigner, | | | - | topic: announcementsTopic | | | - | }) ------------------------------> | | | - | |--- POST /v1/subscriptions/rules | - | | { rule: (signer, topic) } ----->| | - | |<-- 200 ----------------------------| | - |<-- Ok ---------------------------------| | - | | | - ... later, product is closed ... - | - festival publishes signed announcement -------------->| - | - | |<-- tail / subscribe --| - | | signed statement | - | | | - | | verify sig, match | - | | rule, push | - |<-- APNs/FCM/VoIP -------| | - |<-- user device wakes ------------------| | - | OS shows notification | | - | | - user taps "Stop notifying me" | - |--- notifications.pushUnsubscribe({ | | | - | signer: festivalSigner, | | | - | topic: announcementsTopic | | | - | }) ------------------------------> | | | - | |--- DELETE /v1/subscriptions/rules | - | | { rule: (signer, topic) } ----->| | - | |<-- 200 ----------------------------| | - |<-- Ok ---------------------------------| | -``` - -The product call site (TypeScript, illustrative): - -```ts -import { type Client } from "@parity/truapi"; - -export async function subscribeToFestivalAnnouncements( - truapi: Client, - festivalSigner: Uint8Array, - announcementsTopic: Uint8Array, -): Promise { - const result = await truapi.notifications.pushSubscribe({ - rule: { signer: festivalSigner, topic: announcementsTopic }, - }); - if (result.isErr()) throw result.error; +pub enum HostPushListRulesError { + BackendUnavailable, + Unknown { reason: String }, } -export async function unsubscribeFromFestivalAnnouncements( - truapi: Client, - festivalSigner: Uint8Array, - announcementsTopic: Uint8Array, -): Promise { - const result = await truapi.notifications.pushUnsubscribe({ - rule: { signer: festivalSigner, topic: announcementsTopic }, - }); - if (result.isErr()) throw result.error; +pub enum HostPushSetRulesError { + PermissionDenied, + BackendUnavailable, + Unknown { reason: String }, } ``` - -## Drawbacks - -1. **State the host must persist.** Subscription rules belong to the user (not the product session) and must survive product restarts, app updates, and device reboots. Hosts now own a small but real piece of state per product. - -2. **Backend coupling.** The host implementation depends on a reachable push backend (an instance of the v2 backend design). Hosts that ship without one must return `BackendUnavailable` for every call, which is a degraded but well-defined state. - -3. **Per-product cap of 64 may be too tight for some products.** A chat product whose user has 200 contacts cannot subscribe per-contact. The intended workaround is to subscribe at a coarser topic granularity (e.g., one topic per app-defined channel) or to rely on a single signer covering many topics via wildcards added in a future RFC. - -4. **No introspection in v1.** This RFC deliberately omits a `push_list_subscriptions` method (see "Unresolved Questions"). Products that want to reconcile local UI state with server state must currently track it themselves. - -## Alternatives - -### Keep everything on `System` - -Add the two new methods to `System` and leave `push_notification` where it is. **Rejected**: `System` already mixes handshake, feature detection, and navigation. Adding three notification-related methods would make it the catch-all junk drawer. A dedicated `Notifications` trait keeps the surface coherent and gives a natural home for future additions (e.g. a list endpoint, bulk replace). The move is wire-stable because `push_notification` keeps its existing `request_id = 4`. - -### Bulk `push_set_subscriptions(rules: Vec)` instead of per-rule add/remove - -Mirror the backend's `PUT /v1/subscriptions/rules` atomic-replace endpoint at the TrUAPI surface. **Rejected for v1**: products overwhelmingly drive subscriptions from per-rule UI actions (tap toggle → one rule changes). A bulk replace forces every caller to fetch-modify-send, which is the wrong default. A bulk method can be added later without breaking the per-rule API. - -### Have the product write its own statement-store subscriptions and forward them to push - -Reuse `remote_statement_store_subscribe` (RFC 0008) and have the host detect that a product is interested and forward to push. **Rejected**: conflates two semantically different things (in-product live updates vs. wake-the-device-when-closed) and forces the host to introspect the running product's connection state to decide whether to push. The two have different reliability requirements, payload constraints, and permission stories; they deserve different surfaces. - -### Encode the rule as `(signer, channel)` instead of `(signer, topic)` - -`Statement.channel` is a single optional 32-byte field, while `Statement.topics` is a vector. Matching on `channel` would be a single equality check. **Rejected**: topic-based rules are what the v2 backend design specifies, and `topics` is the field products already use for fan-out via RFC 0008. Mixing `channel` would split the matching strategy for no benefit. - -## Unresolved Questions - -- **List endpoint.** Should we add `push_list_subscriptions() -> Vec` to let a product reconcile local UI state with the host's view (e.g., after a logout/login cycle)? Easy to add later. Skipped for v1 to keep the surface minimal. -- **Atomic bulk replace.** Whether and when to add `push_set_subscriptions(Vec)` mirroring the backend's `PUT /v1/subscriptions/rules`. -- **Signer schemes beyond Sr25519/Ed25519.** `StatementProof` also has `Ecdsa` (33-byte signer) and `OnChain` (32-byte `who`). The `[u8; 32]` shape covers Sr25519, Ed25519, and `OnChain`. ECDSA-signed statements (33-byte signer) cannot be expressed by the current rule type; if this becomes a real use case the rule should evolve into a tagged enum. -- **Topic-granularity wildcards.** A product that wants "all statements from signer S regardless of topic" must subscribe per-topic. Whether to add `MatchAnyTopic` semantics, and how that interacts with per-product caps, is left for a follow-up. -- **Backend selection.** A host MAY have multiple eligible push backends (e.g., one per network). How a rule binds to a specific backend, and what happens when the user's device set spans backends, is not specified here. -- **Rate-limit visibility.** The v2 backend rate-limits at 30 notifications per 60s per `(sender, receiver)` pair. The TrUAPI surface currently makes that invisible to the product. Whether to expose a "dropped due to rate limit" signal back to the product is open. diff --git a/rust/crates/truapi/src/api/notifications.rs b/rust/crates/truapi/src/api/notifications.rs index f7c42640..41dc4617 100644 --- a/rust/crates/truapi/src/api/notifications.rs +++ b/rust/crates/truapi/src/api/notifications.rs @@ -1,15 +1,26 @@ //! Unified [`Notifications`] trait. use crate::versioned::notifications::{ + HostPushAddRulesError, HostPushAddRulesRequest, HostPushAddRulesResponse, + HostPushListRulesError, HostPushListRulesRequest, HostPushListRulesResponse, HostPushNotificationError, HostPushNotificationRequest, HostPushNotificationResponse, - HostPushSubscribeError, HostPushSubscribeRequest, HostPushSubscribeResponse, - HostPushUnsubscribeError, HostPushUnsubscribeRequest, HostPushUnsubscribeResponse, + HostPushRemoveRulesError, HostPushRemoveRulesRequest, HostPushRemoveRulesResponse, + HostPushSetRulesError, HostPushSetRulesRequest, HostPushSetRulesResponse, }; use crate::wire; use crate::{CallContext, CallError}; /// Notification methods: locally-rendered notifications and Statement Store /// subscription rules for backend-delivered pushes. +/// +/// The rule-management methods (`push_add_rules`, `push_remove_rules`, +/// `push_list_rules`, `push_set_rules`) mirror the rule-management endpoints +/// of the push-notifications v2 backend design: +/// +/// - — v2, +/// backend-mediated +/// - — v1, +/// peer-to-peer (historical context) pub trait Notifications: Send + Sync { /// Send a notification to the user, rendered immediately by the host. /// @@ -31,53 +42,94 @@ pub trait Notifications: Send + Sync { request: HostPushNotificationRequest, ) -> Result>; - /// Register a `(signer, topic)` rule so the user is woken up by a push - /// when a signed statement matching the rule appears on the Statement - /// Store. + /// Register one or more `(signer, topic)` rules so the user is woken up + /// by a push when a signed statement matching any registered rule + /// appears on the Statement Store. Mirrors + /// `POST /v1/subscriptions/rules` from the v2 push backend spec. /// /// ```ts /// import { type Client } from "@parity/truapi"; /// - /// export async function subscribeToAnnouncements( + /// export async function addAnnouncementsRules( /// truapi: Client, - /// signer: Uint8Array, - /// topic: Uint8Array, + /// rules: Array<{ signer: Uint8Array; topic: Uint8Array }>, /// ): Promise { - /// const result = await truapi.notifications.pushSubscribe({ - /// rule: { signer, topic }, - /// }); + /// const result = await truapi.notifications.pushAddRules({ rules }); /// /// if (result.isErr()) throw result.error; /// } /// ``` #[wire(request_id = 134)] - async fn push_subscribe( + async fn push_add_rules( &self, cx: &CallContext, - request: HostPushSubscribeRequest, - ) -> Result>; + request: HostPushAddRulesRequest, + ) -> Result>; - /// Remove a previously registered subscription rule. + /// Remove one or more previously registered subscription rules. Mirrors + /// `DELETE /v1/subscriptions/rules` from the v2 push backend spec. /// /// ```ts /// import { type Client } from "@parity/truapi"; /// - /// export async function unsubscribeFromAnnouncements( + /// export async function removeAnnouncementsRules( /// truapi: Client, - /// signer: Uint8Array, - /// topic: Uint8Array, + /// rules: Array<{ signer: Uint8Array; topic: Uint8Array }>, /// ): Promise { - /// const result = await truapi.notifications.pushUnsubscribe({ - /// rule: { signer, topic }, - /// }); + /// const result = await truapi.notifications.pushRemoveRules({ rules }); /// /// if (result.isErr()) throw result.error; /// } /// ``` #[wire(request_id = 136)] - async fn push_unsubscribe( + async fn push_remove_rules( + &self, + cx: &CallContext, + request: HostPushRemoveRulesRequest, + ) -> Result>; + + /// List the calling product's currently registered subscription rules. + /// Useful for reconciling local UI state with what the host believes is + /// active (e.g. after logout/login). Mirrors + /// `GET /v1/subscriptions` from the v2 push backend spec. + /// + /// ```ts + /// import { type Client } from "@parity/truapi"; + /// + /// export async function listRules(truapi: Client) { + /// const result = await truapi.notifications.pushListRules({}); + /// if (result.isErr()) throw result.error; + /// return result.value.rules; + /// } + /// ``` + #[wire(request_id = 138)] + async fn push_list_rules( + &self, + cx: &CallContext, + request: HostPushListRulesRequest, + ) -> Result>; + + /// Atomically replace the calling product's entire rule set with the + /// supplied vector. After a successful call, the product's active rules + /// are exactly `rules`. Mirrors `PUT /v1/subscriptions/rules` from the + /// v2 push backend spec. + /// + /// ```ts + /// import { type Client } from "@parity/truapi"; + /// + /// export async function setRules( + /// truapi: Client, + /// rules: Array<{ signer: Uint8Array; topic: Uint8Array }>, + /// ): Promise { + /// const result = await truapi.notifications.pushSetRules({ rules }); + /// + /// if (result.isErr()) throw result.error; + /// } + /// ``` + #[wire(request_id = 140)] + async fn push_set_rules( &self, cx: &CallContext, - request: HostPushUnsubscribeRequest, - ) -> Result>; + request: HostPushSetRulesRequest, + ) -> Result>; } diff --git a/rust/crates/truapi/src/v01/notifications.rs b/rust/crates/truapi/src/v01/notifications.rs index a9f59bf7..550cf9ed 100644 --- a/rust/crates/truapi/src/v01/notifications.rs +++ b/rust/crates/truapi/src/v01/notifications.rs @@ -20,11 +20,15 @@ pub struct HostPushNotificationRequest { /// [`StatementProof::Ed25519`]: super::StatementProof::Ed25519 pub type StatementSigner = [u8; 32]; -/// A single `(signer, topic)` pair the user wants to be woken up for. +/// A single `(signer, topic)` rule the user wants to be woken up for. /// /// The host's push backend delivers a push to the user's device(s) whenever /// a signed statement appears on the Statement Store whose signer equals /// `signer` and whose `topics` list contains `topic`. +/// +/// At the host level the effective key is `(product, signer, topic)`: rules +/// are scoped per calling product, so two products can register the same +/// `(signer, topic)` pair independently and never see each other's rules. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct PushSubscriptionRule { /// Signer the matching statement must be signed by. @@ -33,20 +37,19 @@ pub struct PushSubscriptionRule { pub topic: Topic, } -/// Request to register a single subscription rule with the host. +/// Request to register one or more subscription rules with the host. Each +/// rule is added independently; existing rules are not touched. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPushSubscribeRequest { - /// Rule to register. - pub rule: PushSubscriptionRule, +pub struct HostPushAddRulesRequest { + /// Rules to register. + pub rules: Vec, } -/// Failure modes for [`HostPushSubscribeRequest`]. +/// Failure modes for [`HostPushAddRulesRequest`]. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HostPushSubscribeError { +pub enum HostPushAddRulesError { /// The user has not granted `DevicePermission::Notifications`. PermissionDenied, - /// The product has reached the maximum number of active rules. - SubscriptionLimitReached, /// The host's push backend is currently unreachable; the rule was not /// registered. The product MAY retry later. BackendUnavailable, @@ -54,19 +57,64 @@ pub enum HostPushSubscribeError { Unknown { reason: String }, } -/// Request to remove a previously registered subscription rule. +/// Request to remove one or more previously registered subscription rules. +/// Rules not currently active are ignored. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPushUnsubscribeRequest { - /// Rule to remove. - pub rule: PushSubscriptionRule, +pub struct HostPushRemoveRulesRequest { + /// Rules to remove. + pub rules: Vec, } -/// Failure modes for [`HostPushUnsubscribeRequest`]. +/// Failure modes for [`HostPushRemoveRulesRequest`]. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HostPushUnsubscribeError { +pub enum HostPushRemoveRulesError { /// The host's push backend is currently unreachable; the rule may still /// be active. The product MAY retry later. BackendUnavailable, /// Catch-all. Unknown { reason: String }, } + +/// Request to list the calling product's currently registered subscription +/// rules. Has no fields; the host scopes results by the calling product +/// identity. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPushListRulesRequest; + +/// Snapshot of the calling product's currently registered subscription rules. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPushListRulesResponse { + /// Currently registered rules for this product, in unspecified order. + pub rules: Vec, +} + +/// Failure modes for [`HostPushListRulesRequest`]. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostPushListRulesError { + /// The host's push backend is currently unreachable. The product MAY + /// retry later. + BackendUnavailable, + /// Catch-all. + Unknown { reason: String }, +} + +/// Atomic replace of the calling product's full rule set with the supplied +/// vector. After a successful call, the product's active rules are exactly +/// `rules`. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPushSetRulesRequest { + /// Rules that should be active for this product after the call. + pub rules: Vec, +} + +/// Failure modes for [`HostPushSetRulesRequest`]. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostPushSetRulesError { + /// The user has not granted `DevicePermission::Notifications`. + PermissionDenied, + /// The host's push backend is currently unreachable; no change was + /// applied. The product MAY retry later. + BackendUnavailable, + /// Catch-all. + Unknown { reason: String }, +} diff --git a/rust/crates/truapi/src/versioned/notifications.rs b/rust/crates/truapi/src/versioned/notifications.rs index 5cf8075b..17e19733 100644 --- a/rust/crates/truapi/src/versioned/notifications.rs +++ b/rust/crates/truapi/src/versioned/notifications.rs @@ -6,10 +6,16 @@ versioned_type! { pub enum HostPushNotificationRequest { V1 => v01::HostPushNotificationRequest } pub enum HostPushNotificationResponse { V1 } pub enum HostPushNotificationError { V1 => v01::GenericError } - pub enum HostPushSubscribeRequest { V1 => v01::HostPushSubscribeRequest } - pub enum HostPushSubscribeResponse { V1 } - pub enum HostPushSubscribeError { V1 => v01::HostPushSubscribeError } - pub enum HostPushUnsubscribeRequest { V1 => v01::HostPushUnsubscribeRequest } - pub enum HostPushUnsubscribeResponse { V1 } - pub enum HostPushUnsubscribeError { V1 => v01::HostPushUnsubscribeError } + pub enum HostPushAddRulesRequest { V1 => v01::HostPushAddRulesRequest } + pub enum HostPushAddRulesResponse { V1 } + pub enum HostPushAddRulesError { V1 => v01::HostPushAddRulesError } + pub enum HostPushRemoveRulesRequest { V1 => v01::HostPushRemoveRulesRequest } + pub enum HostPushRemoveRulesResponse { V1 } + pub enum HostPushRemoveRulesError { V1 => v01::HostPushRemoveRulesError } + pub enum HostPushListRulesRequest { V1 => v01::HostPushListRulesRequest } + pub enum HostPushListRulesResponse { V1 => v01::HostPushListRulesResponse } + pub enum HostPushListRulesError { V1 => v01::HostPushListRulesError } + pub enum HostPushSetRulesRequest { V1 => v01::HostPushSetRulesRequest } + pub enum HostPushSetRulesResponse { V1 } + pub enum HostPushSetRulesError { V1 => v01::HostPushSetRulesError } } From 8788defb37072ed4e2438a5401d2011493a10612 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Fri, 15 May 2026 16:36:23 +0200 Subject: [PATCH 04/20] fix: unit struct and doc examples for codegen compatibility - Change HostPushListRulesRequest from unit struct to empty braced struct so the codegen can handle it. - Rewrite rule-method TS examples to use literal default requests instead of variable references, matching the playground codegen's expectation of extractable JSON. --- rust/crates/truapi/src/api/notifications.rs | 34 +++++++++++++++------ rust/crates/truapi/src/v01/notifications.rs | 2 +- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/rust/crates/truapi/src/api/notifications.rs b/rust/crates/truapi/src/api/notifications.rs index 41dc4617..a96a39cf 100644 --- a/rust/crates/truapi/src/api/notifications.rs +++ b/rust/crates/truapi/src/api/notifications.rs @@ -52,9 +52,15 @@ pub trait Notifications: Send + Sync { /// /// export async function addAnnouncementsRules( /// truapi: Client, - /// rules: Array<{ signer: Uint8Array; topic: Uint8Array }>, /// ): Promise { - /// const result = await truapi.notifications.pushAddRules({ rules }); + /// const result = await truapi.notifications.pushAddRules({ + /// rules: [ + /// { + /// signer: "0x0000000000000000000000000000000000000000000000000000000000000000", + /// topic: "0x00", + /// }, + /// ], + /// }); /// /// if (result.isErr()) throw result.error; /// } @@ -74,9 +80,15 @@ pub trait Notifications: Send + Sync { /// /// export async function removeAnnouncementsRules( /// truapi: Client, - /// rules: Array<{ signer: Uint8Array; topic: Uint8Array }>, /// ): Promise { - /// const result = await truapi.notifications.pushRemoveRules({ rules }); + /// const result = await truapi.notifications.pushRemoveRules({ + /// rules: [ + /// { + /// signer: "0x0000000000000000000000000000000000000000000000000000000000000000", + /// topic: "0x00", + /// }, + /// ], + /// }); /// /// if (result.isErr()) throw result.error; /// } @@ -117,11 +129,15 @@ pub trait Notifications: Send + Sync { /// ```ts /// import { type Client } from "@parity/truapi"; /// - /// export async function setRules( - /// truapi: Client, - /// rules: Array<{ signer: Uint8Array; topic: Uint8Array }>, - /// ): Promise { - /// const result = await truapi.notifications.pushSetRules({ rules }); + /// export async function setRules(truapi: Client): Promise { + /// const result = await truapi.notifications.pushSetRules({ + /// rules: [ + /// { + /// signer: "0x0000000000000000000000000000000000000000000000000000000000000000", + /// topic: "0x00", + /// }, + /// ], + /// }); /// /// if (result.isErr()) throw result.error; /// } diff --git a/rust/crates/truapi/src/v01/notifications.rs b/rust/crates/truapi/src/v01/notifications.rs index 550cf9ed..9bacca0b 100644 --- a/rust/crates/truapi/src/v01/notifications.rs +++ b/rust/crates/truapi/src/v01/notifications.rs @@ -79,7 +79,7 @@ pub enum HostPushRemoveRulesError { /// rules. Has no fields; the host scopes results by the calling product /// identity. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPushListRulesRequest; +pub struct HostPushListRulesRequest {} /// Snapshot of the calling product's currently registered subscription rules. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] From 7290dfa4ce05e4752a730ad2678cc235c622bb25 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 18 May 2026 12:03:29 +0200 Subject: [PATCH 05/20] update --- .../0020-push-notification-subscriptions.md | 35 +++++++++---------- rust/crates/truapi/src/api/notifications.rs | 30 +++++----------- rust/crates/truapi/src/v01/notifications.rs | 25 +++++-------- 3 files changed, 33 insertions(+), 57 deletions(-) diff --git a/docs/rfcs/0020-push-notification-subscriptions.md b/docs/rfcs/0020-push-notification-subscriptions.md index f93f6fb4..60ce381b 100644 --- a/docs/rfcs/0020-push-notification-subscriptions.md +++ b/docs/rfcs/0020-push-notification-subscriptions.md @@ -10,7 +10,7 @@ pr: ## Summary -Adds four TrUAPI methods — `push_add_rules`, `push_remove_rules`, `push_list_rules`, `push_set_rules` — that mirror the rule-management endpoints of the [v2 push backend spec](https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r16YTVg5Ze). A rule is a `(signer, topic)` pair: the host's push backend delivers a push to the user's device(s) whenever a signed statement matching that pair appears on the Statement Store. The product never sees push tokens. +Adds four TrUAPI methods — `push_add_rules`, `push_remove_rules`, `push_list_rules`, `push_set_rules` — that mirror the rule-management endpoints of the [v2 push backend spec](https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r16YTVg5Ze). From the product's point of view a rule is just a `topic`: the product does not specify the signer, the host injects it when forwarding the rule to its push backend. The backend then delivers a push to the user's device(s) whenever a signed statement matching the resulting `(signer, topic)` pair appears on the Statement Store. The product never sees push tokens. The method names use `add` / `remove` rather than `subscribe` / `unsubscribe` because the `_subscribe` suffix is reserved for streaming TrUAPI methods (e.g. `statementStore.subscribe`). @@ -23,11 +23,11 @@ This RFC exposes a TrUAPI-shaped surface over the rule-management API defined in ## Motivation -The push-notifications v2 design assigns delivery to a host-side push backend that tails the Statement Store, verifies signatures, and delivers pushes only for `(signer, topic)` pairs the user has whitelisted. TrUAPI needs a primitive that lets a product manipulate that whitelist. +The push-notifications v2 design assigns delivery to a host-side push backend that tails the Statement Store, verifies signatures, and delivers pushes only for `(signer, topic)` pairs the user has whitelisted. TrUAPI needs a primitive that lets a product manipulate that whitelist. The product supplies the `topic`; the host fills in the `signer` from the calling product's identity before forwarding to the backend. ### Worked example: festival announcements -A conference product publishes festival-wide announcements as signed statements on a well-known topic. When the user taps "notify me about announcements," the subscriber app calls `push_add_rules({ rules: [{ signer: festival_signer, topic: announcements_topic }] })`. From that point on, the user is woken up for new announcements even with the product closed: +A conference product publishes festival-wide announcements as signed statements on a well-known topic, signed with the product's own identity key (`pkProduct`). When the user taps "notify me about announcements," the subscriber app calls `push_add_rules({ rules: [{ topic: announcements_topic }] })`. The host injects `pkProduct` as the signer when relaying to the backend, so from that point on the user is woken up for new announcements even with the product closed: ``` Publisher app Subscriber app @@ -36,17 +36,17 @@ Publisher app Subscriber app | | | | | | (1) pushAddRules({ | (6) push | | rules: [{ - | back to | | signer: pkPublisher, - | caller | | topic: T_announcements - | | | }] + | back to | | topic: T_announcements + | caller | | }] | | | }) | | | | | v | +------------------------------------+---+------+ - | | Push backend | - | | stores rule: | - | | (pkPublisher, T_announcements) | - | | -> this subscriber app | + | | Host | + | | injects signer = pkProduct, then forwards | + | | to push backend: | + | | rule (pkProduct, T_announcements) | + | | -> this subscriber app | | +-----------------------+-----------------------+ | ^ | | (4) tail / match rule @@ -99,16 +99,15 @@ async fn push_set_rules( `Topic` is reused from `v01::statement_store`. ```rust -pub type StatementSigner = [u8; 32]; - -/// A single (signer, topic) rule the user wants to be woken up for. +/// A single topic the user wants to be woken up for. /// -/// At the host level the effective key is (product, signer, topic): rules -/// are scoped per calling product, so two products can register the same -/// (signer, topic) pair independently and never see each other's rules. +/// At the host level the effective key is (product, topic): rules are scoped +/// per calling product, so two products can register the same topic +/// independently and never see each other's rules. The product does not +/// specify the signer; the host injects it when forwarding the rule to the +/// push backend. pub struct PushSubscriptionRule { - pub signer: StatementSigner, - pub topic: Topic, + pub topic: Topic, } pub struct HostPushAddRulesRequest { pub rules: Vec } diff --git a/rust/crates/truapi/src/api/notifications.rs b/rust/crates/truapi/src/api/notifications.rs index a96a39cf..8e50c059 100644 --- a/rust/crates/truapi/src/api/notifications.rs +++ b/rust/crates/truapi/src/api/notifications.rs @@ -42,10 +42,11 @@ pub trait Notifications: Send + Sync { request: HostPushNotificationRequest, ) -> Result>; - /// Register one or more `(signer, topic)` rules so the user is woken up - /// by a push when a signed statement matching any registered rule - /// appears on the Statement Store. Mirrors - /// `POST /v1/subscriptions/rules` from the v2 push backend spec. + /// Register one or more topic rules so the user is woken up by a push + /// when a signed statement matching any registered rule appears on the + /// Statement Store. Mirrors `POST /v1/subscriptions/rules` from the v2 + /// push backend spec. The signer is injected by the host (based on the + /// calling product's identity) when relaying the rule to the backend. /// /// ```ts /// import { type Client } from "@parity/truapi"; @@ -54,12 +55,7 @@ pub trait Notifications: Send + Sync { /// truapi: Client, /// ): Promise { /// const result = await truapi.notifications.pushAddRules({ - /// rules: [ - /// { - /// signer: "0x0000000000000000000000000000000000000000000000000000000000000000", - /// topic: "0x00", - /// }, - /// ], + /// rules: [{ topic: "0x00" }], /// }); /// /// if (result.isErr()) throw result.error; @@ -82,12 +78,7 @@ pub trait Notifications: Send + Sync { /// truapi: Client, /// ): Promise { /// const result = await truapi.notifications.pushRemoveRules({ - /// rules: [ - /// { - /// signer: "0x0000000000000000000000000000000000000000000000000000000000000000", - /// topic: "0x00", - /// }, - /// ], + /// rules: [{ topic: "0x00" }], /// }); /// /// if (result.isErr()) throw result.error; @@ -131,12 +122,7 @@ pub trait Notifications: Send + Sync { /// /// export async function setRules(truapi: Client): Promise { /// const result = await truapi.notifications.pushSetRules({ - /// rules: [ - /// { - /// signer: "0x0000000000000000000000000000000000000000000000000000000000000000", - /// topic: "0x00", - /// }, - /// ], + /// rules: [{ topic: "0x00" }], /// }); /// /// if (result.isErr()) throw result.error; diff --git a/rust/crates/truapi/src/v01/notifications.rs b/rust/crates/truapi/src/v01/notifications.rs index 9bacca0b..066f57ad 100644 --- a/rust/crates/truapi/src/v01/notifications.rs +++ b/rust/crates/truapi/src/v01/notifications.rs @@ -11,28 +11,19 @@ pub struct HostPushNotificationRequest { pub deeplink: Option, } -/// 32-byte statement signer key. -/// -/// Matches the `signer` field of [`StatementProof::Sr25519`] and -/// [`StatementProof::Ed25519`]. -/// -/// [`StatementProof::Sr25519`]: super::StatementProof::Sr25519 -/// [`StatementProof::Ed25519`]: super::StatementProof::Ed25519 -pub type StatementSigner = [u8; 32]; - -/// A single `(signer, topic)` rule the user wants to be woken up for. +/// A single topic the user wants to be woken up for. /// /// The host's push backend delivers a push to the user's device(s) whenever -/// a signed statement appears on the Statement Store whose signer equals -/// `signer` and whose `topics` list contains `topic`. +/// a signed statement appears on the Statement Store whose signer matches +/// the calling product's identity and whose `topics` list contains `topic`. +/// The product does not specify the signer; the host injects it when +/// forwarding the rule to the push backend. /// -/// At the host level the effective key is `(product, signer, topic)`: rules -/// are scoped per calling product, so two products can register the same -/// `(signer, topic)` pair independently and never see each other's rules. +/// At the host level the effective key is `(product, topic)`: rules are +/// scoped per calling product, so two products can register the same topic +/// independently and never see each other's rules. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct PushSubscriptionRule { - /// Signer the matching statement must be signed by. - pub signer: StatementSigner, /// Topic the matching statement must carry in its `topics` list. pub topic: Topic, } From 6b26442d50fe09e057cf4e774db9fddab797156b Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 18 May 2026 12:17:04 +0200 Subject: [PATCH 06/20] unnest --- .../0020-push-notification-subscriptions.md | 29 ++++------- rust/crates/truapi/src/api/notifications.rs | 30 ++++++------ rust/crates/truapi/src/v01/notifications.rs | 49 +++++++------------ 3 files changed, 44 insertions(+), 64 deletions(-) diff --git a/docs/rfcs/0020-push-notification-subscriptions.md b/docs/rfcs/0020-push-notification-subscriptions.md index 60ce381b..5856f77a 100644 --- a/docs/rfcs/0020-push-notification-subscriptions.md +++ b/docs/rfcs/0020-push-notification-subscriptions.md @@ -27,7 +27,7 @@ The push-notifications v2 design assigns delivery to a host-side push backend th ### Worked example: festival announcements -A conference product publishes festival-wide announcements as signed statements on a well-known topic, signed with the product's own identity key (`pkProduct`). When the user taps "notify me about announcements," the subscriber app calls `push_add_rules({ rules: [{ topic: announcements_topic }] })`. The host injects `pkProduct` as the signer when relaying to the backend, so from that point on the user is woken up for new announcements even with the product closed: +A conference product publishes festival-wide announcements as signed statements on a well-known topic, signed with the product's own identity key (`pkProduct`). When the user taps "notify me about announcements," the subscriber app calls `push_add_rules({ topics: [announcements_topic] })`. The host injects `pkProduct` as the signer when relaying to the backend, so from that point on the user is woken up for new announcements even with the product closed: ``` Publisher app Subscriber app @@ -35,9 +35,9 @@ Publisher app Subscriber app | ^ | | | | | | | (1) pushAddRules({ - | (6) push | | rules: [{ - | back to | | topic: T_announcements - | caller | | }] + | (6) push | | topics: [ + | back to | | T_announcements + | caller | | ] | | | }) | | | | | v @@ -98,25 +98,16 @@ async fn push_set_rules( `Topic` is reused from `v01::statement_store`. -```rust -/// A single topic the user wants to be woken up for. -/// -/// At the host level the effective key is (product, topic): rules are scoped -/// per calling product, so two products can register the same topic -/// independently and never see each other's rules. The product does not -/// specify the signer; the host injects it when forwarding the rule to the -/// push backend. -pub struct PushSubscriptionRule { - pub topic: Topic, -} +A rule is just a `Topic`. At the host level the effective key is `(product, topic)`: rules are scoped per calling product, so two products can register the same topic independently and never see each other's rules. The product does not specify the signer; the host injects it when forwarding the rule to the push backend. -pub struct HostPushAddRulesRequest { pub rules: Vec } -pub struct HostPushRemoveRulesRequest { pub rules: Vec } +```rust +pub struct HostPushAddRulesRequest { pub topics: Vec } +pub struct HostPushRemoveRulesRequest { pub topics: Vec } pub struct HostPushListRulesRequest; -pub struct HostPushSetRulesRequest { pub rules: Vec } +pub struct HostPushSetRulesRequest { pub topics: Vec } pub struct HostPushListRulesResponse { - pub rules: Vec, + pub topics: Vec, } pub enum HostPushAddRulesError { diff --git a/rust/crates/truapi/src/api/notifications.rs b/rust/crates/truapi/src/api/notifications.rs index 8e50c059..19b71eaf 100644 --- a/rust/crates/truapi/src/api/notifications.rs +++ b/rust/crates/truapi/src/api/notifications.rs @@ -42,8 +42,8 @@ pub trait Notifications: Send + Sync { request: HostPushNotificationRequest, ) -> Result>; - /// Register one or more topic rules so the user is woken up by a push - /// when a signed statement matching any registered rule appears on the + /// Register one or more topics so the user is woken up by a push when a + /// signed statement matching any registered topic appears on the /// Statement Store. Mirrors `POST /v1/subscriptions/rules` from the v2 /// push backend spec. The signer is injected by the host (based on the /// calling product's identity) when relaying the rule to the backend. @@ -55,7 +55,7 @@ pub trait Notifications: Send + Sync { /// truapi: Client, /// ): Promise { /// const result = await truapi.notifications.pushAddRules({ - /// rules: [{ topic: "0x00" }], + /// topics: ["0x00"], /// }); /// /// if (result.isErr()) throw result.error; @@ -68,7 +68,7 @@ pub trait Notifications: Send + Sync { request: HostPushAddRulesRequest, ) -> Result>; - /// Remove one or more previously registered subscription rules. Mirrors + /// Remove one or more previously registered topics. Mirrors /// `DELETE /v1/subscriptions/rules` from the v2 push backend spec. /// /// ```ts @@ -78,7 +78,7 @@ pub trait Notifications: Send + Sync { /// truapi: Client, /// ): Promise { /// const result = await truapi.notifications.pushRemoveRules({ - /// rules: [{ topic: "0x00" }], + /// topics: ["0x00"], /// }); /// /// if (result.isErr()) throw result.error; @@ -91,10 +91,10 @@ pub trait Notifications: Send + Sync { request: HostPushRemoveRulesRequest, ) -> Result>; - /// List the calling product's currently registered subscription rules. - /// Useful for reconciling local UI state with what the host believes is - /// active (e.g. after logout/login). Mirrors - /// `GET /v1/subscriptions` from the v2 push backend spec. + /// List the calling product's currently registered topics. Useful for + /// reconciling local UI state with what the host believes is active + /// (e.g. after logout/login). Mirrors `GET /v1/subscriptions` from the + /// v2 push backend spec. /// /// ```ts /// import { type Client } from "@parity/truapi"; @@ -102,7 +102,7 @@ pub trait Notifications: Send + Sync { /// export async function listRules(truapi: Client) { /// const result = await truapi.notifications.pushListRules({}); /// if (result.isErr()) throw result.error; - /// return result.value.rules; + /// return result.value.topics; /// } /// ``` #[wire(request_id = 138)] @@ -112,17 +112,17 @@ pub trait Notifications: Send + Sync { request: HostPushListRulesRequest, ) -> Result>; - /// Atomically replace the calling product's entire rule set with the - /// supplied vector. After a successful call, the product's active rules - /// are exactly `rules`. Mirrors `PUT /v1/subscriptions/rules` from the - /// v2 push backend spec. + /// Atomically replace the calling product's entire topic set with the + /// supplied vector. After a successful call, the product's active + /// topics are exactly `topics`. Mirrors `PUT /v1/subscriptions/rules` + /// from the v2 push backend spec. /// /// ```ts /// import { type Client } from "@parity/truapi"; /// /// export async function setRules(truapi: Client): Promise { /// const result = await truapi.notifications.pushSetRules({ - /// rules: [{ topic: "0x00" }], + /// topics: ["0x00"], /// }); /// /// if (result.isErr()) throw result.error; diff --git a/rust/crates/truapi/src/v01/notifications.rs b/rust/crates/truapi/src/v01/notifications.rs index 066f57ad..7da52395 100644 --- a/rust/crates/truapi/src/v01/notifications.rs +++ b/rust/crates/truapi/src/v01/notifications.rs @@ -11,29 +11,18 @@ pub struct HostPushNotificationRequest { pub deeplink: Option, } -/// A single topic the user wants to be woken up for. -/// -/// The host's push backend delivers a push to the user's device(s) whenever -/// a signed statement appears on the Statement Store whose signer matches -/// the calling product's identity and whose `topics` list contains `topic`. -/// The product does not specify the signer; the host injects it when -/// forwarding the rule to the push backend. +/// Request to register one or more topics the user wants to be woken up for. +/// Each topic is added independently; existing rules are not touched. /// /// At the host level the effective key is `(product, topic)`: rules are /// scoped per calling product, so two products can register the same topic -/// independently and never see each other's rules. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct PushSubscriptionRule { - /// Topic the matching statement must carry in its `topics` list. - pub topic: Topic, -} - -/// Request to register one or more subscription rules with the host. Each -/// rule is added independently; existing rules are not touched. +/// independently and never see each other's rules. The product does not +/// specify the signer; the host injects it when forwarding the rule to the +/// push backend. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPushAddRulesRequest { - /// Rules to register. - pub rules: Vec, + /// Topics to register. + pub topics: Vec, } /// Failure modes for [`HostPushAddRulesRequest`]. @@ -48,12 +37,12 @@ pub enum HostPushAddRulesError { Unknown { reason: String }, } -/// Request to remove one or more previously registered subscription rules. -/// Rules not currently active are ignored. +/// Request to remove one or more previously registered topics. +/// Topics not currently active are ignored. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPushRemoveRulesRequest { - /// Rules to remove. - pub rules: Vec, + /// Topics to remove. + pub topics: Vec, } /// Failure modes for [`HostPushRemoveRulesRequest`]. @@ -72,11 +61,11 @@ pub enum HostPushRemoveRulesError { #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPushListRulesRequest {} -/// Snapshot of the calling product's currently registered subscription rules. +/// Snapshot of the calling product's currently registered topics. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPushListRulesResponse { - /// Currently registered rules for this product, in unspecified order. - pub rules: Vec, + /// Currently registered topics for this product, in unspecified order. + pub topics: Vec, } /// Failure modes for [`HostPushListRulesRequest`]. @@ -89,13 +78,13 @@ pub enum HostPushListRulesError { Unknown { reason: String }, } -/// Atomic replace of the calling product's full rule set with the supplied -/// vector. After a successful call, the product's active rules are exactly -/// `rules`. +/// Atomic replace of the calling product's full topic set with the supplied +/// vector. After a successful call, the product's active topics are exactly +/// `topics`. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPushSetRulesRequest { - /// Rules that should be active for this product after the call. - pub rules: Vec, + /// Topics that should be active for this product after the call. + pub topics: Vec, } /// Failure modes for [`HostPushSetRulesRequest`]. From a2497d55c7577f59753521441928672d2c0e209b Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 18 May 2026 12:20:41 +0200 Subject: [PATCH 07/20] simplify diagram --- docs/rfcs/0020-push-notification-subscriptions.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/rfcs/0020-push-notification-subscriptions.md b/docs/rfcs/0020-push-notification-subscriptions.md index 5856f77a..b6ba09ad 100644 --- a/docs/rfcs/0020-push-notification-subscriptions.md +++ b/docs/rfcs/0020-push-notification-subscriptions.md @@ -34,12 +34,9 @@ Publisher app Subscriber app (organizer side) (attendee side) | ^ | | | | - | | | (1) pushAddRules({ - | (6) push | | topics: [ - | back to | | T_announcements - | caller | | ] - | | | }) - | | | + | (6) push | | (1) pushAddRules({ topics: [ T_announcements ] }) + | back to | | + | caller | | | | v | +------------------------------------+---+------+ | | Host | From 74daaae87fd483fc05927b2153fef97bdb14f74a Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Tue, 19 May 2026 18:26:09 +0200 Subject: [PATCH 08/20] Simplify notification doc examples to use result.match() pattern --- rust/crates/truapi/src/api/notifications.rs | 64 +++++++++------------ 1 file changed, 26 insertions(+), 38 deletions(-) diff --git a/rust/crates/truapi/src/api/notifications.rs b/rust/crates/truapi/src/api/notifications.rs index cf5fc8c8..4068b87a 100644 --- a/rust/crates/truapi/src/api/notifications.rs +++ b/rust/crates/truapi/src/api/notifications.rs @@ -80,17 +80,13 @@ pub trait Notifications: Send + Sync { /// calling product's identity) when relaying the rule to the backend. /// /// ```ts - /// import { type Client } from "@parity/truapi"; - /// - /// export async function addAnnouncementsRules( - /// truapi: Client, - /// ): Promise { - /// const result = await truapi.notifications.pushAddRules({ - /// topics: ["0x00"], - /// }); - /// - /// if (result.isErr()) throw result.error; - /// } + /// const result = await truapi.notifications.pushAddRules({ + /// topics: ["0x00"], + /// }); + /// result.match( + /// () => console.log("ok"), + /// (error) => console.error(error), + /// ); /// ``` #[wire(request_id = 164)] async fn push_add_rules( @@ -103,17 +99,13 @@ pub trait Notifications: Send + Sync { /// `DELETE /v1/subscriptions/rules` from the v2 push backend spec. /// /// ```ts - /// import { type Client } from "@parity/truapi"; - /// - /// export async function removeAnnouncementsRules( - /// truapi: Client, - /// ): Promise { - /// const result = await truapi.notifications.pushRemoveRules({ - /// topics: ["0x00"], - /// }); - /// - /// if (result.isErr()) throw result.error; - /// } + /// const result = await truapi.notifications.pushRemoveRules({ + /// topics: ["0x00"], + /// }); + /// result.match( + /// () => console.log("ok"), + /// (error) => console.error(error), + /// ); /// ``` #[wire(request_id = 166)] async fn push_remove_rules( @@ -128,13 +120,11 @@ pub trait Notifications: Send + Sync { /// v2 push backend spec. /// /// ```ts - /// import { type Client } from "@parity/truapi"; - /// - /// export async function listRules(truapi: Client) { - /// const result = await truapi.notifications.pushListRules({}); - /// if (result.isErr()) throw result.error; - /// return result.value.topics; - /// } + /// const result = await truapi.notifications.pushListRules({}); + /// result.match( + /// (value) => console.log(value.topics), + /// (error) => console.error(error), + /// ); /// ``` #[wire(request_id = 168)] async fn push_list_rules( @@ -149,15 +139,13 @@ pub trait Notifications: Send + Sync { /// from the v2 push backend spec. /// /// ```ts - /// import { type Client } from "@parity/truapi"; - /// - /// export async function setRules(truapi: Client): Promise { - /// const result = await truapi.notifications.pushSetRules({ - /// topics: ["0x00"], - /// }); - /// - /// if (result.isErr()) throw result.error; - /// } + /// const result = await truapi.notifications.pushSetRules({ + /// topics: ["0x00"], + /// }); + /// result.match( + /// () => console.log("ok"), + /// (error) => console.error(error), + /// ); /// ``` #[wire(request_id = 170)] async fn push_set_rules( From 4602d4d74365f4e7a6e6ce2e03d4156e3c57e9bd Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Wed, 20 May 2026 06:28:10 +0200 Subject: [PATCH 09/20] Rename BackendUnavailable to NotificationSystemUnavailable(String) and add optional signer to rule requests Address review feedback: - Replace BackendUnavailable with NotificationSystemUnavailable(String) so the error variant does not leak implementation details; the string payload lets hosts describe why the system is unavailable. - Add `signer: Option` to HostPushAddRulesRequest, HostPushRemoveRulesRequest, and HostPushSetRulesRequest so a product can subscribe to statements from a different product's identity. --- .../0020-push-notification-subscriptions.md | 18 ++++---- rust/crates/truapi/src/v01/notifications.rs | 42 +++++++++++-------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/docs/rfcs/0020-push-notification-subscriptions.md b/docs/rfcs/0020-push-notification-subscriptions.md index 7be05653..dcf4d418 100644 --- a/docs/rfcs/0020-push-notification-subscriptions.md +++ b/docs/rfcs/0020-push-notification-subscriptions.md @@ -95,13 +95,13 @@ async fn push_set_rules( `Topic` is reused from `v01::statement_store`. -A rule is just a `Topic`. At the host level the effective key is `(product, topic)`: rules are scoped per calling product, so two products can register the same topic independently and never see each other's rules. The product does not specify the signer; the host injects it when forwarding the rule to the push backend. +A rule is a `(signer, topic)` pair. When `signer` is `None` the host injects the calling product's own identity; set it explicitly to subscribe to statements published by a different product. ```rust -pub struct HostPushAddRulesRequest { pub topics: Vec } -pub struct HostPushRemoveRulesRequest { pub topics: Vec } +pub struct HostPushAddRulesRequest { pub topics: Vec, pub signer: Option } +pub struct HostPushRemoveRulesRequest { pub topics: Vec, pub signer: Option } pub struct HostPushListRulesRequest; -pub struct HostPushSetRulesRequest { pub topics: Vec } +pub struct HostPushSetRulesRequest { pub topics: Vec, pub signer: Option } pub struct HostPushListRulesResponse { pub topics: Vec, @@ -113,25 +113,25 @@ pub enum HostPushAddRulesError { /// a product; if the user dismisses or declines, this variant is /// returned and no rules are stored. PermissionDenied, - /// The host could not reach the push backend; no rules were stored. - BackendUnavailable, + /// The notification system is currently unavailable; no rules were stored. + NotificationSystemUnavailable(String), /// Catch-all. `reason` Unknown { reason: String }, } pub enum HostPushRemoveRulsError { - BackendUnavailable, + NotificationSystemUnavailable(String), Unknown { reason: String }, } pub enum HostPushListRulesError { - BackendUnavailable, + NotificationSystemUnavailable(String), Unknown { reason: String }, } pub enum HostPushSetRulesError { PermissionDenied, - BackendUnavailable, + NotificationSystemUnavailable(String), Unknown { reason: String }, } ``` diff --git a/rust/crates/truapi/src/v01/notifications.rs b/rust/crates/truapi/src/v01/notifications.rs index d86d16ab..ab1477a8 100644 --- a/rust/crates/truapi/src/v01/notifications.rs +++ b/rust/crates/truapi/src/v01/notifications.rs @@ -1,6 +1,6 @@ use parity_scale_codec::{Decode, Encode}; -use super::Topic; +use super::{ProductAccountId, Topic}; /// Opaque identifier for a push notification, unique per product. pub type NotificationId = u32; @@ -52,15 +52,17 @@ pub struct HostPushNotificationCancelRequest { /// Request to register one or more topics the user wants to be woken up for. /// Each topic is added independently; existing rules are not touched. /// -/// At the host level the effective key is `(product, topic)`: rules are -/// scoped per calling product, so two products can register the same topic -/// independently and never see each other's rules. The product does not -/// specify the signer; the host injects it when forwarding the rule to the -/// push backend. +/// When `signer` is `None` the host injects the calling product's own +/// identity as the signer. Set `signer` explicitly to subscribe to +/// statements published by a *different* product (e.g. a conference +/// organizer's announcements). #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPushAddRulesRequest { /// Topics to register. pub topics: Vec, + /// Signer whose statements should trigger a push. Defaults to the + /// calling product's own identity when `None`. + pub signer: Option, } /// Failure modes for [`HostPushAddRulesRequest`]. @@ -68,9 +70,9 @@ pub struct HostPushAddRulesRequest { pub enum HostPushAddRulesError { /// The user has not granted `DevicePermission::Notifications`. PermissionDenied, - /// The host's push backend is currently unreachable; the rule was not + /// The notification system is currently unavailable; the rule was not /// registered. The product MAY retry later. - BackendUnavailable, + NotificationSystemUnavailable(String), /// Catch-all. Unknown { reason: String }, } @@ -81,14 +83,17 @@ pub enum HostPushAddRulesError { pub struct HostPushRemoveRulesRequest { /// Topics to remove. pub topics: Vec, + /// Signer scope. When `None`, removes rules for the calling product's + /// own identity. + pub signer: Option, } /// Failure modes for [`HostPushRemoveRulesRequest`]. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum HostPushRemoveRulesError { - /// The host's push backend is currently unreachable; the rule may still + /// The notification system is currently unavailable; the rule may still /// be active. The product MAY retry later. - BackendUnavailable, + NotificationSystemUnavailable(String), /// Catch-all. Unknown { reason: String }, } @@ -109,20 +114,23 @@ pub struct HostPushListRulesResponse { /// Failure modes for [`HostPushListRulesRequest`]. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum HostPushListRulesError { - /// The host's push backend is currently unreachable. The product MAY + /// The notification system is currently unavailable. The product MAY /// retry later. - BackendUnavailable, + NotificationSystemUnavailable(String), /// Catch-all. Unknown { reason: String }, } -/// Atomic replace of the calling product's full topic set with the supplied -/// vector. After a successful call, the product's active topics are exactly +/// Atomic replace of the full topic set for the given signer with the +/// supplied vector. After a successful call, the active topics are exactly /// `topics`. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPushSetRulesRequest { - /// Topics that should be active for this product after the call. + /// Topics that should be active after the call. pub topics: Vec, + /// Signer scope. When `None`, replaces rules for the calling product's + /// own identity. + pub signer: Option, } /// Failure modes for [`HostPushSetRulesRequest`]. @@ -130,9 +138,9 @@ pub struct HostPushSetRulesRequest { pub enum HostPushSetRulesError { /// The user has not granted `DevicePermission::Notifications`. PermissionDenied, - /// The host's push backend is currently unreachable; no change was + /// The notification system is currently unavailable; no change was /// applied. The product MAY retry later. - BackendUnavailable, + NotificationSystemUnavailable(String), /// Catch-all. Unknown { reason: String }, } From 4cfaa6e11c05019ad61edfac5d602e44c5b132c8 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Wed, 20 May 2026 06:54:31 +0200 Subject: [PATCH 10/20] Update RFC worked example to use explicit signer instead of implicit pkProduct --- docs/rfcs/0020-push-notification-subscriptions.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/rfcs/0020-push-notification-subscriptions.md b/docs/rfcs/0020-push-notification-subscriptions.md index dcf4d418..f7769cc4 100644 --- a/docs/rfcs/0020-push-notification-subscriptions.md +++ b/docs/rfcs/0020-push-notification-subscriptions.md @@ -23,27 +23,25 @@ This RFC exposes a TrUAPI-shaped surface over the rule-management API defined in ## Motivation -The push-notifications v2 design assigns delivery to a host-side push backend that tails the Statement Store, verifies signatures, and delivers pushes only for `(signer, topic)` pairs the user has whitelisted. TrUAPI needs a primitive that lets a product manipulate that whitelist. The product supplies the `topic`; the host fills in the `signer` from the calling product's identity before forwarding to the backend. +The push-notifications v2 design assigns delivery to a host-side notification system that tails the Statement Store, verifies signatures, and delivers pushes only for `(signer, topic)` pairs the user has whitelisted. TrUAPI needs a primitive that lets a product manipulate that whitelist. When `signer` is omitted the host defaults to the calling product's own identity; when provided explicitly the product can subscribe to statements from a different product. ### Worked example: festival announcements -A conference product publishes festival-wide announcements as signed statements on a well-known topic, signed with the product's own identity key (`pkProduct`). When the user taps "notify me about announcements," the subscriber app calls `push_add_rules({ topics: [announcements_topic] })`. The host injects `pkProduct` as the signer when relaying to the backend, so from that point on the user is woken up for new announcements even with the product closed: +A conference product publishes festival-wide announcements as signed statements on a well-known topic. An attendee's app subscribes by calling `push_add_rules({ topics: [announcements_topic], signer: organizer_id })`, passing the organizer product's `ProductAccountId` explicitly. From that point on the user is woken up for new announcements even with the app closed: ``` Publisher app Subscriber app (organizer side) (attendee side) | ^ | | | | - | (6) push | | (1) pushAddRules({ topics: [ T_announcements ] }) + | (6) push | | (1) pushAddRules({ topics: [T], signer: organizer_id }) | back to | | | caller | | | | v | +------------------------------------+---+------+ | | Host | - | | injects signer = pkProduct, then forwards | - | | to push backend: | - | | rule (pkProduct, T_announcements) | - | | -> this subscriber app | + | | stores rule (organizer_id, T) | + | | -> deliver to this subscriber | | +-----------------------+-----------------------+ | ^ | | (4) tail / match rule From 792cd8ea0091d8a1a53348156bfe197e33edc750 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Wed, 20 May 2026 10:18:21 +0200 Subject: [PATCH 11/20] Auto-number RFCs on merge via CI Authors now create unnumbered `docs/rfcs/.md` files. On merge to main the new `number-rfc.yml` workflow assigns the next sequential number, renames the file, and appends it to `_index.md`. Updated CONTRIBUTING.md, PR template, and RFC template accordingly. --- .github/PULL_REQUEST_TEMPLATE/rfc.md | 5 +- .github/workflows/number-rfc.yml | 84 ++++++++++++++++++++++++++++ CONTRIBUTING.md | 8 +-- docs/rfcs/0001-template.md | 2 +- 4 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/number-rfc.yml diff --git a/.github/PULL_REQUEST_TEMPLATE/rfc.md b/.github/PULL_REQUEST_TEMPLATE/rfc.md index ebd8e000..48c598f3 100644 --- a/.github/PULL_REQUEST_TEMPLATE/rfc.md +++ b/.github/PULL_REQUEST_TEMPLATE/rfc.md @@ -6,10 +6,9 @@ ### Checklist -- [ ] Added `docs/rfcs/NNNN-short-slug.md` with completed frontmatter +- [ ] Added `docs/rfcs/.md` (no number — CI assigns one on merge) - [ ] Filled all RFC sections (Summary, Motivation, Detailed Design, Drawbacks, Alternatives, Unresolved Questions) -- [ ] Updated `docs/rfcs/_index.md` with a link to the new RFC -- [ ] Added labels: `rfc`, `proposal` +- [ ] Added label: `rfc` ### Motivation diff --git a/.github/workflows/number-rfc.yml b/.github/workflows/number-rfc.yml new file mode 100644 index 00000000..442317db --- /dev/null +++ b/.github/workflows/number-rfc.yml @@ -0,0 +1,84 @@ +name: Number RFC + +on: + push: + branches: [main] + paths: + - 'docs/rfcs/**' + +permissions: + contents: write + +jobs: + number-rfc: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Find unnumbered RFCs and assign numbers + run: | + cd docs/rfcs + + # Collect unnumbered RFC files (not _index.md, not template, not already numbered) + UNNUMBERED=() + for f in *.md; do + case "$f" in + _index.md|[0-9][0-9][0-9][0-9]-*) continue ;; + *) UNNUMBERED+=("$f") ;; + esac + done + + if [ ${#UNNUMBERED[@]} -eq 0 ]; then + echo "No unnumbered RFCs found." + exit 0 + fi + + # Determine the highest existing number + MAX=0 + for f in [0-9][0-9][0-9][0-9]-*.md; do + [ -e "$f" ] || continue + NUM=$(echo "$f" | grep -oE '^[0-9]+' | sed 's/^0*//') + [ "${NUM:-0}" -gt "$MAX" ] && MAX="$NUM" + done + + for DRAFT in "${UNNUMBERED[@]}"; do + MAX=$((MAX + 1)) + PADDED=$(printf '%04d' "$MAX") + SLUG="${DRAFT%.md}" + NEW_NAME="${PADDED}-${SLUG}.md" + + echo "Numbering: $DRAFT -> $NEW_NAME (RFC $PADDED)" + + # Rename the file + git mv "$DRAFT" "$NEW_NAME" + + # Update the heading: "# RFC — Title" or "# Title" -> "# RFC NNNN — Title" + sed -i -E "s/^# RFC — /# RFC ${PADDED} — /" "$NEW_NAME" + sed -i -E "s/^# RFC [0-9]+ — /# RFC ${PADDED} — /" "$NEW_NAME" + + # Extract title from frontmatter + TITLE=$(grep -m1 '^title:' "$NEW_NAME" | sed 's/^title: *"//;s/" *$//') + # Extract owner from frontmatter + OWNER=$(grep -m1 '^owner:' "$NEW_NAME" | sed 's/^owner: *"//;s/" *$//') + # Extract status from frontmatter (default to accepted on merge) + STATUS=$(grep -m1 '^status:' "$NEW_NAME" | sed 's/^status: *//;s/ *$//') + STATUS="${STATUS:-accepted}" + + # Append row to _index.md + printf '| %s | [%s](%s) | %-18s | %-13s | |\n' \ + "$PADDED" "$TITLE" "$NEW_NAME" "$STATUS" "$OWNER" >> _index.md + done + + - name: Commit and push + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + if git diff --cached --quiet && git diff --quiet; then + echo "Nothing to commit." + exit 0 + fi + git add docs/rfcs/ + git commit -m "Assign RFC number to newly merged RFC(s)" + git push diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a14da08..cace9606 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,11 +19,11 @@ Feature proposals live as markdown files in `docs/features/`. To propose a new f For larger changes that need cross-team discussion, use the RFC process: -1. Create a branch and add a new file to `docs/rfcs/` using the next available number (e.g., `docs/rfcs/0002-my-proposal.md`) +1. Create a branch and add a new file to `docs/rfcs/.md` (e.g., `docs/rfcs/my-proposal.md`) — do **not** assign a number 2. Use `docs/rfcs/0001-template.md` as a reference for the expected structure and frontmatter -3. Update `docs/rfcs/_index.md` with a link to your RFC -4. Open a PR using the **rfc** template (`?template=rfc.md`) and add the `rfc` and `proposal` labels -5. The PR will be auto-added to the project board for tracking and review +3. Open a PR using the **rfc** template (`?template=rfc.md`) and add the `rfc` label +4. The PR will be auto-added to the project board for tracking and review +5. When the PR is approved and merged, CI automatically assigns the next sequential number, renames the file, and appends it to `docs/rfcs/_index.md` **Important:** RFC PRs must include corresponding changes to the TrUAPI Rust interfaces in `rust/crates/truapi/`. A CI check (`check-rfc.yml`) enforces diff --git a/docs/rfcs/0001-template.md b/docs/rfcs/0001-template.md index 17a2e5a5..938ee910 100644 --- a/docs/rfcs/0001-template.md +++ b/docs/rfcs/0001-template.md @@ -3,7 +3,7 @@ title: "RFC Title" owner: "@ownerhandle" --- -# RFC 0001 — Title +# RFC — Title ## Summary From e762c6eeb899acdc73c79fca8be3a222f47da613 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Wed, 20 May 2026 10:19:00 +0200 Subject: [PATCH 12/20] Revert "Auto-number RFCs on merge via CI" This reverts commit 792cd8ea0091d8a1a53348156bfe197e33edc750. --- .github/PULL_REQUEST_TEMPLATE/rfc.md | 5 +- .github/workflows/number-rfc.yml | 84 ---------------------------- CONTRIBUTING.md | 8 +-- docs/rfcs/0001-template.md | 2 +- 4 files changed, 8 insertions(+), 91 deletions(-) delete mode 100644 .github/workflows/number-rfc.yml diff --git a/.github/PULL_REQUEST_TEMPLATE/rfc.md b/.github/PULL_REQUEST_TEMPLATE/rfc.md index 48c598f3..ebd8e000 100644 --- a/.github/PULL_REQUEST_TEMPLATE/rfc.md +++ b/.github/PULL_REQUEST_TEMPLATE/rfc.md @@ -6,9 +6,10 @@ ### Checklist -- [ ] Added `docs/rfcs/.md` (no number — CI assigns one on merge) +- [ ] Added `docs/rfcs/NNNN-short-slug.md` with completed frontmatter - [ ] Filled all RFC sections (Summary, Motivation, Detailed Design, Drawbacks, Alternatives, Unresolved Questions) -- [ ] Added label: `rfc` +- [ ] Updated `docs/rfcs/_index.md` with a link to the new RFC +- [ ] Added labels: `rfc`, `proposal` ### Motivation diff --git a/.github/workflows/number-rfc.yml b/.github/workflows/number-rfc.yml deleted file mode 100644 index 442317db..00000000 --- a/.github/workflows/number-rfc.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Number RFC - -on: - push: - branches: [main] - paths: - - 'docs/rfcs/**' - -permissions: - contents: write - -jobs: - number-rfc: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Find unnumbered RFCs and assign numbers - run: | - cd docs/rfcs - - # Collect unnumbered RFC files (not _index.md, not template, not already numbered) - UNNUMBERED=() - for f in *.md; do - case "$f" in - _index.md|[0-9][0-9][0-9][0-9]-*) continue ;; - *) UNNUMBERED+=("$f") ;; - esac - done - - if [ ${#UNNUMBERED[@]} -eq 0 ]; then - echo "No unnumbered RFCs found." - exit 0 - fi - - # Determine the highest existing number - MAX=0 - for f in [0-9][0-9][0-9][0-9]-*.md; do - [ -e "$f" ] || continue - NUM=$(echo "$f" | grep -oE '^[0-9]+' | sed 's/^0*//') - [ "${NUM:-0}" -gt "$MAX" ] && MAX="$NUM" - done - - for DRAFT in "${UNNUMBERED[@]}"; do - MAX=$((MAX + 1)) - PADDED=$(printf '%04d' "$MAX") - SLUG="${DRAFT%.md}" - NEW_NAME="${PADDED}-${SLUG}.md" - - echo "Numbering: $DRAFT -> $NEW_NAME (RFC $PADDED)" - - # Rename the file - git mv "$DRAFT" "$NEW_NAME" - - # Update the heading: "# RFC — Title" or "# Title" -> "# RFC NNNN — Title" - sed -i -E "s/^# RFC — /# RFC ${PADDED} — /" "$NEW_NAME" - sed -i -E "s/^# RFC [0-9]+ — /# RFC ${PADDED} — /" "$NEW_NAME" - - # Extract title from frontmatter - TITLE=$(grep -m1 '^title:' "$NEW_NAME" | sed 's/^title: *"//;s/" *$//') - # Extract owner from frontmatter - OWNER=$(grep -m1 '^owner:' "$NEW_NAME" | sed 's/^owner: *"//;s/" *$//') - # Extract status from frontmatter (default to accepted on merge) - STATUS=$(grep -m1 '^status:' "$NEW_NAME" | sed 's/^status: *//;s/ *$//') - STATUS="${STATUS:-accepted}" - - # Append row to _index.md - printf '| %s | [%s](%s) | %-18s | %-13s | |\n' \ - "$PADDED" "$TITLE" "$NEW_NAME" "$STATUS" "$OWNER" >> _index.md - done - - - name: Commit and push - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - if git diff --cached --quiet && git diff --quiet; then - echo "Nothing to commit." - exit 0 - fi - git add docs/rfcs/ - git commit -m "Assign RFC number to newly merged RFC(s)" - git push diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cace9606..0a14da08 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,11 +19,11 @@ Feature proposals live as markdown files in `docs/features/`. To propose a new f For larger changes that need cross-team discussion, use the RFC process: -1. Create a branch and add a new file to `docs/rfcs/.md` (e.g., `docs/rfcs/my-proposal.md`) — do **not** assign a number +1. Create a branch and add a new file to `docs/rfcs/` using the next available number (e.g., `docs/rfcs/0002-my-proposal.md`) 2. Use `docs/rfcs/0001-template.md` as a reference for the expected structure and frontmatter -3. Open a PR using the **rfc** template (`?template=rfc.md`) and add the `rfc` label -4. The PR will be auto-added to the project board for tracking and review -5. When the PR is approved and merged, CI automatically assigns the next sequential number, renames the file, and appends it to `docs/rfcs/_index.md` +3. Update `docs/rfcs/_index.md` with a link to your RFC +4. Open a PR using the **rfc** template (`?template=rfc.md`) and add the `rfc` and `proposal` labels +5. The PR will be auto-added to the project board for tracking and review **Important:** RFC PRs must include corresponding changes to the TrUAPI Rust interfaces in `rust/crates/truapi/`. A CI check (`check-rfc.yml`) enforces diff --git a/docs/rfcs/0001-template.md b/docs/rfcs/0001-template.md index 938ee910..17a2e5a5 100644 --- a/docs/rfcs/0001-template.md +++ b/docs/rfcs/0001-template.md @@ -3,7 +3,7 @@ title: "RFC Title" owner: "@ownerhandle" --- -# RFC — Title +# RFC 0001 — Title ## Summary From 5cb2ebed3f6d12ddf24c1f2139296481e10e4c32 Mon Sep 17 00:00:00 2001 From: Santi Balaguer Date: Wed, 27 May 2026 00:05:25 +0200 Subject: [PATCH 13/20] added broadcast method to RFC0020 --- .../0020-push-notification-subscriptions.md | 62 +++++++++++++++---- rust/crates/truapi/src/api/notifications.rs | 25 ++++++++ rust/crates/truapi/src/v01/notifications.rs | 42 +++++++++++++ .../truapi/src/versioned/notifications.rs | 3 + 4 files changed, 119 insertions(+), 13 deletions(-) diff --git a/docs/rfcs/0020-push-notification-subscriptions.md b/docs/rfcs/0020-push-notification-subscriptions.md index f7769cc4..1b83945c 100644 --- a/docs/rfcs/0020-push-notification-subscriptions.md +++ b/docs/rfcs/0020-push-notification-subscriptions.md @@ -2,7 +2,7 @@ title: "Push Notification Subscriptions" type: rfc status: draft -owner: "@pgherveou" +owner: ["@pgherveou", "@sbalaguer"] pr: --- @@ -14,6 +14,8 @@ Adds four TrUAPI methods — `push_add_rules`, `push_remove_rules`, `push_list_r The method names use `add` / `remove` rather than `subscribe` / `unsubscribe` because the `_subscribe` suffix is reserved for streaming TrUAPI methods (e.g. `statementStore.subscribe`). +An **interim transport**, `push_broadcast`, distributes announcements **without using the Statement Store as the distribution layer**, while still preserving the broadcaster's authenticity: the host signs the announcement with the calling product's account and submits it directly to the push backend, which verifies the signature and fans out using the same `(signer, topic)` rule matching. It is marked **(interim)** in the API and Types sections below. + ## References - Push notifications, original (v1, peer-to-peer): https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/SyPN2yV6lx @@ -27,31 +29,27 @@ The push-notifications v2 design assigns delivery to a host-side notification sy ### Worked example: festival announcements -A conference product publishes festival-wide announcements as signed statements on a well-known topic. An attendee's app subscribes by calling `push_add_rules({ topics: [announcements_topic], signer: organizer_id })`, passing the organizer product's `ProductAccountId` explicitly. From that point on the user is woken up for new announcements even with the app closed: +A conference product publishes festival-wide announcements signed by the organizer. An attendee's app subscribes by calling `push_add_rules({ topics: [announcements_topic], signer: organizer_id })`, passing the organizer product's `ProductAccountId` explicitly. The organizer publishes with `push_broadcast` — the host signs the announcement and submits it to the backend. From that point on the attendee is woken for new announcements even with the app closed: ``` Publisher app Subscriber app (organizer side) (attendee side) | ^ | | | | - | (6) push | | (1) pushAddRules({ topics: [T], signer: organizer_id }) + | (5) push | | (1) push_add_rules({ topics: [T], signer: organizer_id }) | back to | | | caller | | | | v | +------------------------------------+---+------+ - | | Host | + | | Host + push backend | | | stores rule (organizer_id, T) | - | | -> deliver to this subscriber | - | +-----------------------+-----------------------+ - | ^ - | | (4) tail / match rule - | | - | +-----------------------+-----------------------+ - | | Statement Store | + | | (4) verify signature; match (organizer_id,T) | + | | -> deliver to this subscriber | | +-----------------------+-----------------------+ | ^ - | (2) compose signed statement | - |--- (3) statementStore.submit(statement) -+ + | (2) push_broadcast({ topics: [T], | (3) host signs the + | content }) | announcement and + |------------------------------------------+ submits to backend ``` ## Detailed Design @@ -66,6 +64,7 @@ Each TrUAPI method mirrors one backend endpoint: | `push_remove_rules` | `DELETE /v1/subscriptions/rules` | remove one or more rules | | `push_list_rules` | `GET /v1/subscriptions` | snapshot of currently active set | | `push_set_rules` | `PUT /v1/subscriptions/rules` | atomic replace of the full set | +| `push_broadcast` | direct submit _(interim)_ | publish a signed announcement | ```rust #[wire(request_id = 164)] @@ -89,6 +88,17 @@ async fn push_set_rules( ) -> Result>; ``` +#### Interim: direct broadcast + +`push_broadcast` distributes an announcement **without using the Statement Store as the distribution layer**, while preserving the broadcaster's authenticity. The product sends only `{ topics, content }`. The host **MUST** sign the broadcast with the calling product's account (it sets the signer to that account — unforgeable, host-set — and signs a canonical encoding of `(signer, topics, content)`) and submit it directly to the backend. The backend **MUST** verify the signature, rejecting any unsigned or invalidly-signed broadcast, then matches `(signer, topic)` against subscriber rules; matching, rate-limiting, dedup, and dispatch are unchanged — only the distribution layer differs. The signature is the basis of authenticity; the product neither supplies nor can forge it, which is why it appears in neither the request nor the response. + +```rust +#[wire(request_id = 172)] +async fn push_broadcast( + &self, cx: &CallContext, request: HostPushBroadcastRequest, +) -> Result>; +``` + ### Types `Topic` is reused from `v01::statement_store`. @@ -133,3 +143,29 @@ pub enum HostPushSetRulesError { Unknown { reason: String }, } ``` + +#### Interim: direct broadcast + +The broadcast is **not** a Statement Store statement: the requirement is a **verifiable signature**, not a store-shaped object, so there is no `channel`, topic slots, or `expiry`. A later version can move distribution to the Statement Store without changing subscriber rules or the authenticity model. + +```rust +pub struct PushBroadcastContent { + pub title: String, + pub body: String, + pub deeplink: Option, // route/URL to open on tap +} + +pub struct HostPushBroadcastRequest { + pub topics: Vec, // matched against subscriber rules (signer = caller) + pub content: PushBroadcastContent, +} + +pub struct HostPushBroadcastResponse { + pub message_hash: [u8; 32], // Blake2b-256 of the signed broadcast (dedup / audit) +} + +pub enum HostPushBroadcastError { + NotificationSystemUnavailable(String), + Unknown { reason: String }, +} +``` diff --git a/rust/crates/truapi/src/api/notifications.rs b/rust/crates/truapi/src/api/notifications.rs index 4068b87a..e6647147 100644 --- a/rust/crates/truapi/src/api/notifications.rs +++ b/rust/crates/truapi/src/api/notifications.rs @@ -2,6 +2,7 @@ use crate::versioned::notifications::{ HostPushAddRulesError, HostPushAddRulesRequest, HostPushAddRulesResponse, + HostPushBroadcastError, HostPushBroadcastRequest, HostPushBroadcastResponse, HostPushListRulesError, HostPushListRulesRequest, HostPushListRulesResponse, HostPushNotificationCancelError, HostPushNotificationCancelRequest, HostPushNotificationCancelResponse, HostPushNotificationError, HostPushNotificationRequest, @@ -153,4 +154,28 @@ pub trait Notifications: Send + Sync { cx: &CallContext, request: HostPushSetRulesRequest, ) -> Result>; + + /// Publish an announcement to subscribers. Interim distribution that does + /// not use the Statement Store as the distribution layer, while preserving + /// the broadcaster's authenticity: the host signs the announcement with the + /// calling product's account and submits it directly to the push backend, + /// which verifies the signature and fans out using the same `(signer, + /// topic)` rule matching. + /// + /// ```ts + /// const result = await truapi.notifications.pushBroadcast({ + /// topics: ["0x00"], + /// content: { title: "Web3 Summit", body: "Keynote moved to Hall A" }, + /// }); + /// result.match( + /// (value) => console.log(value.matched), + /// (error) => console.error(error), + /// ); + /// ``` + #[wire(request_id = 172)] + async fn push_broadcast( + &self, + cx: &CallContext, + request: HostPushBroadcastRequest, + ) -> Result>; } diff --git a/rust/crates/truapi/src/v01/notifications.rs b/rust/crates/truapi/src/v01/notifications.rs index ab1477a8..14803685 100644 --- a/rust/crates/truapi/src/v01/notifications.rs +++ b/rust/crates/truapi/src/v01/notifications.rs @@ -144,3 +144,45 @@ pub enum HostPushSetRulesError { /// Catch-all. Unknown { reason: String }, } + +/// Structured announcement content rendered on the device. Plaintext — +/// announcements are authenticity-only, not confidential. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct PushBroadcastContent { + /// Notification title. + pub title: String, + /// Notification body. + pub body: String, + /// Route or URL to open on tap. + pub deeplink: Option, +} + +/// Request to publish an announcement to subscribers via the interim direct +/// transport. The host signs the announcement with the calling product's +/// account and submits it directly to the push backend; the product supplies +/// only the topics and content. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPushBroadcastRequest { + /// Topics to publish on; matched against subscriber rules with the caller + /// as signer. + pub topics: Vec, + /// Announcement content carried to the device. + pub content: PushBroadcastContent, +} + +/// Result of a successful [`HostPushBroadcastRequest`]. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPushBroadcastResponse { + /// Blake2b-256 hash of the signed broadcast, for dedup and audit. + pub message_hash: [u8; 32], +} + +/// Failure modes for [`HostPushBroadcastRequest`]. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostPushBroadcastError { + /// The notification system is currently unavailable; nothing was published. + /// The product MAY retry later. + NotificationSystemUnavailable(String), + /// Catch-all. + Unknown { reason: String }, +} diff --git a/rust/crates/truapi/src/versioned/notifications.rs b/rust/crates/truapi/src/versioned/notifications.rs index 8c3ed058..49ae381a 100644 --- a/rust/crates/truapi/src/versioned/notifications.rs +++ b/rust/crates/truapi/src/versioned/notifications.rs @@ -21,4 +21,7 @@ versioned_type! { pub enum HostPushSetRulesRequest { V1 => v01::HostPushSetRulesRequest } pub enum HostPushSetRulesResponse { V1 } pub enum HostPushSetRulesError { V1 => v01::HostPushSetRulesError } + pub enum HostPushBroadcastRequest { V1 => v01::HostPushBroadcastRequest } + pub enum HostPushBroadcastResponse { V1 => v01::HostPushBroadcastResponse } + pub enum HostPushBroadcastError { V1 => v01::HostPushBroadcastError } } From 8f3e7187c8ec63be95d0f986145c7fe2f5c42e6c Mon Sep 17 00:00:00 2001 From: Santi Balaguer Date: Wed, 27 May 2026 11:19:40 +0200 Subject: [PATCH 14/20] edits --- .../0020-push-notification-subscriptions.md | 18 +++++++++--------- rust/crates/truapi/src/api/notifications.rs | 9 ++++----- rust/crates/truapi/src/v01/notifications.rs | 8 ++++---- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/docs/rfcs/0020-push-notification-subscriptions.md b/docs/rfcs/0020-push-notification-subscriptions.md index 1b83945c..8373dabe 100644 --- a/docs/rfcs/0020-push-notification-subscriptions.md +++ b/docs/rfcs/0020-push-notification-subscriptions.md @@ -14,7 +14,7 @@ Adds four TrUAPI methods — `push_add_rules`, `push_remove_rules`, `push_list_r The method names use `add` / `remove` rather than `subscribe` / `unsubscribe` because the `_subscribe` suffix is reserved for streaming TrUAPI methods (e.g. `statementStore.subscribe`). -An **interim transport**, `push_broadcast`, distributes announcements **without using the Statement Store as the distribution layer**, while still preserving the broadcaster's authenticity: the host signs the announcement with the calling product's account and submits it directly to the push backend, which verifies the signature and fans out using the same `(signer, topic)` rule matching. It is marked **(interim)** in the API and Types sections below. +An **interim transport**, `push_broadcast`, distributes announcements **without using the Statement Store as the distribution layer**. The host submits the announcement to the push backend, **setting the publisher `signer` itself** (the product cannot override it), and the backend fans out using the same `(signer, topic)` rule matching. It is marked **(interim)** in the API and Types sections below. ## References @@ -29,7 +29,7 @@ The push-notifications v2 design assigns delivery to a host-side notification sy ### Worked example: festival announcements -A conference product publishes festival-wide announcements signed by the organizer. An attendee's app subscribes by calling `push_add_rules({ topics: [announcements_topic], signer: organizer_id })`, passing the organizer product's `ProductAccountId` explicitly. The organizer publishes with `push_broadcast` — the host signs the announcement and submits it to the backend. From that point on the attendee is woken for new announcements even with the app closed: +A conference product publishes festival-wide announcements signed by the organizer. An attendee's app subscribes by calling `push_add_rules({ topics: [announcements_topic], signer: organizer_id })`, passing the organizer product's `ProductAccountId` explicitly. The organizer publishes with `push_broadcast` — the host sets the `signer` to the organizer and submits the announcement to the backend. From that point on the attendee is woken for new announcements even with the app closed: ``` Publisher app Subscriber app @@ -43,13 +43,13 @@ Publisher app Subscriber app | +------------------------------------+---+------+ | | Host + push backend | | | stores rule (organizer_id, T) | - | | (4) verify signature; match (organizer_id,T) | + | | (4) match (organizer_id, T) | | | -> deliver to this subscriber | | +-----------------------+-----------------------+ | ^ - | (2) push_broadcast({ topics: [T], | (3) host signs the - | content }) | announcement and - |------------------------------------------+ submits to backend + | (2) push_broadcast({ topics: [T], | (3) host sets signer + | content }) | and submits to + |------------------------------------------+ the backend ``` ## Detailed Design @@ -90,7 +90,7 @@ async fn push_set_rules( #### Interim: direct broadcast -`push_broadcast` distributes an announcement **without using the Statement Store as the distribution layer**, while preserving the broadcaster's authenticity. The product sends only `{ topics, content }`. The host **MUST** sign the broadcast with the calling product's account (it sets the signer to that account — unforgeable, host-set — and signs a canonical encoding of `(signer, topics, content)`) and submit it directly to the backend. The backend **MUST** verify the signature, rejecting any unsigned or invalidly-signed broadcast, then matches `(signer, topic)` against subscriber rules; matching, rate-limiting, dedup, and dispatch are unchanged — only the distribution layer differs. The signature is the basis of authenticity; the product neither supplies nor can forge it, which is why it appears in neither the request nor the response. +`push_broadcast` distributes an announcement **without using the Statement Store as the distribution layer**. The product sends only `{ topics, content }`. The host **sets the `signer` itself** — to the calling product's channel identity, host-set so the product cannot override or spoof it — and submits the announcement to the backend. The backend matches `(signer, topic)` against subscriber rules; matching, rate-limiting, dedup, and dispatch are unchanged — only the distribution layer differs. The product never sets `signer`, which is why it is absent from the request. ```rust #[wire(request_id = 172)] @@ -146,7 +146,7 @@ pub enum HostPushSetRulesError { #### Interim: direct broadcast -The broadcast is **not** a Statement Store statement: the requirement is a **verifiable signature**, not a store-shaped object, so there is no `channel`, topic slots, or `expiry`. A later version can move distribution to the Statement Store without changing subscriber rules or the authenticity model. +The broadcast is **not** a Statement Store statement: it is a plain `{ topics, content }` the host submits with a host-set `signer`, so there is no `channel`, topic slots, or `expiry`. A later version can move distribution to the Statement Store without changing subscriber rules. ```rust pub struct PushBroadcastContent { @@ -161,7 +161,7 @@ pub struct HostPushBroadcastRequest { } pub struct HostPushBroadcastResponse { - pub message_hash: [u8; 32], // Blake2b-256 of the signed broadcast (dedup / audit) + pub message_hash: [u8; 32], // Blake2b-256 of the broadcast (dedup / audit) } pub enum HostPushBroadcastError { diff --git a/rust/crates/truapi/src/api/notifications.rs b/rust/crates/truapi/src/api/notifications.rs index e6647147..75a151aa 100644 --- a/rust/crates/truapi/src/api/notifications.rs +++ b/rust/crates/truapi/src/api/notifications.rs @@ -156,11 +156,10 @@ pub trait Notifications: Send + Sync { ) -> Result>; /// Publish an announcement to subscribers. Interim distribution that does - /// not use the Statement Store as the distribution layer, while preserving - /// the broadcaster's authenticity: the host signs the announcement with the - /// calling product's account and submits it directly to the push backend, - /// which verifies the signature and fans out using the same `(signer, - /// topic)` rule matching. + /// not use the Statement Store as the distribution layer: the host sets the + /// publisher `signer` to the calling product's identity (the product cannot + /// override it) and submits the announcement to the push backend, which fans + /// out using the same `(signer, topic)` rule matching. /// /// ```ts /// const result = await truapi.notifications.pushBroadcast({ diff --git a/rust/crates/truapi/src/v01/notifications.rs b/rust/crates/truapi/src/v01/notifications.rs index 14803685..176da77f 100644 --- a/rust/crates/truapi/src/v01/notifications.rs +++ b/rust/crates/truapi/src/v01/notifications.rs @@ -158,9 +158,9 @@ pub struct PushBroadcastContent { } /// Request to publish an announcement to subscribers via the interim direct -/// transport. The host signs the announcement with the calling product's -/// account and submits it directly to the push backend; the product supplies -/// only the topics and content. +/// transport. The host sets the publisher `signer` to the calling product's +/// identity and submits the announcement to the push backend; the product +/// supplies only the topics and content. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPushBroadcastRequest { /// Topics to publish on; matched against subscriber rules with the caller @@ -173,7 +173,7 @@ pub struct HostPushBroadcastRequest { /// Result of a successful [`HostPushBroadcastRequest`]. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPushBroadcastResponse { - /// Blake2b-256 hash of the signed broadcast, for dedup and audit. + /// Blake2b-256 hash of the broadcast, for dedup and audit. pub message_hash: [u8; 32], } From 4e2502c3122f6b4a8b1c999e0e53770ae68d9aa4 Mon Sep 17 00:00:00 2001 From: Santi Balaguer Date: Wed, 27 May 2026 12:54:35 +0200 Subject: [PATCH 15/20] makes signer mandatory on rules --- .../0020-push-notification-subscriptions.md | 12 +++--- rust/crates/truapi/src/api/notifications.rs | 14 ++++--- rust/crates/truapi/src/v01/notifications.rs | 37 +++++++++---------- 3 files changed, 33 insertions(+), 30 deletions(-) diff --git a/docs/rfcs/0020-push-notification-subscriptions.md b/docs/rfcs/0020-push-notification-subscriptions.md index 8373dabe..8c7bd2c8 100644 --- a/docs/rfcs/0020-push-notification-subscriptions.md +++ b/docs/rfcs/0020-push-notification-subscriptions.md @@ -10,7 +10,7 @@ pr: ## Summary -Adds four TrUAPI methods — `push_add_rules`, `push_remove_rules`, `push_list_rules`, `push_set_rules` — that mirror the rule-management endpoints of the [v2 push backend spec](https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r16YTVg5Ze). From the product's point of view a rule is just a `topic`: the product does not specify the signer, the host injects it when forwarding the rule to its push backend. The backend then delivers a push to the user's device(s) whenever a signed statement matching the resulting `(signer, topic)` pair appears on the Statement Store. The product never sees push tokens. +Adds four TrUAPI methods — `push_add_rules`, `push_remove_rules`, `push_list_rules`, `push_set_rules` — that mirror the rule-management endpoints of the [v2 push backend spec](https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r16YTVg5Ze). A rule is a `(signer, topic)` pair the product specifies in full: `signer` (mandatory) is the publisher whose statements should wake the user. The backend then delivers a push to the user's device(s) whenever a signed statement matching that `(signer, topic)` pair appears on the Statement Store. The product never sees push tokens. The method names use `add` / `remove` rather than `subscribe` / `unsubscribe` because the `_subscribe` suffix is reserved for streaming TrUAPI methods (e.g. `statementStore.subscribe`). @@ -25,7 +25,7 @@ This RFC exposes a TrUAPI-shaped surface over the rule-management API defined in ## Motivation -The push-notifications v2 design assigns delivery to a host-side notification system that tails the Statement Store, verifies signatures, and delivers pushes only for `(signer, topic)` pairs the user has whitelisted. TrUAPI needs a primitive that lets a product manipulate that whitelist. When `signer` is omitted the host defaults to the calling product's own identity; when provided explicitly the product can subscribe to statements from a different product. +The push-notifications v2 design assigns delivery to a host-side notification system that tails the Statement Store, verifies signatures, and delivers pushes only for `(signer, topic)` pairs the user has whitelisted. TrUAPI needs a primitive that lets a product manipulate that whitelist. `signer` is **mandatory** on every rule: the product always names the publisher it wants. ### Worked example: festival announcements @@ -103,13 +103,13 @@ async fn push_broadcast( `Topic` is reused from `v01::statement_store`. -A rule is a `(signer, topic)` pair. When `signer` is `None` the host injects the calling product's own identity; set it explicitly to subscribe to statements published by a different product. +A rule is a `(signer, topic)` pair. `signer` is **mandatory**: the subscriber always names the publisher. ```rust -pub struct HostPushAddRulesRequest { pub topics: Vec, pub signer: Option } -pub struct HostPushRemoveRulesRequest { pub topics: Vec, pub signer: Option } +pub struct HostPushAddRulesRequest { pub topics: Vec, pub signer: ProductAccountId } +pub struct HostPushRemoveRulesRequest { pub topics: Vec, pub signer: ProductAccountId } pub struct HostPushListRulesRequest; -pub struct HostPushSetRulesRequest { pub topics: Vec, pub signer: Option } +pub struct HostPushSetRulesRequest { pub topics: Vec, pub signer: ProductAccountId } pub struct HostPushListRulesResponse { pub topics: Vec, diff --git a/rust/crates/truapi/src/api/notifications.rs b/rust/crates/truapi/src/api/notifications.rs index 75a151aa..4806119d 100644 --- a/rust/crates/truapi/src/api/notifications.rs +++ b/rust/crates/truapi/src/api/notifications.rs @@ -74,15 +74,17 @@ pub trait Notifications: Send + Sync { request: HostPushNotificationCancelRequest, ) -> Result>; - /// Register one or more topics so the user is woken up by a push when a - /// signed statement matching any registered topic appears on the - /// Statement Store. Mirrors `POST /v1/subscriptions/rules` from the v2 - /// push backend spec. The signer is injected by the host (based on the - /// calling product's identity) when relaying the rule to the backend. + /// Register one or more `(signer, topic)` rules so the user is woken by a + /// push when a signed statement matching a rule appears on the Statement + /// Store. Mirrors `POST /v1/subscriptions/rules` from the v2 push backend + /// spec. `signer` is mandatory — the publisher whose statements should wake + /// the user (the calling product's own identity to self-subscribe, or + /// another product's). /// /// ```ts /// const result = await truapi.notifications.pushAddRules({ /// topics: ["0x00"], + /// signer: "0x…", /// }); /// result.match( /// () => console.log("ok"), @@ -102,6 +104,7 @@ pub trait Notifications: Send + Sync { /// ```ts /// const result = await truapi.notifications.pushRemoveRules({ /// topics: ["0x00"], + /// signer: "0x…", /// }); /// result.match( /// () => console.log("ok"), @@ -142,6 +145,7 @@ pub trait Notifications: Send + Sync { /// ```ts /// const result = await truapi.notifications.pushSetRules({ /// topics: ["0x00"], + /// signer: "0x…", /// }); /// result.match( /// () => console.log("ok"), diff --git a/rust/crates/truapi/src/v01/notifications.rs b/rust/crates/truapi/src/v01/notifications.rs index 176da77f..6985c8f8 100644 --- a/rust/crates/truapi/src/v01/notifications.rs +++ b/rust/crates/truapi/src/v01/notifications.rs @@ -49,20 +49,21 @@ pub struct HostPushNotificationCancelRequest { pub id: NotificationId, } -/// Request to register one or more topics the user wants to be woken up for. -/// Each topic is added independently; existing rules are not touched. +/// Request to register one or more `(signer, topic)` rules the user wants to be +/// woken up for. Each topic is added independently; existing rules are not +/// touched. /// -/// When `signer` is `None` the host injects the calling product's own -/// identity as the signer. Set `signer` explicitly to subscribe to -/// statements published by a *different* product (e.g. a conference -/// organizer's announcements). +/// `signer` is mandatory: the subscriber always names the publisher whose +/// statements should trigger a push — the calling product's own identity to +/// self-subscribe, or a *different* product's (e.g. a conference organizer's +/// announcements). #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPushAddRulesRequest { /// Topics to register. pub topics: Vec, - /// Signer whose statements should trigger a push. Defaults to the - /// calling product's own identity when `None`. - pub signer: Option, + /// Publisher whose statements should trigger a push. Pass the calling + /// product's own identity to self-subscribe. + pub signer: ProductAccountId, } /// Failure modes for [`HostPushAddRulesRequest`]. @@ -77,15 +78,14 @@ pub enum HostPushAddRulesError { Unknown { reason: String }, } -/// Request to remove one or more previously registered topics. -/// Topics not currently active are ignored. +/// Request to remove one or more previously registered `(signer, topic)` rules. +/// Rules not currently active are ignored. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPushRemoveRulesRequest { /// Topics to remove. pub topics: Vec, - /// Signer scope. When `None`, removes rules for the calling product's - /// own identity. - pub signer: Option, + /// Publisher scope of the rules to remove. Mandatory. + pub signer: ProductAccountId, } /// Failure modes for [`HostPushRemoveRulesRequest`]. @@ -122,15 +122,14 @@ pub enum HostPushListRulesError { } /// Atomic replace of the full topic set for the given signer with the -/// supplied vector. After a successful call, the active topics are exactly -/// `topics`. +/// supplied vector. After a successful call, the active topics for that signer +/// are exactly `topics`. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPushSetRulesRequest { /// Topics that should be active after the call. pub topics: Vec, - /// Signer scope. When `None`, replaces rules for the calling product's - /// own identity. - pub signer: Option, + /// Publisher scope whose rule set is being replaced. Mandatory. + pub signer: ProductAccountId, } /// Failure modes for [`HostPushSetRulesRequest`]. From efa4e6b94ac9d000fc2ce2527ee3af2b9bec1f8c Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 27 May 2026 18:17:32 +0200 Subject: [PATCH 16/20] rename --- docs/rfcs/_index.md | 1 - ...ation-subscriptions.md => push-notification-subscriptions.md} | 0 2 files changed, 1 deletion(-) rename docs/rfcs/{0020-push-notification-subscriptions.md => push-notification-subscriptions.md} (100%) diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index f98fe693..7c9faa19 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -20,4 +20,3 @@ created: 2026-03-13 | 0011 | [Simple Group Chat](0011-simple-group-chat.md) | draft | @filvecchiato | [#131](https://github.com/paritytech/triangle-js-sdks/pull/131) | | 0015 | [Get User Primary DotNS Name](0015-get-user-id.md) | draft | @valentunn | [#144](https://github.com/paritytech/triangle-js-sdks/pull/144) | | 0019 | [Scheduled Push Notifications](0019-scheduled-notifications.md) | draft | @johnthecat | | -| 0020 | [Push Notification Subscriptions](0020-push-notification-subscriptions.md) | draft | @pgherveou | | diff --git a/docs/rfcs/0020-push-notification-subscriptions.md b/docs/rfcs/push-notification-subscriptions.md similarity index 100% rename from docs/rfcs/0020-push-notification-subscriptions.md rename to docs/rfcs/push-notification-subscriptions.md From da93ed3bbbdf0484927be99644b909c19e4ba2e0 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 27 May 2026 18:20:45 +0200 Subject: [PATCH 17/20] refactor text --- docs/rfcs/push-notification-subscriptions.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/rfcs/push-notification-subscriptions.md b/docs/rfcs/push-notification-subscriptions.md index 8c7bd2c8..d1de3b95 100644 --- a/docs/rfcs/push-notification-subscriptions.md +++ b/docs/rfcs/push-notification-subscriptions.md @@ -12,8 +12,6 @@ pr: Adds four TrUAPI methods — `push_add_rules`, `push_remove_rules`, `push_list_rules`, `push_set_rules` — that mirror the rule-management endpoints of the [v2 push backend spec](https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r16YTVg5Ze). A rule is a `(signer, topic)` pair the product specifies in full: `signer` (mandatory) is the publisher whose statements should wake the user. The backend then delivers a push to the user's device(s) whenever a signed statement matching that `(signer, topic)` pair appears on the Statement Store. The product never sees push tokens. -The method names use `add` / `remove` rather than `subscribe` / `unsubscribe` because the `_subscribe` suffix is reserved for streaming TrUAPI methods (e.g. `statementStore.subscribe`). - An **interim transport**, `push_broadcast`, distributes announcements **without using the Statement Store as the distribution layer**. The host submits the announcement to the push backend, **setting the publisher `signer` itself** (the product cannot override it), and the backend fans out using the same `(signer, topic)` rule matching. It is marked **(interim)** in the API and Types sections below. ## References From dc1b21540f391128818fdb660689c75a939f5439 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 27 May 2026 18:37:02 +0200 Subject: [PATCH 18/20] simplify --- docs/rfcs/push-notification-subscriptions.md | 6 +++--- rust/crates/truapi/src/api/notifications.rs | 4 +--- rust/crates/truapi/src/v01/notifications.rs | 19 ++++++++----------- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/docs/rfcs/push-notification-subscriptions.md b/docs/rfcs/push-notification-subscriptions.md index d1de3b95..e0e687f3 100644 --- a/docs/rfcs/push-notification-subscriptions.md +++ b/docs/rfcs/push-notification-subscriptions.md @@ -104,10 +104,10 @@ async fn push_broadcast( A rule is a `(signer, topic)` pair. `signer` is **mandatory**: the subscriber always names the publisher. ```rust -pub struct HostPushAddRulesRequest { pub topics: Vec, pub signer: ProductAccountId } -pub struct HostPushRemoveRulesRequest { pub topics: Vec, pub signer: ProductAccountId } +pub struct HostPushAddRulesRequest { pub topics: Vec, pub signer: AccountId } +pub struct HostPushRemoveRulesRequest { pub topics: Vec, pub signer: AccountId } pub struct HostPushListRulesRequest; -pub struct HostPushSetRulesRequest { pub topics: Vec, pub signer: ProductAccountId } +pub struct HostPushSetRulesRequest { pub topics: Vec, pub signer: AccountId } pub struct HostPushListRulesResponse { pub topics: Vec, diff --git a/rust/crates/truapi/src/api/notifications.rs b/rust/crates/truapi/src/api/notifications.rs index 4806119d..4bc0de3d 100644 --- a/rust/crates/truapi/src/api/notifications.rs +++ b/rust/crates/truapi/src/api/notifications.rs @@ -77,9 +77,7 @@ pub trait Notifications: Send + Sync { /// Register one or more `(signer, topic)` rules so the user is woken by a /// push when a signed statement matching a rule appears on the Statement /// Store. Mirrors `POST /v1/subscriptions/rules` from the v2 push backend - /// spec. `signer` is mandatory — the publisher whose statements should wake - /// the user (the calling product's own identity to self-subscribe, or - /// another product's). + /// spec. /// /// ```ts /// const result = await truapi.notifications.pushAddRules({ diff --git a/rust/crates/truapi/src/v01/notifications.rs b/rust/crates/truapi/src/v01/notifications.rs index 6985c8f8..1abc0f19 100644 --- a/rust/crates/truapi/src/v01/notifications.rs +++ b/rust/crates/truapi/src/v01/notifications.rs @@ -1,6 +1,6 @@ use parity_scale_codec::{Decode, Encode}; -use super::{ProductAccountId, Topic}; +use super::{AccountId, Topic}; /// Opaque identifier for a push notification, unique per product. pub type NotificationId = u32; @@ -54,16 +54,13 @@ pub struct HostPushNotificationCancelRequest { /// touched. /// /// `signer` is mandatory: the subscriber always names the publisher whose -/// statements should trigger a push — the calling product's own identity to -/// self-subscribe, or a *different* product's (e.g. a conference organizer's -/// announcements). +/// statements should trigger a push. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPushAddRulesRequest { /// Topics to register. pub topics: Vec, - /// Publisher whose statements should trigger a push. Pass the calling - /// product's own identity to self-subscribe. - pub signer: ProductAccountId, + /// Public key of the publisher whose statements should trigger a push. + pub signer: AccountId, } /// Failure modes for [`HostPushAddRulesRequest`]. @@ -84,8 +81,8 @@ pub enum HostPushAddRulesError { pub struct HostPushRemoveRulesRequest { /// Topics to remove. pub topics: Vec, - /// Publisher scope of the rules to remove. Mandatory. - pub signer: ProductAccountId, + /// Public key of the publisher whose rules to remove. Mandatory. + pub signer: AccountId, } /// Failure modes for [`HostPushRemoveRulesRequest`]. @@ -128,8 +125,8 @@ pub enum HostPushListRulesError { pub struct HostPushSetRulesRequest { /// Topics that should be active after the call. pub topics: Vec, - /// Publisher scope whose rule set is being replaced. Mandatory. - pub signer: ProductAccountId, + /// Public key of the publisher whose rule set is being replaced. Mandatory. + pub signer: AccountId, } /// Failure modes for [`HostPushSetRulesRequest`]. From 2f73e931642dd663af2cecebf8cc4b11121ff166 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 27 May 2026 18:59:28 +0200 Subject: [PATCH 19/20] use rfc skill --- docs/rfcs/push-notification-subscriptions.md | 177 ++++++++++++++----- 1 file changed, 130 insertions(+), 47 deletions(-) diff --git a/docs/rfcs/push-notification-subscriptions.md b/docs/rfcs/push-notification-subscriptions.md index e0e687f3..3fad48ba 100644 --- a/docs/rfcs/push-notification-subscriptions.md +++ b/docs/rfcs/push-notification-subscriptions.md @@ -3,38 +3,38 @@ title: "Push Notification Subscriptions" type: rfc status: draft owner: ["@pgherveou", "@sbalaguer"] +date: 2026-05-27 pr: --- -# RFC 0020 — Push Notification Subscriptions +# RFC: Push Notification Subscriptions ## Summary -Adds four TrUAPI methods — `push_add_rules`, `push_remove_rules`, `push_list_rules`, `push_set_rules` — that mirror the rule-management endpoints of the [v2 push backend spec](https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r16YTVg5Ze). A rule is a `(signer, topic)` pair the product specifies in full: `signer` (mandatory) is the publisher whose statements should wake the user. The backend then delivers a push to the user's device(s) whenever a signed statement matching that `(signer, topic)` pair appears on the Statement Store. The product never sees push tokens. +Adds four TrUAPI methods — `push_add_rules`, `push_remove_rules`, `push_list_rules`, `push_set_rules` — that expose the rule-management endpoints of the [v2 push backend spec](https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r16YTVg5Ze) to products. A rule is a `(signer, topic)` pair the product specifies in full: `signer` is the publisher whose signed statements should wake the user. The backend delivers a push to the user's device(s) whenever a signed statement matching a whitelisted `(signer, topic)` pair appears on the Statement Store. The product never sees push tokens; tokens live in the backend subscription keyed to the authenticated device. -An **interim transport**, `push_broadcast`, distributes announcements **without using the Statement Store as the distribution layer**. The host submits the announcement to the push backend, **setting the publisher `signer` itself** (the product cannot override it), and the backend fans out using the same `(signer, topic)` rule matching. It is marked **(interim)** in the API and Types sections below. - -## References - -- Push notifications, original (v1, peer-to-peer): https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/SyPN2yV6lx -- Push notifications backend design (v2, backend-mediated): https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r16YTVg5Ze - -This RFC exposes a TrUAPI-shaped surface over the rule-management API defined in the v2 spec. +A fifth method, `push_broadcast`, is an **interim transport** that distributes an announcement without using the Statement Store as the distribution layer. The host submits the announcement to the push backend and **sets the publisher `signer` itself** to the calling product's identity (the product cannot override it), and the backend fans out using the same `(signer, topic)` rule matching. It is marked **(interim)** throughout. ## Motivation -The push-notifications v2 design assigns delivery to a host-side notification system that tails the Statement Store, verifies signatures, and delivers pushes only for `(signer, topic)` pairs the user has whitelisted. TrUAPI needs a primitive that lets a product manipulate that whitelist. `signer` is **mandatory** on every rule: the product always names the publisher it wants. +The push-notifications v2 design assigns delivery to a host-side notification system that tails the Statement Store, verifies signatures, and delivers pushes only for `(signer, topic)` pairs the user has whitelisted. TrUAPI needs a primitive that lets a product manipulate that whitelist on the user's own device. `signer` is mandatory on every rule: the product always names the publisher it wants to hear from. ### Worked example: festival announcements -A conference product publishes festival-wide announcements signed by the organizer. An attendee's app subscribes by calling `push_add_rules({ topics: [announcements_topic], signer: organizer_id })`, passing the organizer product's `ProductAccountId` explicitly. The organizer publishes with `push_broadcast` — the host sets the `signer` to the organizer and submits the announcement to the backend. From that point on the attendee is woken for new announcements even with the app closed: +A conference product publishes festival-wide announcements signed by the organizer: + +- The attendee's app subscribes by calling `push_add_rules` with a rule naming the organizer's `AccountId`. +- The organizer publishes with `push_broadcast`; the host sets `signer` to the organizer's identity and submits the announcement to the backend. +- The backend matches `(organizer, topic)` against the attendee's rule and delivers a push. + +From that point the attendee is woken for new announcements even with the app closed: ``` Publisher app Subscriber app (organizer side) (attendee side) | ^ | | | | - | (5) push | | (1) push_add_rules({ topics: [T], signer: organizer_id }) + | (5) push | | (1) push_add_rules({ rules: [{ signer: organizer_id, topics: [T] }] }) | back to | | | caller | | | | v @@ -50,19 +50,34 @@ Publisher app Subscriber app |------------------------------------------+ the backend ``` -## Detailed Design +## Stakeholders + +- **Subscriber products** that want their users woken by publisher activity (event apps, channels) without running their own background process. +- **Publisher products** that announce to their audience; with `push_broadcast` they publish under a host-attested identity they cannot forge. +- **Host implementers**, who own the push token, the user's `Notifications` permission grant, and the binding of `signer` on broadcast. +- **Push backend operators**, who run the Statement Store tailer, rule store, and dispatch described in the v2 spec. + +The design follows the v2 backend spec ([backend-mediated](https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r16YTVg5Ze)), which itself supersedes the original peer-to-peer v1 design ([v1](https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/SyPN2yV6lx)). This RFC exposes a TrUAPI-shaped surface over that backend's rule-management API. + +## Explanation + +### Rule model + +A rule is a `(signer, topic)` pair. `signer` is mandatory: the subscriber always names the publisher. Rules are grouped per signer on the wire as `PushRule { signer, topics }`, which is equivalent to the flat `(signer, topic)` tuple set the backend stores. `Topic` is reused from `v01::statement_store`. + +All rule operations are scoped to the **calling user's own subscription**: a product manages only the rules on the device it is running on, and cannot read or mutate another user's rules. ### API -Each TrUAPI method mirrors one backend endpoint: +Each TrUAPI method maps to one backend endpoint: -| TrUAPI method | Backend endpoint | Purpose | -| ------------------- | -------------------------------- | -------------------------------- | -| `push_add_rules` | `POST /v1/subscriptions/rules` | add one or more rules | -| `push_remove_rules` | `DELETE /v1/subscriptions/rules` | remove one or more rules | -| `push_list_rules` | `GET /v1/subscriptions` | snapshot of currently active set | -| `push_set_rules` | `PUT /v1/subscriptions/rules` | atomic replace of the full set | -| `push_broadcast` | direct submit _(interim)_ | publish a signed announcement | +| TrUAPI method | Backend endpoint | Purpose | +| ------------------- | -------------------------------- | --------------------------------------------- | +| `push_add_rules` | `POST /v1/subscriptions/rules` | additively whitelist rules | +| `push_remove_rules` | `DELETE /v1/subscriptions/rules` | remove specific rules | +| `push_list_rules` | `GET /v1/subscriptions` | snapshot of the currently active rule set | +| `push_set_rules` | `PUT /v1/subscriptions/rules` | atomic replace of the full multi-signer set | +| `push_broadcast` | direct submit _(interim)_ | publish a signed announcement | ```rust #[wire(request_id = 164)] @@ -86,46 +101,59 @@ async fn push_set_rules( ) -> Result>; ``` -#### Interim: direct broadcast +### Semantics -`push_broadcast` distributes an announcement **without using the Statement Store as the distribution layer**. The product sends only `{ topics, content }`. The host **sets the `signer` itself** — to the calling product's channel identity, host-set so the product cannot override or spoof it — and submits the announcement to the backend. The backend matches `(signer, topic)` against subscriber rules; matching, rate-limiting, dedup, and dispatch are unchanged — only the distribution layer differs. The product never sets `signer`, which is why it is absent from the request. +- **`push_add_rules`** additively whitelists the rules in the request. Adding a rule that is already present is a no-op for that rule. The call is **idempotent**: the post-state is the set union of the prior rules and the requested rules, regardless of how many were already present. +- **`push_remove_rules`** removes the named rules. Removing a rule that is not present is a no-op for that rule. The call is **idempotent**: the post-state is the prior set minus the requested rules. +- **`push_set_rules`** atomically replaces the **entire** rule set for the subscription with exactly the rules in the request, across all signers. Rules not present in the request are deleted; this is the only operation that affects rules for signers the caller did not name. +- **`push_list_rules`** returns the full active rule set as `Vec`, including the `signer` of each rule. It is read-only and reflects the subscription's current state after any prior add/remove/set. -```rust -#[wire(request_id = 172)] -async fn push_broadcast( - &self, cx: &CallContext, request: HostPushBroadcastRequest, -) -> Result>; -``` +Within a single subscription the same `(signer, topic)` pair is never duplicated, so the rule set behaves as a set rather than a multiset. -### Types +### Permission gating -`Topic` is reused from `v01::statement_store`. +`push_add_rules` and `push_set_rules` are gated by `DevicePermission::Notifications`: they create the capacity for the user to receive pushes, which requires consent. The host SHOULD prompt for the permission lazily on the first such call; if the user dismisses or declines, the call returns `PermissionDenied` and no rules are stored. -A rule is a `(signer, topic)` pair. `signer` is **mandatory**: the subscriber always names the publisher. +`push_remove_rules` and `push_list_rules` carry **no** `PermissionDenied` variant. Removing rules only de-escalates (it can never cause new notifications), and listing returns only the user's own rules to the user's own product; neither expands what the product can do without consent. + +### Types ```rust -pub struct HostPushAddRulesRequest { pub topics: Vec, pub signer: AccountId } -pub struct HostPushRemoveRulesRequest { pub topics: Vec, pub signer: AccountId } +/// One or more topics the subscriber wants to hear about from a single publisher. +pub struct PushRule { + /// The publisher whose signed statements should wake the user. + pub signer: AccountId, + /// Topics to match for this publisher. + pub topics: Vec, +} + +pub struct HostPushAddRulesRequest { pub rules: Vec } +pub struct HostPushRemoveRulesRequest { pub rules: Vec } pub struct HostPushListRulesRequest; -pub struct HostPushSetRulesRequest { pub topics: Vec, pub signer: AccountId } +pub struct HostPushSetRulesRequest { pub rules: Vec } + +pub struct HostPushAddRulesResponse; +pub struct HostPushRemoveRulesResponse; +pub struct HostPushSetRulesResponse; pub struct HostPushListRulesResponse { - pub topics: Vec, + /// The full active rule set for the calling subscription. + pub rules: Vec, } pub enum HostPushAddRulesError { /// The user has not granted `DevicePermission::Notifications`. The host - /// SHOULD prompt for the permission lazily on the first such call from - /// a product; if the user dismisses or declines, this variant is - /// returned and no rules are stored. + /// SHOULD prompt for the permission lazily on the first such call from a + /// product; if the user dismisses or declines, this variant is returned + /// and no rules are stored. PermissionDenied, /// The notification system is currently unavailable; no rules were stored. NotificationSystemUnavailable(String), - /// Catch-all. `reason` + /// Catch-all. Unknown { reason: String }, } -pub enum HostPushRemoveRulsError { +pub enum HostPushRemoveRulesError { NotificationSystemUnavailable(String), Unknown { reason: String }, } @@ -142,24 +170,34 @@ pub enum HostPushSetRulesError { } ``` -#### Interim: direct broadcast +### Interim: direct broadcast -The broadcast is **not** a Statement Store statement: it is a plain `{ topics, content }` the host submits with a host-set `signer`, so there is no `channel`, topic slots, or `expiry`. A later version can move distribution to the Statement Store without changing subscriber rules. +`push_broadcast` distributes an announcement without using the Statement Store as the distribution layer. The product sends only `{ topics, content }`. The host **sets the `signer` itself** to the calling product's identity, host-set so the product cannot override or spoof it, and submits the announcement to the backend. The backend matches `(signer, topic)` against subscriber rules; matching, rate-limiting, dedup, and dispatch are the same as for Statement-Store-sourced announcements. Only the distribution layer differs. The product never sets `signer`, which is why it is absent from the request. + +The broadcast is not a Statement Store statement: it is a plain `{ topics, content }` the host submits with a host-set `signer`, so there is no `channel`, topic slots, or `expiry`. The backend enforces its own per-publisher rate limits and notification payload size caps as defined in the v2 backend spec. ```rust +#[wire(request_id = 172)] +async fn push_broadcast( + &self, cx: &CallContext, request: HostPushBroadcastRequest, +) -> Result>; + pub struct PushBroadcastContent { pub title: String, pub body: String, - pub deeplink: Option, // route/URL to open on tap + /// Route or URL to open on tap. + pub deeplink: Option, } pub struct HostPushBroadcastRequest { - pub topics: Vec, // matched against subscriber rules (signer = caller) + /// Matched against subscriber rules; `signer` is set by the host to the caller. + pub topics: Vec, pub content: PushBroadcastContent, } pub struct HostPushBroadcastResponse { - pub message_hash: [u8; 32], // Blake2b-256 of the broadcast (dedup / audit) + /// Blake2b-256 of the broadcast, for dedup and audit. + pub message_hash: [u8; 32], } pub enum HostPushBroadcastError { @@ -167,3 +205,48 @@ pub enum HostPushBroadcastError { Unknown { reason: String }, } ``` + +## Drawbacks + +- **Two delivery paths during the interim.** `push_broadcast` and Statement-Store-sourced announcements coexist, so the backend matches the same `(signer, topic)` rules against two sources until distribution is unified. This is transitional complexity that the Future Directions section retires. +- **No per-product rule quota is specified here.** A product can add an unbounded number of rules to the user's subscription, subject only to whatever the backend imposes. Quota policy is left to the backend. +- **`push_set_rules` is a blunt instrument.** Because it replaces the whole multi-signer set, a product that holds a stale snapshot can clobber rules added by another product on the same subscription. Products that only mean to adjust their own publisher should prefer add/remove. + +## Testing, Security, and Privacy + +- **Testing.** Each method has a wire round-trip equality test (the repo's wire-equality and wire-table-loop smoke tests cover request/response shapes). Idempotency is verified by asserting that repeated `push_add_rules`/`push_remove_rules` calls converge to the same `push_list_rules` snapshot, and that `push_set_rules` yields exactly the posted set. The `PermissionDenied` path is exercised for add/set. +- **Push tokens are never exposed.** The token lives in the backend subscription keyed to the authenticated device; TrUAPI returns only rules. A product cannot read or derive the token. +- **Rule operations are scoped to the calling user's own subscription.** A product cannot read or mutate rules on another user's device. Add/remove/set/list all act on the subscription of the device the product runs on. +- **`signer` on broadcast is host-attested.** In `push_broadcast` the host sets `signer` to the calling product's identity; a product cannot broadcast under another publisher's identity. +- **Subscribe-vs-publish asymmetry.** A product may subscribe the user to **any** `signer` (the festival example subscribes the attendee to the organizer), but may only **broadcast as itself**. Naming a publisher in a rule grants no ability to publish as that publisher. + +## Performance, Ergonomics, and Compatibility + +### Performance + +Rule management is low-frequency control-plane traffic (subscribe/unsubscribe), not on any hot path. Delivery cost is borne by the backend tailer and dispatch, unchanged by this RFC. `push_broadcast` adds a direct submit path but reuses the existing matching and rate-limiting machinery. + +### Ergonomics + +The `PushRule { signer, topics }` shape groups topics per publisher, so a product subscribing to several topics from one signer sends one entry rather than N flat tuples. Idempotent add/remove let products converge state without read-modify-write races; `push_set_rules` is available when a product genuinely owns the whole set. + +### Compatibility + +These are new methods at fresh wire ids (164–172); no existing method changes, so there is no wire break for current clients. Hosts that do not implement the push backend return `NotificationSystemUnavailable`. + +## Prior Art and References + +- Push notifications, original (v1, peer-to-peer): https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/SyPN2yV6lx +- Push notifications backend design (v2, backend-mediated): https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r16YTVg5Ze +- RFC 0019 — Scheduled Push Notifications (`0019-scheduled-notifications.md`): host-mediated, OS-scheduler-backed local notifications, complementary to the backend-mediated delivery here. +- RFC 0008 — Statement Store: the `Topic` type and the statement model that the non-interim delivery path tails. + +## Unresolved Questions + +- **Broadcast authorization.** Should the host gate which products may call `push_broadcast`, or is host-attested `signer` sufficient on its own? An unbounded broadcast right lets any product wake its subscribers at the backend's rate-limit ceiling. +- **Rule quota.** Should TrUAPI surface a per-subscription rule cap (and a corresponding error) rather than deferring entirely to the backend? +- **List pagination.** `push_list_rules` returns the whole set in one response. A subscription with many rules may warrant pagination; left out until a concrete need appears. + +## Future Directions and Related Material + +The non-interim path moves announcement **distribution** onto the Statement Store: a publisher writes a signed statement, the backend tailer matches it against the same `(signer, topic)` rules, and delivery proceeds identically. At that point `push_broadcast` is retired as a distribution mechanism while subscriber rules and the four rule-management methods stay unchanged. The interim `push_broadcast` exists only so products can announce before the Statement-Store distribution path is live; designing rules around `(signer, topic)` from the start is what makes the eventual switch transparent to subscribers. From 3a128ddeaf682991131d456d12e92fdb1c1d335f Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 28 May 2026 09:46:24 +0200 Subject: [PATCH 20/20] update specs --- docs/rfcs/push-notification-subscriptions.md | 34 ++++++++++++-------- rust/crates/truapi/src/api/notifications.rs | 2 +- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/docs/rfcs/push-notification-subscriptions.md b/docs/rfcs/push-notification-subscriptions.md index 3fad48ba..bdfccdd3 100644 --- a/docs/rfcs/push-notification-subscriptions.md +++ b/docs/rfcs/push-notification-subscriptions.md @@ -11,7 +11,7 @@ pr: ## Summary -Adds four TrUAPI methods — `push_add_rules`, `push_remove_rules`, `push_list_rules`, `push_set_rules` — that expose the rule-management endpoints of the [v2 push backend spec](https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r16YTVg5Ze) to products. A rule is a `(signer, topic)` pair the product specifies in full: `signer` is the publisher whose signed statements should wake the user. The backend delivers a push to the user's device(s) whenever a signed statement matching a whitelisted `(signer, topic)` pair appears on the Statement Store. The product never sees push tokens; tokens live in the backend subscription keyed to the authenticated device. +Adds four TrUAPI methods — `push_add_rules`, `push_remove_rules`, `push_list_rules`, `push_set_rules` — that expose the rule-management endpoints of the [v2 push backend spec](https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r16YTVg5Ze) to products. A rule is a `(signer, topic)` pair: `signer` is the publisher whose signed statements should wake the user. The backend delivers a push to the user's device(s) whenever a signed statement matching a whitelisted `(signer, topic)` pair appears on the Statement Store. The product never sees push tokens; tokens live in the backend subscription keyed to the authenticated device. A fifth method, `push_broadcast`, is an **interim transport** that distributes an announcement without using the Statement Store as the distribution layer. The host submits the announcement to the push backend and **sets the publisher `signer` itself** to the calling product's identity (the product cannot override it), and the backend fans out using the same `(signer, topic)` rule matching. It is marked **(interim)** throughout. @@ -63,7 +63,7 @@ The design follows the v2 backend spec ([backend-mediated](https://hackmd.io/@1J ### Rule model -A rule is a `(signer, topic)` pair. `signer` is mandatory: the subscriber always names the publisher. Rules are grouped per signer on the wire as `PushRule { signer, topics }`, which is equivalent to the flat `(signer, topic)` tuple set the backend stores. `Topic` is reused from `v01::statement_store`. +A rule is a `(signer, topic)` pair. `signer` is mandatory: the subscriber always names the publisher. Rules are grouped per signer on the wire as `PushRule { signer, topics }`, which is equivalent to the flat `(signer, topic)` tuple set the backend stores. All rule operations are scoped to the **calling user's own subscription**: a product manages only the rules on the device it is running on, and cannot read or mutate another user's rules. @@ -71,13 +71,13 @@ All rule operations are scoped to the **calling user's own subscription**: a pro Each TrUAPI method maps to one backend endpoint: -| TrUAPI method | Backend endpoint | Purpose | -| ------------------- | -------------------------------- | --------------------------------------------- | -| `push_add_rules` | `POST /v1/subscriptions/rules` | additively whitelist rules | -| `push_remove_rules` | `DELETE /v1/subscriptions/rules` | remove specific rules | -| `push_list_rules` | `GET /v1/subscriptions` | snapshot of the currently active rule set | -| `push_set_rules` | `PUT /v1/subscriptions/rules` | atomic replace of the full multi-signer set | -| `push_broadcast` | direct submit _(interim)_ | publish a signed announcement | +| TrUAPI method | Backend endpoint | Purpose | +| ------------------- | -------------------------------- | ------------------------------------------- | +| `push_add_rules` | `POST /v1/subscriptions/rules` | additively whitelist rules | +| `push_remove_rules` | `DELETE /v1/subscriptions/rules` | remove specific rules | +| `push_list_rules` | `GET /v1/subscriptions` | snapshot of the currently active rule set | +| `push_set_rules` | `PUT /v1/subscriptions/rules` | atomic replace of the full multi-signer set | +| `push_broadcast` | direct submit _(interim)_ | publish a signed announcement | ```rust #[wire(request_id = 164)] @@ -176,6 +176,13 @@ pub enum HostPushSetRulesError { The broadcast is not a Statement Store statement: it is a plain `{ topics, content }` the host submits with a host-set `signer`, so there is no `channel`, topic slots, or `expiry`. The backend enforces its own per-publisher rate limits and notification payload size caps as defined in the v2 backend spec. +**Why not just use the existing `statementStore.submit` path.** Two reasons, in order of weight: + +1. **No 1→many encryption scheme exists.** Statements on the Statement Store are encrypted per-recipient: each statement is readable by exactly one addressee. Nothing in the v1 or v2 design defines a way to encrypt a single statement so that many subscribers can read it. The only short-term workaround would be plaintext statements, which puts announcement content in the clear on every node that propagates the topic and keeps it there until expiry. +2. **Timeline.** Host-direct submission to the push backend is the simpler engineering path until 1→many encryption (or a deliberate plaintext-with-explicit-mitigations decision) is settled. + +`push_broadcast` sidesteps both: announcement content is plaintext but authenticity-only, submission is gated by the host-attested product identity (the backend can rate-limit per publisher at the door), and nothing lands on SS. + ```rust #[wire(request_id = 172)] async fn push_broadcast( @@ -208,9 +215,9 @@ pub enum HostPushBroadcastError { ## Drawbacks +- **Broadcast content is not confidential.** `push_broadcast` is authenticity-only: `signer` is host-attested but `content` travels plaintext from the host to the backend and into the delivered push. Pairwise statement-store messages are end-to-end encrypted under `K(A,B)`; announcements are not. Products MUST NOT use `push_broadcast` for sensitive payloads. - **Two delivery paths during the interim.** `push_broadcast` and Statement-Store-sourced announcements coexist, so the backend matches the same `(signer, topic)` rules against two sources until distribution is unified. This is transitional complexity that the Future Directions section retires. - **No per-product rule quota is specified here.** A product can add an unbounded number of rules to the user's subscription, subject only to whatever the backend imposes. Quota policy is left to the backend. -- **`push_set_rules` is a blunt instrument.** Because it replaces the whole multi-signer set, a product that holds a stale snapshot can clobber rules added by another product on the same subscription. Products that only mean to adjust their own publisher should prefer add/remove. ## Testing, Security, and Privacy @@ -218,7 +225,6 @@ pub enum HostPushBroadcastError { - **Push tokens are never exposed.** The token lives in the backend subscription keyed to the authenticated device; TrUAPI returns only rules. A product cannot read or derive the token. - **Rule operations are scoped to the calling user's own subscription.** A product cannot read or mutate rules on another user's device. Add/remove/set/list all act on the subscription of the device the product runs on. - **`signer` on broadcast is host-attested.** In `push_broadcast` the host sets `signer` to the calling product's identity; a product cannot broadcast under another publisher's identity. -- **Subscribe-vs-publish asymmetry.** A product may subscribe the user to **any** `signer` (the festival example subscribes the attendee to the organizer), but may only **broadcast as itself**. Naming a publisher in a rule grants no ability to publish as that publisher. ## Performance, Ergonomics, and Compatibility @@ -243,10 +249,12 @@ These are new methods at fresh wire ids (164–172); no existing method changes, ## Unresolved Questions -- **Broadcast authorization.** Should the host gate which products may call `push_broadcast`, or is host-attested `signer` sufficient on its own? An unbounded broadcast right lets any product wake its subscribers at the backend's rate-limit ceiling. +- **1→many encryption.** A non-interim SS-based broadcast path is blocked on an encryption scheme that lets one statement be readable by many subscribers. Today each statement is addressed to a single recipient. Accepting plaintext statements is the alternative, but it puts announcement content in the clear on every node that propagates the topic. Which direction the eventual design takes is open. - **Rule quota.** Should TrUAPI surface a per-subscription rule cap (and a corresponding error) rather than deferring entirely to the backend? - **List pagination.** `push_list_rules` returns the whole set in one response. A subscription with many rules may warrant pagination; left out until a concrete need appears. ## Future Directions and Related Material -The non-interim path moves announcement **distribution** onto the Statement Store: a publisher writes a signed statement, the backend tailer matches it against the same `(signer, topic)` rules, and delivery proceeds identically. At that point `push_broadcast` is retired as a distribution mechanism while subscriber rules and the four rule-management methods stay unchanged. The interim `push_broadcast` exists only so products can announce before the Statement-Store distribution path is live; designing rules around `(signer, topic)` from the start is what makes the eventual switch transparent to subscribers. +The non-interim publish path is already exposed: a publisher can write a signed statement to the Statement Store today via `statementStore.submit` (wire id 62), and the v2 backend design has the tailer match `(signer, topic)` against the same subscriber rules. Designing rules around `(signer, topic)` from the start is what makes the eventual switch transparent to subscribers; whenever the SS-based delivery is wired up, `push_broadcast` is retired with no change to the rule-management surface. + +The real blocker to retiring `push_broadcast` is **not** the backend tailer plumbing but the missing 1→many encryption: SS statements are addressed to a single recipient today, and there is no defined scheme that lets one statement be readable by many subscribers without falling back to plaintext (with the content-visibility implications described in the interim-broadcast section). Future work picks one of: (a) define a 1→many encryption scheme for SS statements; (b) accept plaintext broadcast statements as a deliberate trade-off, with the visibility characteristics that implies. diff --git a/rust/crates/truapi/src/api/notifications.rs b/rust/crates/truapi/src/api/notifications.rs index 4bc0de3d..5c04a3ff 100644 --- a/rust/crates/truapi/src/api/notifications.rs +++ b/rust/crates/truapi/src/api/notifications.rs @@ -169,7 +169,7 @@ pub trait Notifications: Send + Sync { /// content: { title: "Web3 Summit", body: "Keynote moved to Hall A" }, /// }); /// result.match( - /// (value) => console.log(value.matched), + /// (value) => console.log(value.messageHash), /// (error) => console.error(error), /// ); /// ```