Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9de935b
notifications: add push_subscribe / push_unsubscribe (RFC 0020)
pgherveou May 15, 2026
2ddcc3b
Apply suggestion from @pgherveou
pgherveou May 15, 2026
b85b3d5
update
pgherveou May 15, 2026
8788def
fix: unit struct and doc examples for codegen compatibility
filvecchiato May 15, 2026
b43c701
Merge branch 'main' into notification-subscriptions
filvecchiato May 15, 2026
7290dfa
update
pgherveou May 18, 2026
6b26442
unnest
pgherveou May 18, 2026
a2497d5
simplify diagram
pgherveou May 18, 2026
a65f55b
Merge remote-tracking branch 'origin/main' into notification-subscrip…
pgherveou May 19, 2026
b51c50d
Merge branch 'main' into notification-subscriptions
filvecchiato May 19, 2026
74daaae
Simplify notification doc examples to use result.match() pattern
filvecchiato May 19, 2026
4602d4d
Rename BackendUnavailable to NotificationSystemUnavailable(String) an…
filvecchiato May 20, 2026
4cfaa6e
Update RFC worked example to use explicit signer instead of implicit …
filvecchiato May 20, 2026
792cd8e
Auto-number RFCs on merge via CI
filvecchiato May 20, 2026
e762c6e
Revert "Auto-number RFCs on merge via CI"
filvecchiato May 20, 2026
5cb2ebe
added broadcast method to RFC0020
SBalaguer May 26, 2026
8f3e718
edits
SBalaguer May 27, 2026
4e2502c
makes signer mandatory on rules
SBalaguer May 27, 2026
5b047d2
Merge pull request #139 from paritytech/sb/rfc0020-contribution
pgherveou May 27, 2026
efa4e6b
rename
pgherveou May 27, 2026
da93ed3
refactor text
pgherveou May 27, 2026
106b096
Merge branch 'main' into notification-subscriptions
pgherveou May 27, 2026
dc1b215
simplify
pgherveou May 27, 2026
2f73e93
use rfc skill
pgherveou May 27, 2026
3a128dd
update specs
pgherveou May 28, 2026
be1a1e4
Merge branch 'main' into notification-subscriptions
pgherveou May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions docs/rfcs/0020-push-notification-subscriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
---
title: "Push Notification Subscriptions"
type: rfc
status: draft
owner: "@pgherveou"
pr:
---

# RFC 0020 — 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). 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`).

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does the user's host know about pkProduct? Product instance running on user device and product instance running on admin device are two separate instances.

Maybe we should allow product to specify pkProduct in add_rules?

Overall it seems like this "worked example" is mixing motivation and implementation show-case which is very confusing to me


```
Publisher app Subscriber app
(organizer side) (attendee side)
| ^ |
| | |
| (6) push | | (1) pushAddRules({ topics: [ T_announcements ] })
| back to | |
| caller | |
| | v
| +------------------------------------+---+------+
| | Host |
| | injects signer = pkProduct, then forwards |
| | to push backend: |
| | rule (pkProduct, T_announcements) |
| | -> this subscriber app |
| +-----------------------+-----------------------+
| ^
| | (4) tail / match rule
| |
| +-----------------------+-----------------------+
| | Statement Store |
| +-----------------------+-----------------------+
| ^
| (2) compose signed statement |
|--- (3) statementStore.submit(statement) -+
```

## Detailed Design

### API

Each TrUAPI method mirrors 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 |

```rust
#[wire(request_id = 164)]
async fn push_add_rules(
&self, cx: &CallContext, request: HostPushAddRulesRequest,
) -> Result<HostPushAddRulesResponse, CallError<HostPushAddRulesError>>;

#[wire(request_id = 166)]
async fn push_remove_rules(
&self, cx: &CallContext, request: HostPushRemoveRulesRequest,
) -> Result<HostPushRemoveRulesResponse, CallError<HostPushRemoveRulesError>>;

#[wire(request_id = 168)]
async fn push_list_rules(
&self, cx: &CallContext, request: HostPushListRulesRequest,
) -> Result<HostPushListRulesResponse, CallError<HostPushListRulesError>>;

#[wire(request_id = 170)]
async fn push_set_rules(
&self, cx: &CallContext, request: HostPushSetRulesRequest,
) -> Result<HostPushSetRulesResponse, CallError<HostPushSetRulesError>>;
```

### Types

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

```rust
pub struct HostPushAddRulesRequest { pub topics: Vec<Topic> }
pub struct HostPushRemoveRulesRequest { pub topics: Vec<Topic> }
pub struct HostPushListRulesRequest;
pub struct HostPushSetRulesRequest { pub topics: Vec<Topic> }

pub struct HostPushListRulesResponse {
pub topics: Vec<Topic>,
}

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,
/// The host could not reach the push backend; no rules were stored.
BackendUnavailable,
Comment thread
pgherveou marked this conversation as resolved.
Outdated
/// Catch-all. `reason`
Unknown { reason: String },
}

pub enum HostPushRemoveRulsError {
BackendUnavailable,
Unknown { reason: String },
}

pub enum HostPushListRulesError {
BackendUnavailable,
Unknown { reason: String },
}

pub enum HostPushSetRulesError {
PermissionDenied,
BackendUnavailable,
Unknown { reason: String },
}
```
1 change: 1 addition & 0 deletions docs/rfcs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,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 | |
111 changes: 109 additions & 2 deletions rust/crates/truapi/src/api/notifications.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
//! Unified [`Notifications`] trait.

