diff --git a/docs/rfcs/0020-push-notification-subscriptions.md b/docs/rfcs/0020-push-notification-subscriptions.md index f7769cc4..8c7bd2c8 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: --- @@ -10,10 +10,12 @@ 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`). +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 @@ -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 @@ -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,17 +88,28 @@ async fn push_set_rules( ) -> Result>; ``` +#### 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>; +``` + ### 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, 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, @@ -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, // 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 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..4806119d 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, @@ -73,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"), @@ -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"), @@ -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"), @@ -153,4 +158,27 @@ 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: 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>; } diff --git a/rust/crates/truapi/src/v01/notifications.rs b/rust/crates/truapi/src/v01/notifications.rs index ab1477a8..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`]. @@ -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, +} + +/// 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, + /// 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 }, +} 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 } }