Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
74 changes: 55 additions & 19 deletions docs/rfcs/0020-push-notification-subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@
title: "Push Notification Subscriptions"
type: rfc
status: draft
owner: "@pgherveou"
owner: ["@pgherveou", "@sbalaguer"]
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.
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

- Push notifications, original (v1, peer-to-peer): https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/SyPN2yV6lx
Expand All @@ -23,35 +25,31 @@ 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

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 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
(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) match (organizer_id, T) |
| | -> deliver to this subscriber |
| +-----------------------+-----------------------+
| ^
| (2) compose signed statement |
|--- (3) statementStore.submit(statement) -+
| (2) push_broadcast({ topics: [T], | (3) host sets signer
| content }) | and submits to
|------------------------------------------+ the backend
```

## Detailed Design
Expand All @@ -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)]
Expand All @@ -89,17 +88,28 @@ async fn push_set_rules(
) -> Result<HostPushSetRulesResponse, CallError<HostPushSetRulesError>>;
```

#### Interim: direct broadcast

`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)]
async fn push_broadcast(
&self, cx: &CallContext, request: HostPushBroadcastRequest,
) -> Result<HostPushBroadcastResponse, CallError<HostPushBroadcastError>>;
```

### Types

`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<Topic>, pub signer: Option<ProductAccountId> }
pub struct HostPushRemoveRulesRequest { pub topics: Vec<Topic>, pub signer: Option<ProductAccountId> }
pub struct HostPushAddRulesRequest { pub topics: Vec<Topic>, pub signer: ProductAccountId }
pub struct HostPushRemoveRulesRequest { pub topics: Vec<Topic>, pub signer: ProductAccountId }
pub struct HostPushListRulesRequest;
pub struct HostPushSetRulesRequest { pub topics: Vec<Topic>, pub signer: Option<ProductAccountId> }
pub struct HostPushSetRulesRequest { pub topics: Vec<Topic>, pub signer: ProductAccountId }

pub struct HostPushListRulesResponse {
pub topics: Vec<Topic>,
Expand Down Expand Up @@ -133,3 +143,29 @@ pub enum HostPushSetRulesError {
Unknown { reason: String },
}
```

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

```rust
pub struct PushBroadcastContent {
pub title: String,
pub body: String,
pub deeplink: Option<String>, // route/URL to open on tap
}

pub struct HostPushBroadcastRequest {
pub topics: Vec<Topic>, // matched against subscriber rules (signer = caller)
pub content: PushBroadcastContent,
}

pub struct HostPushBroadcastResponse {
pub message_hash: [u8; 32], // Blake2b-256 of the broadcast (dedup / audit)
}

pub enum HostPushBroadcastError {
NotificationSystemUnavailable(String),
Unknown { reason: String },
}
```
38 changes: 33 additions & 5 deletions rust/crates/truapi/src/api/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use crate::versioned::notifications::{
HostPushAddRulesError, HostPushAddRulesRequest, HostPushAddRulesResponse,
HostPushBroadcastError, HostPushBroadcastRequest, HostPushBroadcastResponse,
HostPushListRulesError, HostPushListRulesRequest, HostPushListRulesResponse,
HostPushNotificationCancelError, HostPushNotificationCancelRequest,
HostPushNotificationCancelResponse, HostPushNotificationError, HostPushNotificationRequest,
Expand Down Expand Up @@ -73,15 +74,17 @@ pub trait Notifications: Send + Sync {
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.
/// 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"),
Expand All @@ -101,6 +104,7 @@ pub trait Notifications: Send + Sync {
/// ```ts
/// const result = await truapi.notifications.pushRemoveRules({
/// topics: ["0x00"],
/// signer: "0x…",
/// });
/// result.match(
/// () => console.log("ok"),
Expand Down Expand Up @@ -141,6 +145,7 @@ pub trait Notifications: Send + Sync {
/// ```ts
/// const result = await truapi.notifications.pushSetRules({
/// topics: ["0x00"],
/// signer: "0x…",
/// });
/// result.match(
/// () => console.log("ok"),
Expand All @@ -153,4 +158,27 @@ pub trait Notifications: Send + Sync {
cx: &CallContext,
request: HostPushSetRulesRequest,
) -> Result<HostPushSetRulesResponse, CallError<HostPushSetRulesError>>;

/// Publish an announcement to subscribers. Interim distribution that does
/// 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({
/// 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<HostPushBroadcastResponse, CallError<HostPushBroadcastError>>;
}
79 changes: 60 additions & 19 deletions rust/crates/truapi/src/v01/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Topic>,
/// Signer whose statements should trigger a push. Defaults to the
/// calling product's own identity when `None`.
pub signer: Option<ProductAccountId>,
/// Publisher whose statements should trigger a push. Pass the calling
/// product's own identity to self-subscribe.
pub signer: ProductAccountId,
}

/// Failure modes for [`HostPushAddRulesRequest`].
Expand All @@ -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<Topic>,
/// Signer scope. When `None`, removes rules for the calling product's
/// own identity.
pub signer: Option<ProductAccountId>,
/// Publisher scope of the rules to remove. Mandatory.
pub signer: ProductAccountId,
}

/// Failure modes for [`HostPushRemoveRulesRequest`].
Expand Down Expand Up @@ -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<Topic>,
/// Signer scope. When `None`, replaces rules for the calling product's
/// own identity.
pub signer: Option<ProductAccountId>,
/// Publisher scope whose rule set is being replaced. Mandatory.
pub signer: ProductAccountId,
}

/// Failure modes for [`HostPushSetRulesRequest`].
Expand All @@ -144,3 +143,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<String>,
}

/// Request to publish an announcement to subscribers via the interim direct
/// 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
/// as signer.
pub topics: Vec<Topic>,
/// 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 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 },
}
3 changes: 3 additions & 0 deletions rust/crates/truapi/src/versioned/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Loading