use crate::versioned::notifications::{
HostPushAddRulesError, HostPushAddRulesRequest, HostPushAddRulesResponse,
HostPushListRulesError, HostPushListRulesRequest, HostPushListRulesResponse,
HostPushNotificationCancelError, HostPushNotificationCancelRequest,
HostPushNotificationCancelResponse, HostPushNotificationError, HostPushNotificationRequest,
HostPushNotificationResponse,
HostPushNotificationResponse, HostPushRemoveRulesError, HostPushRemoveRulesRequest,
HostPushRemoveRulesResponse, HostPushSetRulesError, HostPushSetRulesRequest,
HostPushSetRulesResponse,
};
use crate::wire;
use crate::{CallContext, CallError};

/// Notification methods for locally-rendered push notifications.
/// 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:
///
/// - <https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/r16YTVg5Ze> — v2,
/// backend-mediated
/// - <https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/SyPN2yV6lx> — v1,
/// peer-to-peer (historical context)
pub trait Notifications: Send + Sync {
/// Send a push notification to the user.
///
Expand Down Expand Up @@ -58,4 +72,97 @@ pub trait Notifications: Send + Sync {
cx: &CallContext,
request: HostPushNotificationCancelRequest,
) -> Result<HostPushNotificationCancelResponse, CallError<HostPushNotificationCancelError>>;

/// 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.
///
/// ```ts
/// import { type Client } from "@parity/truapi";
///
/// export async function addAnnouncementsRules(
/// truapi: Client,
/// ): Promise<void> {
/// const result = await truapi.notifications.pushAddRules({
/// topics: ["0x00"],
/// });
///
/// if (result.isErr()) throw result.error;
/// }
/// ```
#[wire(request_id = 164)]
async fn push_add_rules(
&self,
cx: &CallContext,
request: HostPushAddRulesRequest,
) -> Result<HostPushAddRulesResponse, CallError<HostPushAddRulesError>>;

/// Remove one or more previously registered topics. Mirrors
/// `DELETE /v1/subscriptions/rules` from the v2 push backend spec.
///
/// ```ts
/// import { type Client } from "@parity/truapi";
///
/// export async function removeAnnouncementsRules(
/// truapi: Client,
/// ): Promise<void> {
/// const result = await truapi.notifications.pushRemoveRules({
/// topics: ["0x00"],
/// });
///
/// if (result.isErr()) throw result.error;
/// }
/// ```
#[wire(request_id = 166)]
async fn push_remove_rules(
&self,
cx: &CallContext,
request: HostPushRemoveRulesRequest,
) -> Result<HostPushRemoveRulesResponse, CallError<HostPushRemoveRulesError>>;

/// 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";
///
/// export async function listRules(truapi: Client) {
/// const result = await truapi.notifications.pushListRules({});
/// if (result.isErr()) throw result.error;
/// return result.value.topics;
/// }
/// ```
#[wire(request_id = 168)]
async fn push_list_rules(
&self,
cx: &CallContext,
request: HostPushListRulesRequest,
) -> Result<HostPushListRulesResponse, CallError<HostPushListRulesError>>;

/// 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<void> {
/// const result = await truapi.notifications.pushSetRules({
/// topics: ["0x00"],
/// });
///
/// if (result.isErr()) throw result.error;
/// }
/// ```
#[wire(request_id = 170)]
async fn push_set_rules(
&self,
cx: &CallContext,
request: HostPushSetRulesRequest,
) -> Result<HostPushSetRulesResponse, CallError<HostPushSetRulesError>>;
}
90 changes: 90 additions & 0 deletions rust/crates/truapi/src/v01/notifications.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use parity_scale_codec::{Decode, Encode};

use super::Topic;

/// Opaque identifier for a push notification, unique per product.
pub type NotificationId = u32;

Expand Down Expand Up @@ -46,3 +48,91 @@ pub struct HostPushNotificationCancelRequest {
/// The notification identifier returned by [`HostPushNotificationResponse`].
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.
///
/// 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.
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
pub struct HostPushAddRulesRequest {
/// Topics to register.
pub topics: Vec<Topic>,
}

/// Failure modes for [`HostPushAddRulesRequest`].
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
pub enum HostPushAddRulesError {
/// The user has not granted `DevicePermission::Notifications`.
PermissionDenied,
/// 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 one or more previously registered topics.
/// Topics not currently active are ignored.
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
pub struct HostPushRemoveRulesRequest {
/// Topics to remove.
pub topics: Vec<Topic>,
}

/// 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
/// 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 topics.
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
pub struct HostPushListRulesResponse {
/// Currently registered topics for this product, in unspecified order.
pub topics: Vec<Topic>,
}

/// 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 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 {
/// Topics that should be active for this product after the call.
pub topics: Vec<Topic>,
}

/// 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 },
}
12 changes: 12 additions & 0 deletions rust/crates/truapi/src/versioned/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,16 @@ versioned_type! {
pub enum HostPushNotificationCancelRequest { V1 => v01::HostPushNotificationCancelRequest }
pub enum HostPushNotificationCancelResponse { V1 }
pub enum HostPushNotificationCancelError { V1 => v01::GenericError }
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 }
}
Loading