From a6b813650554c07cd44514d2802f1257904ace24 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Thu, 23 Apr 2026 12:57:44 +0200 Subject: [PATCH 1/8] RFC-0013: Favourites API --- docs/rfcs/0013-favourites-api.md | 115 +++++++++++++++++++++++++++++++ docs/rfcs/_index.md | 1 + 2 files changed, 116 insertions(+) create mode 100644 docs/rfcs/0013-favourites-api.md diff --git a/docs/rfcs/0013-favourites-api.md b/docs/rfcs/0013-favourites-api.md new file mode 100644 index 00000000..c1620e11 --- /dev/null +++ b/docs/rfcs/0013-favourites-api.md @@ -0,0 +1,115 @@ +# RFC-0013: Favourites API + +| | | +| --------------- | --------------------------------------------------------------- | +| **Start Date** | 2026-04-21 | +| **Description** | Let products read and manage the user's bookmarked apps | +| **Authors** | Filippo Vecchiato | + +## Summary + +Products can query, add and remove bookmarked apps from the host's local product catalogue. The host exposes a subscription for the installed-product list and two mutations for adding/removing entries. Browse (the on-chain discovery product) receives privileged access without an explicit permission prompt. + +## Motivation + +The host maintains a local catalogue of products the user has bookmarked (starred). Today this data lives in the host's IndexedDB and is inaccessible to products. Browse — the primary discovery surface — cannot show which apps are already installed or let the user bookmark new ones without direct database access. + +Exposing this catalogue: + +1. **Enables discovery UIs** — Browse can render install/uninstall affordances inline. +2. **Keeps the host authoritative** — mutations go through the host, which owns the storage schema and can enforce invariants. +3. **Supports other products** — any product with permission can read the installed list (e.g. a dashboard, launcher, or analytics tool). + +## Detailed Design + +### Data Model + +```rust +struct FavouriteProduct { + product_id: DotNsIdentifier, + installed: bool, + source: ProductSource, + created_at: Timestamp, + updated_at: Timestamp +} + +enum ProductSource { + Remote, // discovered via on-chain registry + Local // sideloaded or manually added +} +``` + +This mirrors the existing `ProductRecord` in the host's `products` table, exposing only the fields relevant to products. + +### API + +```rust +enum FavouritesErr { + NotConnected, + Rejected, + Unknown(GenericErr) +} + +fn host_favourites_subscribe( + callback: fn(Vec) +) -> Result; + +fn host_favourites_add( + product_id: DotNsIdentifier +) -> Result; + +fn host_favourites_forget( + product_id: DotNsIdentifier +) -> Result; +``` + +- `host_favourites_subscribe` delivers the full list on each callback; hosts MAY debounce. +- `host_favourites_add` upserts a `FavouriteProduct` with `source: Remote`, setting `created_at` on first install and `updated_at` on every call. Returns the resulting record. +- `host_favourites_forget` removes the product from the catalogue entirely. + +All methods require authentication (RFC-0009). + +### Permission Model + +Extends `DevicePermission` from RFC-0002: + +```rust +enum DevicePermission { + // ... existing variants ... + Favourites +} +``` + +| Permission | Grants | +|-----------|--------| +| `Favourites` | Read, add, and forget bookmarked products | + +**Browse privilege:** The host MAY grant implicit `Favourites` to Browse (the built-in discovery product) without prompting. This is analogous to how Browse currently writes directly to the products table. + +### Host Behaviour + +On `host_favourites_add`: +1. Upsert row in the products table with `installed: true`, `source: 'remote'`. +2. Set `created_at` if new, `updated_at` on every call. +3. Notify all active subscribers. + +On `host_favourites_forget`: +1. Delete the row from the products table. +2. Notify all active subscribers. + +The host SHOULD display a brief confirmation toast on install/forget for products other than Browse. + +Favourites are local to the host instance. Cross-host sync is out of scope for this RFC. + +## Drawbacks + +- **Full-list delivery.** No pagination or filtered subscriptions. Acceptable for typical catalogue sizes (tens to low hundreds). +- **Browse coupling.** Implicit privilege for Browse assumes a well-known product identity. If Browse's DotNS identifier changes, the host must update its allowlist. + +## Alternatives + +- + +## Unresolved Questions + +1. **Batch operations.** Should `host_favourites_add` accept multiple product IDs? diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index 2e478a63..c64b2355 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -17,3 +17,4 @@ created: 2026-03-13 | 0008 | [Statement Store Host API v0.2](0008-statement-store.md) | accepted | @johnthecat | [#118](https://github.com/paritytech/triangle-js-sdks/pull/118) | | 0009 | [Unauthenticated Product Access](0009-unauthenticated-product-access.md) | accepted | @filvecchiato | [#128](https://github.com/paritytech/triangle-js-sdks/pull/128) | | 0010 | [Root account access Host API](0010-get-root-account.md) | accepted | @johnthecat | [#126](https://github.com/paritytech/triangle-js-sdks/pull/126) | +| 0013 | [Favourites API](0013-favourites-api.md) | draft | @filvecchiato | [#141](https://github.com/paritytech/triangle-js-sdks/pull/141) | From fb07a476e0c0969f4a031347c37a5f3fc901b4ce Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Wed, 29 Apr 2026 10:32:31 +0200 Subject: [PATCH 2/8] Address review feedback on RFC-0013 Favourites API - Explain why Browse gets implicit privilege (default discovery product) - Rephrase motivation: hosts store data products can't access, bookmarks are one example; tie to DotNS being permissionless - Improve discovery UI bullet with concrete details - Lead Data Model section with the ProductRecord reference - Add NotSupported error variant for hosts without a favourites table - Remove empty Alternatives section --- docs/rfcs/0013-favourites-api.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/rfcs/0013-favourites-api.md b/docs/rfcs/0013-favourites-api.md index c1620e11..fc0a4b37 100644 --- a/docs/rfcs/0013-favourites-api.md +++ b/docs/rfcs/0013-favourites-api.md @@ -8,17 +8,19 @@ ## Summary -Products can query, add and remove bookmarked apps from the host's local product catalogue. The host exposes a subscription for the installed-product list and two mutations for adding/removing entries. Browse (the on-chain discovery product) receives privileged access without an explicit permission prompt. +Products can query, add and remove bookmarked apps from the host's local product catalogue. The host exposes a subscription for the installed-product list and two mutations for adding/removing entries. Browse (the on-chain discovery product) receives privileged access without an explicit permission prompt, because it is the default discovery surface for users to find and manage products. ## Motivation -The host maintains a local catalogue of products the user has bookmarked (starred). Today this data lives in the host's IndexedDB and is inaccessible to products. Browse — the primary discovery surface — cannot show which apps are already installed or let the user bookmark new ones without direct database access. +Hosts store data that products don't have access to but would benefit from. Bookmarked apps are one such example — some hosts maintain a local catalogue of products the user has bookmarked (starred), but this data is inaccessible to products. Browse — the primary discovery surface — cannot show which apps are already installed or let the user bookmark new ones without direct database access. + +Since DotNS is permissionless and anyone can publish a product, discovery is essential. Browse and other products should be able to help users find and keep track of apps that solve their problems. Exposing this catalogue: -1. **Enables discovery UIs** — Browse can render install/uninstall affordances inline. +1. **Enables discovery UIs** — Browse can render install/uninstall affordances inline, showing which apps are already bookmarked and letting users add or remove them directly from the discovery surface. 2. **Keeps the host authoritative** — mutations go through the host, which owns the storage schema and can enforce invariants. -3. **Supports other products** — any product with permission can read the installed list (e.g. a dashboard, launcher, or analytics tool). +3. **Supports other products** — any product with permission can read the installed list (e.g. a dashboard, launcher, or analytics tool). Because DotNS is permissionless and anyone can publish a product, discovery tools beyond Browse may emerge and benefit from the same API. ## Detailed Design @@ -39,7 +41,7 @@ enum ProductSource { } ``` -This mirrors the existing `ProductRecord` in the host's `products` table, exposing only the fields relevant to products. +Exposing only the fields relevant to products, this mirrors the existing `ProductRecord` in the host's `products` table (see e.g. [polkadot-desktop products table](https://github.com/nickvdao/polkadot-desktop)). ### API @@ -47,6 +49,7 @@ This mirrors the existing `ProductRecord` in the host's `products` table, exposi enum FavouritesErr { NotConnected, Rejected, + NotSupported, // host does not implement a favourites catalogue Unknown(GenericErr) } @@ -106,10 +109,6 @@ Favourites are local to the host instance. Cross-host sync is out of scope for t - **Full-list delivery.** No pagination or filtered subscriptions. Acceptable for typical catalogue sizes (tens to low hundreds). - **Browse coupling.** Implicit privilege for Browse assumes a well-known product identity. If Browse's DotNS identifier changes, the host must update its allowlist. -## Alternatives - -- - ## Unresolved Questions 1. **Batch operations.** Should `host_favourites_add` accept multiple product IDs? From 97cdbd2ec4ba4f4bb0ef14dcbfe675f23efbf538 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Fri, 5 Jun 2026 21:27:39 +0200 Subject: [PATCH 3/8] Add v0.3 to protocol versions in README Co-Authored-By: Claude Opus 4.6 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a7a206b9..1d5b9bad 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,8 @@ This repopulates the ignored generated TS under `js/packages/truapi/`, including ## Protocol versions - **v0.1**: initial protocol version. -- **v0.2**: current protocol version. See [`docs/design/v02-changes.md`](docs/design/v02-changes.md) for the rationale behind each change. +- **v0.2**: See [`docs/design/v02-changes.md`](docs/design/v02-changes.md) for the rationale behind each change. +- **v0.3**: current protocol version. Adds the Favourites API ([RFC 0013](docs/rfcs/0013-favourites-api.md)). ## Deploy From 555deabe01d9f3250f3a1192625d42fd804100ca Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Fri, 5 Jun 2026 21:30:24 +0200 Subject: [PATCH 4/8] Revert "Add v0.3 to protocol versions in README" This reverts commit 97cdbd2ec4ba4f4bb0ef14dcbfe675f23efbf538. --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 1d5b9bad..a7a206b9 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,7 @@ This repopulates the ignored generated TS under `js/packages/truapi/`, including ## Protocol versions - **v0.1**: initial protocol version. -- **v0.2**: See [`docs/design/v02-changes.md`](docs/design/v02-changes.md) for the rationale behind each change. -- **v0.3**: current protocol version. Adds the Favourites API ([RFC 0013](docs/rfcs/0013-favourites-api.md)). +- **v0.2**: current protocol version. See [`docs/design/v02-changes.md`](docs/design/v02-changes.md) for the rationale behind each change. ## Deploy From c842c66d481d8adb154f38033d1afe370420b370 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Fri, 5 Jun 2026 21:33:38 +0200 Subject: [PATCH 5/8] Add Favourites API trait, types, and RFC Introduce the `Favourites` trait with subscribe, add, and forget methods for managing the host's bookmarked-product catalogue. --- docs/rfcs/0013-favourites-api.md | 110 ++++++------------ rust/crates/truapi/src/api/favourites.rs | 73 ++++++++++++ rust/crates/truapi/src/api/mod.rs | 4 + rust/crates/truapi/src/v01/favourites.rs | 70 +++++++++++ rust/crates/truapi/src/v01/mod.rs | 2 + .../crates/truapi/src/versioned/favourites.rs | 31 +++++ rust/crates/truapi/src/versioned/mod.rs | 1 + 7 files changed, 218 insertions(+), 73 deletions(-) create mode 100644 rust/crates/truapi/src/api/favourites.rs create mode 100644 rust/crates/truapi/src/v01/favourites.rs create mode 100644 rust/crates/truapi/src/versioned/favourites.rs diff --git a/docs/rfcs/0013-favourites-api.md b/docs/rfcs/0013-favourites-api.md index fc0a4b37..8ffad800 100644 --- a/docs/rfcs/0013-favourites-api.md +++ b/docs/rfcs/0013-favourites-api.md @@ -1,26 +1,17 @@ -# RFC-0013: Favourites API +--- +title: "Favourites API" +owner: "@filippovecchiato" +--- -| | | -| --------------- | --------------------------------------------------------------- | -| **Start Date** | 2026-04-21 | -| **Description** | Let products read and manage the user's bookmarked apps | -| **Authors** | Filippo Vecchiato | +# RFC 0013 — Favourites API ## Summary -Products can query, add and remove bookmarked apps from the host's local product catalogue. The host exposes a subscription for the installed-product list and two mutations for adding/removing entries. Browse (the on-chain discovery product) receives privileged access without an explicit permission prompt, because it is the default discovery surface for users to find and manage products. +Products can query, add, and remove bookmarked apps from the host's local product catalogue. The host exposes a subscription for the bookmarked-product list and two mutations for adding/removing entries. ## Motivation -Hosts store data that products don't have access to but would benefit from. Bookmarked apps are one such example — some hosts maintain a local catalogue of products the user has bookmarked (starred), but this data is inaccessible to products. Browse — the primary discovery surface — cannot show which apps are already installed or let the user bookmark new ones without direct database access. - -Since DotNS is permissionless and anyone can publish a product, discovery is essential. Browse and other products should be able to help users find and keep track of apps that solve their problems. - -Exposing this catalogue: - -1. **Enables discovery UIs** — Browse can render install/uninstall affordances inline, showing which apps are already bookmarked and letting users add or remove them directly from the discovery surface. -2. **Keeps the host authoritative** — mutations go through the host, which owns the storage schema and can enforce invariants. -3. **Supports other products** — any product with permission can read the installed list (e.g. a dashboard, launcher, or analytics tool). Because DotNS is permissionless and anyone can publish a product, discovery tools beyond Browse may emerge and benefit from the same API. +Hosts maintain a local catalogue of products the user has bookmarked, but this data is inaccessible to products. Discovery surfaces like Browse cannot show which apps are already bookmarked or let the user add new ones without this API. ## Detailed Design @@ -28,87 +19,60 @@ Exposing this catalogue: ```rust struct FavouriteProduct { - product_id: DotNsIdentifier, - installed: bool, - source: ProductSource, - created_at: Timestamp, - updated_at: Timestamp + product_id: String, + source: FavouriteProductSource, + created_at: u64, + updated_at: u64, } -enum ProductSource { - Remote, // discovered via on-chain registry - Local // sideloaded or manually added +enum FavouriteProductSource { + Remote, + Local, } ``` -Exposing only the fields relevant to products, this mirrors the existing `ProductRecord` in the host's `products` table (see e.g. [polkadot-desktop products table](https://github.com/nickvdao/polkadot-desktop)). +`product_id` is a DotNS identifier. `source` distinguishes on-chain registry discoveries (`Remote`) from sideloaded entries (`Local`). Timestamps are Unix seconds. ### API -```rust -enum FavouritesErr { - NotConnected, - Rejected, - NotSupported, // host does not implement a favourites catalogue - Unknown(GenericErr) -} +Three methods on a `Favourites` trait: -fn host_favourites_subscribe( - callback: fn(Vec) -) -> Result; +**`subscribe`** — streams the full bookmarked-product list on each change. Hosts MAY debounce. -fn host_favourites_add( - product_id: DotNsIdentifier -) -> Result; +**`add`** — upserts a product with `source: Remote`, setting `created_at` on first add and `updated_at` on every call. Returns the resulting record. -fn host_favourites_forget( - product_id: DotNsIdentifier -) -> Result; -``` - -- `host_favourites_subscribe` delivers the full list on each callback; hosts MAY debounce. -- `host_favourites_add` upserts a `FavouriteProduct` with `source: Remote`, setting `created_at` on first install and `updated_at` on every call. Returns the resulting record. -- `host_favourites_forget` removes the product from the catalogue entirely. +**`forget`** — removes the product from the catalogue. -All methods require authentication (RFC-0009). - -### Permission Model +### Error Handling -Extends `DevicePermission` from RFC-0002: +Subscription errors and mutation errors use `CallError` with a domain enum: ```rust -enum DevicePermission { - // ... existing variants ... - Favourites +enum HostFavouritesSubscribeError { + Unknown { reason: String }, } -``` -| Permission | Grants | -|-----------|--------| -| `Favourites` | Read, add, and forget bookmarked products | +enum HostFavouritesAddError { + Unknown { reason: String }, +} -**Browse privilege:** The host MAY grant implicit `Favourites` to Browse (the built-in discovery product) without prompting. This is analogous to how Browse currently writes directly to the products table. +enum HostFavouritesForgetError { + NotFound, + Unknown { reason: String }, +} +``` -### Host Behaviour +Permission denial and unsupported-host cases are handled by `CallError::Denied` and `CallError::Unsupported`. -On `host_favourites_add`: -1. Upsert row in the products table with `installed: true`, `source: 'remote'`. -2. Set `created_at` if new, `updated_at` on every call. -3. Notify all active subscribers. +### Permission Model -On `host_favourites_forget`: -1. Delete the row from the products table. -2. Notify all active subscribers. +The host SHOULD prompt the user before granting a product access to the favourites catalogue. The host MAY grant implicit access to Browse (the built-in discovery product) without prompting. -The host SHOULD display a brief confirmation toast on install/forget for products other than Browse. +### Host Behaviour -Favourites are local to the host instance. Cross-host sync is out of scope for this RFC. +Favourites are local to the host instance. Cross-host sync is out of scope. ## Drawbacks - **Full-list delivery.** No pagination or filtered subscriptions. Acceptable for typical catalogue sizes (tens to low hundreds). -- **Browse coupling.** Implicit privilege for Browse assumes a well-known product identity. If Browse's DotNS identifier changes, the host must update its allowlist. - -## Unresolved Questions - -1. **Batch operations.** Should `host_favourites_add` accept multiple product IDs? +- **Browse coupling.** Implicit privilege for Browse assumes a well-known product identity. diff --git a/rust/crates/truapi/src/api/favourites.rs b/rust/crates/truapi/src/api/favourites.rs new file mode 100644 index 00000000..da8ff787 --- /dev/null +++ b/rust/crates/truapi/src/api/favourites.rs @@ -0,0 +1,73 @@ +//! Unified [`Favourites`] trait. + +use crate::versioned::favourites::{ + HostFavouritesAddError, HostFavouritesAddRequest, HostFavouritesAddResponse, + HostFavouritesForgetError, HostFavouritesForgetRequest, HostFavouritesSubscribeError, + HostFavouritesSubscribeItem, +}; +use crate::wire; +use crate::{CallContext, CallError, Subscription}; + +/// Bookmarked-product catalogue methods. +pub trait Favourites: Send + Sync { + /// Subscribe to the user's bookmarked products. + /// + /// ```ts + /// import { firstValueFrom, from } from "rxjs"; + /// + /// const favourites = await firstValueFrom( + /// from(truapi.favourites.subscribe()), + /// ); + /// console.log("favourites:", favourites); + /// ``` + #[wire(start_id = 164)] + async fn subscribe( + &self, + _cx: &CallContext, + ) -> Result< + Subscription, + CallError, + > { + Err(CallError::unavailable()) + } + + /// Add a product to the favourites catalogue. + /// + /// ```ts + /// const result = await truapi.favourites.add({ + /// productId: "some-product.dot", + /// }); + /// result.match( + /// (value) => console.log("added:", value), + /// (error) => console.error("add failed:", error), + /// ); + /// ``` + #[wire(request_id = 168)] + async fn add( + &self, + _cx: &CallContext, + _request: HostFavouritesAddRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Remove a product from the favourites catalogue. + /// + /// ```ts + /// const result = await truapi.favourites.forget({ + /// productId: "some-product.dot", + /// }); + /// result.match( + /// () => console.log("forgotten"), + /// (error) => console.error("forget failed:", error), + /// ); + /// ``` + #[wire(request_id = 170)] + async fn forget( + &self, + _cx: &CallContext, + _request: HostFavouritesForgetRequest, + ) -> Result<(), CallError> { + Err(CallError::unavailable()) + } +} diff --git a/rust/crates/truapi/src/api/mod.rs b/rust/crates/truapi/src/api/mod.rs index 957509e4..b0c2040c 100644 --- a/rust/crates/truapi/src/api/mod.rs +++ b/rust/crates/truapi/src/api/mod.rs @@ -5,6 +5,7 @@ pub mod chain; pub mod chat; pub mod coin_payment; pub mod entropy; +pub mod favourites; pub mod local_storage; pub mod notifications; pub mod payment; @@ -21,6 +22,7 @@ pub use chain::Chain; pub use chat::Chat; pub use coin_payment::CoinPayment; pub use entropy::Entropy; +pub use favourites::Favourites; pub use local_storage::LocalStorage; pub use notifications::Notifications; pub use payment::Payment; @@ -39,6 +41,7 @@ pub trait TrUApi: + Chat + CoinPayment + Entropy + + Favourites + LocalStorage + Notifications + Payment @@ -60,6 +63,7 @@ impl TrUApi for T where + Chat + CoinPayment + Entropy + + Favourites + LocalStorage + Notifications + Payment diff --git a/rust/crates/truapi/src/v01/favourites.rs b/rust/crates/truapi/src/v01/favourites.rs new file mode 100644 index 00000000..97786a40 --- /dev/null +++ b/rust/crates/truapi/src/v01/favourites.rs @@ -0,0 +1,70 @@ +use parity_scale_codec::{Decode, Encode}; + +/// How a product entered the favourites catalogue. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum FavouriteProductSource { + /// Discovered via the on-chain registry. + Remote, + /// Sideloaded or manually added. + Local, +} + +/// A bookmarked product in the host's local catalogue. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct FavouriteProduct { + /// DotNS identifier of the product. + pub product_id: String, + /// How the product was added. + pub source: FavouriteProductSource, + /// Unix timestamp (seconds) when first bookmarked. + pub created_at: u64, + /// Unix timestamp (seconds) of the most recent update. + pub updated_at: u64, +} + +/// Request to add a product to the favourites catalogue. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostFavouritesAddRequest { + /// DotNS identifier of the product to add. + pub product_id: String, +} + +/// Response after adding a product to favourites. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostFavouritesAddResponse { + /// The resulting catalogue entry. + pub product: FavouriteProduct, +} + +/// Request to remove a product from the favourites catalogue. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostFavouritesForgetRequest { + /// DotNS identifier of the product to remove. + pub product_id: String, +} + +/// Error from [`crate::api::Favourites::subscribe`]. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostFavouritesSubscribeError { + /// Catch-all. + Unknown { reason: String }, +} + +/// Error from [`crate::api::Favourites::add`]. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostFavouritesAddError { + /// Catch-all. + Unknown { reason: String }, +} + +/// Error from [`crate::api::Favourites::forget`]. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostFavouritesForgetError { + /// The product was not in the catalogue. + NotFound, + /// Catch-all. + Unknown { reason: String }, +} + +/// Item pushed to favourites subscribers: the full list of bookmarked products. +pub type HostFavouritesSubscribeItem = Vec; diff --git a/rust/crates/truapi/src/v01/mod.rs b/rust/crates/truapi/src/v01/mod.rs index 8b34df5a..9150a8ea 100644 --- a/rust/crates/truapi/src/v01/mod.rs +++ b/rust/crates/truapi/src/v01/mod.rs @@ -6,6 +6,7 @@ mod chat; mod coin_payment; mod common; mod entropy; +mod favourites; mod local_storage; mod notifications; mod payment; @@ -24,6 +25,7 @@ pub use chat::*; pub use coin_payment::*; pub use common::*; pub use entropy::*; +pub use favourites::*; pub use local_storage::*; pub use notifications::*; pub use payment::*; diff --git a/rust/crates/truapi/src/versioned/favourites.rs b/rust/crates/truapi/src/versioned/favourites.rs new file mode 100644 index 00000000..28bd37d6 --- /dev/null +++ b/rust/crates/truapi/src/versioned/favourites.rs @@ -0,0 +1,31 @@ +//! Versioned wrappers for [`Favourites`](crate::api::Favourites) methods. + +use crate::v01; + +truapi_macros::versioned_type! { + pub enum HostFavouritesSubscribeItem { V1 => v01::HostFavouritesSubscribeItem } +} + +truapi_macros::versioned_type! { + pub enum HostFavouritesSubscribeError { V1 => v01::HostFavouritesSubscribeError } +} + +truapi_macros::versioned_type! { + pub enum HostFavouritesAddRequest { V1 => v01::HostFavouritesAddRequest } +} + +truapi_macros::versioned_type! { + pub enum HostFavouritesAddResponse { V1 => v01::HostFavouritesAddResponse } +} + +truapi_macros::versioned_type! { + pub enum HostFavouritesAddError { V1 => v01::HostFavouritesAddError } +} + +truapi_macros::versioned_type! { + pub enum HostFavouritesForgetRequest { V1 => v01::HostFavouritesForgetRequest } +} + +truapi_macros::versioned_type! { + pub enum HostFavouritesForgetError { V1 => v01::HostFavouritesForgetError } +} diff --git a/rust/crates/truapi/src/versioned/mod.rs b/rust/crates/truapi/src/versioned/mod.rs index 9da72067..ff0e4569 100644 --- a/rust/crates/truapi/src/versioned/mod.rs +++ b/rust/crates/truapi/src/versioned/mod.rs @@ -35,6 +35,7 @@ pub mod chain; pub mod chat; pub mod coin_payment; pub mod entropy; +pub mod favourites; pub mod local_storage; pub mod notifications; pub mod payment; From 59ac438de5576adb40ffe44f85a7f92c582b7ec0 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 8 Jun 2026 12:01:01 +0200 Subject: [PATCH 6/8] Fix rustfmt formatting on subscribe return type --- rust/crates/truapi/src/api/favourites.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/rust/crates/truapi/src/api/favourites.rs b/rust/crates/truapi/src/api/favourites.rs index da8ff787..64dab275 100644 --- a/rust/crates/truapi/src/api/favourites.rs +++ b/rust/crates/truapi/src/api/favourites.rs @@ -24,10 +24,8 @@ pub trait Favourites: Send + Sync { async fn subscribe( &self, _cx: &CallContext, - ) -> Result< - Subscription, - CallError, - > { + ) -> Result, CallError> + { Err(CallError::unavailable()) } From 8dc4978caafd1bef8c60f67c895b25fb0581e181 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 8 Jun 2026 13:23:12 +0200 Subject: [PATCH 7/8] Add product manifest RFC and host implementation guide --- docs/design/product-manifest.md | 142 +++++++++++ docs/rfcs/product-manifest.md | 418 ++++++++++++++++++++++++++++++++ 2 files changed, 560 insertions(+) create mode 100644 docs/design/product-manifest.md create mode 100644 docs/rfcs/product-manifest.md diff --git a/docs/design/product-manifest.md b/docs/design/product-manifest.md new file mode 100644 index 00000000..a2db20ff --- /dev/null +++ b/docs/design/product-manifest.md @@ -0,0 +1,142 @@ +--- +title: "Product Manifest — Host Implementation Guide" +type: design +--- + +# Product Manifest — Host Implementation Guide + +This document complements [RFC — Product Manifest Format](../rfcs/product-manifest.md) with concrete data structures and a step-by-step guide for host implementors. The RFC is the normative source; this page is a quick-reference. + +## Data Structures + +The manifest system uses two layers of structured data: a **root manifest** at the product's dotNS base name and one **executable manifest** per modality subname. + +### Root Manifest + +```typescript +type RootManifest = { + $v: 1; + displayName: string; + description: string; + icon: Icon; +}; + +type Icon = { + cid: string; // Bulletin-chain CID + format: "jpeg" | "png"; +}; +``` + +### Executable Manifest + +```typescript +type ExecutableManifest = AppManifest | WidgetManifest | WorkerManifest; + +type CommonExecutableFields = { + $v: 1; + appVersion: SemVer; +}; + +type AppManifest = CommonExecutableFields & { + kind: "app"; +}; + +type WidgetManifest = CommonExecutableFields & { + kind: "widget"; + description?: string; + dimensions: { + height: number[]; // supported grid-step heights + width?: number; // defaults to 1 + }; +}; + +type WorkerManifest = CommonExecutableFields & { + kind: "worker"; + entrypoint: string; + includes: Record<"chat" | "pocket", boolean>; +}; + +type SemVer = [major: number, minor: number, patch: number, build?: string]; +``` + +### Subname Convention + +| Subname | Text-record key | Carries | +|-----------------------------|-----------------|----------------------------| +| `.dot` | `manifest` | Root manifest | +| `app..dot` | `executable` | App executable manifest | +| `widget..dot` | `executable` | Widget executable manifest | +| `worker..dot` | `executable` | Worker executable manifest | + +Absence of a subname means the product does not provide that executable. + +## Resolution Flow + +A host resolves a product from its dotNS base name `B` in eight steps: + +``` +1. node = namehash(B) +2. resolver = IDotnsRegistry.resolver(node) + └─ address(0) → product does not exist; stop +3. json = IDotnsContentResolver.text(node, "manifest") + └─ empty → product does not exist; stop +4. Parse JSON, validate $v and RootManifest schema + └─ failure → malformed; surface diagnostic +5. (optional) author = IDotnsRegistry.owner(node) +6. For each executable type the host can render: + subnode = namehash("..dot") + repeat steps 2–4 with text(subnode, "executable") +7. (optional) Verify owner(subnode) == owner(node) +8. Fetch bytes: GET /ipfs/ + verify fetched bytes match the CID +``` + +### dotNS Dry-Run Origin + +All reads use a `ReviveApi.call(origin, ...)` dry-run RPC. The origin MUST be the deterministic Revive system account, not a real keypair: + +```rust +fn pallet_account(pallet_id: &[u8; 8]) -> [u8; 32] { + let mut account = [0u8; 32]; + account[..4].copy_from_slice(b"modl"); + account[4..12].copy_from_slice(pallet_id); + account +} + +let origin = pallet_account(b"py/reviv"); +// 0x6d6f646c70792f7265766976000000...0000 +``` + +This account need not exist or hold a balance. + +## Bulletin Constants + +Fixed by the Bulletin chain protocol. + +| Constant | Value | Meaning | +|----------------|--------------------|---------------------------------------| +| CID version | `1` | CIDv1 | +| Multicodec | `0x55` (`raw`) | Stored bytes addressed as raw payload | +| Multihash code | `0x12` (`sha-256`) | Hash algorithm for the CID | +| Digest length | `32` bytes | SHA-256 output size | + +A Bulletin CID is `CIDv1(raw, sha256(data))`. + +## Error Handling + +| Condition | Host action | +|------------------------------------------|------------------------------------------| +| No resolver / empty root manifest | Product does not exist | +| Unknown `$v` | Undiscoverable; skip, surface diagnostic | +| Malformed JSON / schema validation fail | Do not launch; surface diagnostic | +| Unknown `icon.format` or icon CID fails | Render placeholder; product launchable | +| Missing executable subname | Product does not provide that executable | +| `kind` does not match subname label | Skip that executable | +| Executable CID unreachable / mismatch | Refuse to launch that executable | +| Subname owner differs (strict provenance)| Skip that executable | + +## Caching + +- **Manifests**: cache by base name. Detect re-publish by re-reading the text record or comparing `appVersion`. +- **Icon and executable bytes**: cacheable indefinitely by CID (content-addressed; same CID = same bytes). +- dotNS provides no push notifications; hosts must poll. diff --git a/docs/rfcs/product-manifest.md b/docs/rfcs/product-manifest.md new file mode 100644 index 00000000..88985694 --- /dev/null +++ b/docs/rfcs/product-manifest.md @@ -0,0 +1,418 @@ +--- +title: "Product Manifest Format" +owner: "@johnthecat" +--- + +# RFC — Product Manifest Format + +## Summary + +A two-level product manifest used by Polkadot Hosts to discover, validate, and launch product executables. + +- The **root manifest** carries product-wide metadata (displayName, icon, description) and lives at the product's dotNS base name. Authorship is read from dotNS itself (the on-chain owner of the name) rather than declared in the manifest. +- One or more **executable manifests** describe individual executables (App, Widget, Worker). Each pins a product-defined version and a Bulletin-chain CID for the executable artifact, and lives at a well-known subname of the base name (`app..dot`, `widget..dot`, `worker..dot`). + +Manifests are JSON, stored inline in dotNS text records; referenced binary content (executable bytes, icons) lives on the Bulletin chain and is addressed by CID. + +## Motivation + +### Background + +The Polkadot ecosystem renders third-party applications inside first-party user-agents — **Hosts** (Polkadot Desktop, Polkadot Mobile, the Polkadot Website). A third-party application is called a **Product**, owned by its developer rather than any single Host. + +A product can expose one or more **modalities**, each a distinct user-facing surface: + +- **App** — full-screen web application. +- **Widget** — small web application mounted on a dashboard. +- **Pocket** — passive surfaces such as cards, tickets, or certificates, served by a background JS worker. +- **Chat** — chat bots and chat-room integrations, also served by a background JS worker. + +A modality is delivered by an **executable**. v1 defines three executable types: + +- **App** — the web application backing the App modality. +- **Widget** — the web application backing the Widget modality. +- **Worker** — a single background process. It may back Pocket and/or Chat, or serve no user-facing surface at all and run purely as background logic (see [Why one Worker, not per modality](#executable-manifest-v1)). + +Throughout this RFC, *modality* means a user-facing surface; *executable* means a deployable artifact. + +Two on-chain systems sit under this RFC: + +- **Bulletin chain** — stores binary blobs. Each blob is content-addressed by an identifier ("CID") that doubles as its integrity check. Executable artifacts and product icons live there. +- **dotNS** — Polkadot's on-chain naming system, implemented as contracts on Asset Hub. A name owns text records that hold small structured metadata. Manifests live there. + +### Requirements + +For a Host to discover, verify, and launch a product, it needs a static, on-chain, authenticated description binding a dotNS name to a specific executable revision. Without a standardized manifest, every Host invents its own discovery convention. The manifest format must therefore: + +1. Provide static product-wide metadata: displayName, icon, and description. +2. Provide, for each executable, a version and content identifier sufficient to fetch and verify the artifact. +3. Be discoverable through a storage call or JSON-RPC call to a node. +4. Be encoded in a format any client environment can parse with off-the-shelf tooling. +5. Fit inside dotNS text records as a single inline payload. +6. Be versionable. + +## Detailed Design + +### Overview + +A product is rooted at a **dotNS base name** (e.g. `hackm3.dot`). The base name's text records carry the **root manifest**. Each executable is rooted at a well-known **subname** of the base name (e.g. `widget.hackm3.dot`), whose text records carry the corresponding **executable manifest**. + +**Terminology.** `` is the label portion of the base name (`hackm3` for `hackm3.dot`). + +``` +hackm3.dot → root manifest (displayName, icon, description) +app.hackm3.dot → executable manifest (App) +widget.hackm3.dot → executable manifest (Widget) +worker.hackm3.dot → executable manifest (Worker; serves Pocket and/or Chat) +``` + +A Host discovers a product's executables by querying these subnames. Absence of a subname means the product does not provide that executable. + +### Encoding and storage + +Manifests are encoded as **UTF-8 JSON** and stored **inline** in a single, well-known text record key on the (sub)name: the value of that text record is the manifest JSON itself. All binary references in the manifest are short CID strings; no binary payloads are inlined. + +The text-record key is fixed by this RFC: + +| Subject | Text-record key | +|------------------------------------|-----------------| +| Root manifest (on the base name) | `manifest` | +| Executable manifest (on a subname) | `executable` | + +Hosts MUST query exactly these keys; publishers MUST write under exactly these keys. Keeping them distinct means a wrong-layer query (e.g. `manifest` on `app..dot`) returns an empty value instead of partially parsing a payload of the wrong shape. + +A v1 root manifest is well under 1 KB; an executable manifest is ~200 B. Manifests fit within typical text-record budgets; the exact figure will be confirmed by the dotNS team's Proof-of-Concept (see [Unresolved Questions](#unresolved-questions)). v1 defines no preimage fallback: a manifest that cannot fit MUST be shrunk by the publisher. + +### Versioning + +Every manifest carries `$v` as its first field — a numeric schema-version discriminator. This RFC defines `$v: 1`. Hosts MUST treat any manifest whose `$v` they do not recognise as an undiscoverable product: skip it, surface a diagnostic, and keep working. + +### Root manifest (v1) + +The root manifest describes the product as a whole and is the resolution entry point: a Host parses it first, then probes for executable subnames. + +```typescript +type RootManifest = { + $v: 1; + displayName: string; // Human-readable product name. UTF-8. + description: string; // Short description shown in launchers/lists. + icon: Icon; // Product icon used by every Host surface. +}; + +type Icon = { + cid: string; // Raw Bulletin-chain CID; used verbatim to fetch icon bytes. + format: 'jpeg' | 'png'; +}; +``` + +The icon is always deployed on the Bulletin chain — there is no inline-icon variant in v1. Hosts MUST verify fetched icon bytes against `cid` per the chain's content-addressing rules. An unknown `format` MUST be treated as a malformed manifest. + +### Executable manifest (v1) + +An executable manifest describes one deployable artifact and lives on a well-known subname keyed by its executable type. + +```typescript +type ExecutableManifest = + | AppManifest + | WidgetManifest + | WorkerManifest; + +type CommonExecutableFields = { + $v: 1; + appVersion: SemVer; // Product-defined SemVer of this executable. +}; + +type AppManifest = CommonExecutableFields & { + kind: 'app'; +}; + +type WidgetManifest = CommonExecutableFields & { + kind: 'widget'; + description?: string; // Optional tagline shown on the widget card. + dimensions: { + height: number[]; // Supported grid-step heights the widget can render at. + width?: number; // Grid-step width. Optional; defaults to 1 column. + }; +}; + +type WorkerManifest = CommonExecutableFields & { + kind: 'worker'; + entrypoint: string; // Path to the worker entry module inside the executable directory. + includes: Record<'chat' | 'pocket', boolean>; // Which user-facing surfaces this worker serves. +}; + +type SemVer = [major: number, minor: number, patch: number, build?: string]; +// e.g. [1, 0, 0] or [1, 0, 0, ''] +``` + +- `app` — full-screen App. No extra fields beyond the common ones. +- `widget` — `dimensions.height` is the list of grid-step heights the widget can render at; the Host picks one per layout. `width` defaults to `1` column. The grid unit and bounds belong to the Host's dashboard spec (see [Future Directions](#future-directions)). By convention `8` in `height` signals a full-screen widget; this RFC does not normalise that convention. +- `worker` — background JS worker. `entrypoint` is the module the Host loads inside the worker. `includes` declares which user-facing surfaces this worker serves: `{ chat: true }` means a Host MAY expose "open chat" affordances for the product; `{ pocket: true }` means a Host MAY expose Pocket-artifact navigation; both `true` means the worker serves both. Both `false` is also valid: the worker exposes no user-facing surface and runs purely as background logic — for example caching, notification scheduling, or chain bookkeeping that backs the product's other executables. A Host simply exposes no Pocket or Chat affordances for such a worker; it still launches and runs the background process. + +Publishers MUST set `kind` to match the subname label the manifest is written under: `app` under `app..dot`, `widget` under `widget..dot`, `worker` under `worker..dot`. Hosts MUST reject a manifest whose `kind` does not match the subname it was read from. + +**Why one Worker, not per modality.** A Worker is the product's single background process, carrying its full Host-API surface (signing, notifications, chain access, long-lived caches). Those capabilities do not split cleanly along the Pocket-vs-Chat boundary, and two bundles would duplicate the surface and make the product's on-chain signing identity ambiguous. `includes` only advertises which user-facing affordances the same process serves; the executable remains a single artifact. + +### Executable structure (v1) + +The executable manifest's `cid` points at the bytes; this section defines what those bytes contain. Runtime APIs a Host exposes to a running executable (chain access, message passing, lifecycle hooks, etc.) are out of scope here — those belong in per-modality runtime contracts. + +**App and Widget.** Single-page web applications, packaged as a directory whose root contains an `index.html` file. The Host treats `index.html` as the entry point and loads it to launch the modality. Relative paths inside `index.html` (scripts, styles, images) resolve against the same Bulletin IPFS gateway root from which the executable was fetched. + +**Worker.** A directory of JavaScript files. The executable manifest's `entrypoint` field names the entry-point module as a path relative to the directory root (e.g. `index.js`, `src/worker.js`). The Host loads that module into a JS worker runtime to launch the modality. Other files referenced from the entry module (static imports, dynamic-import paths, asset URLs) resolve against the same Bulletin IPFS gateway root. + +### Subname convention + +| Subname | Carries | +|---------------------------|----------------------------| +| `app..dot` | App executable manifest | +| `widget..dot` | Widget executable manifest | +| `worker..dot` | Worker executable manifest | + +A product MAY publish any combination of these subnames; absence of a subname means the product does not provide that executable. + +For each executable type the Host can render, it MUST query the corresponding subname to discover whether the product provides that executable. A Host with no surface for an executable type (e.g. a CLI Host has no dashboard for widgets) MAY skip the corresponding subname. + +### Corner cases + +- **Icon unreachable or its bytes do not match the declared `format`.** Treat the icon as malformed and render a placeholder; do not sniff or auto-correct. The product remains launchable. +- **Missing root manifest but present executable subnames.** Product is not discoverable; executables MUST NOT be launched. +- **Unknown `kind` in an executable manifest.** Skip that executable rather than fail the whole product. +- **`kind` does not match the subname label** (e.g. `kind: 'app'` read from `worker..dot`). Treat the executable as malformed and skip it; do not coerce to the subname's label. +- **Manifest payload exceeds the dotNS text-record budget.** dotNS rejects the write at wire level (see [Security](#security)); Hosts never observe oversized records in practice. + +### Implementation basics + +The parameters, constants, and transport mechanics in this section are shared by both Publisher and Host implementations. Each role uses a subset of them; the role sections that follow call out which. + +#### Parameters + +Everything in this list is a **parameter** the implementation accepts as input; chains and contract deployments may change between environments. + +- **dotNS smart contract.** A smart contract called through Revive pallet (Asset Hub on current testnets). Publishers need the chain's RPC endpoint for both reads and writes; Hosts need it for dry-run reads only. +- **Contract addresses on the dotNS chain.** Publishers need the registry (`IDotnsRegistry`) and the content resolver (`IDotnsContentResolver`). Hosts need only the registry; the resolver address for any given node is discovered through `IDotnsRegistry.resolver(node)`, not configured. +- **Bulletin chain.** A separate Polkadot chain hosting the `TransactionStorage` pallet. Publishers submit upload extrinsics here; Hosts never contact Bulletin RPC directly. +- **Bulletin IPFS gateway.** HTTP base URL used to read bytes back from Bulletin by CID — e.g. `https://paseo-ipfs.polkadot.io` on testnets. Hosts use it to fetch executable and icon bytes; publishers use it for the Step 8 verify probe. +- **Signing key.** A Polkadot account key, used for dotNS and Bulletin transactions via the standard Polkadot transaction flow. Publishers only — Hosts only read and need no signing key. + +#### Constants + +Fixed by the Bulletin chain protocol; identical across every publisher and Host. + +| Constant | Value | Meaning | +|----------------|--------------------|-----------------------------------------------| +| CID version | `1` | CIDv1 | +| Multicodec | `0x55` (`raw`) | Stored bytes addressed as raw payload | +| Multihash code | `0x12` (`sha-256`) | Hash algorithm used to derive the CID | +| Digest length | `32` bytes | Output size of the SHA-256 digest | + +A Bulletin CID is therefore `CIDv1(raw, sha256(data))`. Its encoded length is fixed, which makes the publisher's Step 2 size preflight deterministic. + +#### dotNS transport + +**Names and nodes.** Prose in this RFC speaks in **subnames** — dotted labels like `widget.hackm3.dot`. The dotNS contract API does not: every read and write addresses a node by its **subnode**, the ENS-style namehash of the dotted label as a `bytes32`. Implementations compute `namehash(subname)` once at each call site and pass the resulting `bytes32` into the contract call. Calls that accept a parent node plus a child label (`setSubnodeOwner`, `setSubnodeResolver`) take the parent's `bytes32` namehash directly and the child label as a string; the contract derives the child subnode internally. + +Every dotNS contract call is composed as ABI-encoded calldata and dispatched through the dotNS chain's `pallet-revive`: + +- **Reads** (`owner`, `resolver`, `text`): wrap the calldata in a `ReviveApi.call(origin, ...)` dry-run RPC; ABI-decode the result. The dry-run requires an `origin` account, but nothing is signed, charged, or mutated — so it MUST NOT be a real keypair such as `//Alice` (using one couples reads to an account that may be unfunded, unknown, or absent in a given environment). Instead, derive the deterministic **Revive system account** using Substrate's standard `PalletId` account convention: the 4-byte tag `modl`, followed by the 8-byte `pallet-revive` ID `py/reviv`, zero-padded to a 32-byte `AccountId`. This account need not exist or hold a balance; it only names the dry-run caller, keeping reads environment-independent. + +The 32-byte derivation (Rust): + +```rust +fn pallet_account(pallet_id: &[u8; 8]) -> [u8; 32] { + let mut account = [0u8; 32]; + account[..4].copy_from_slice(b"modl"); + account[4..12].copy_from_slice(pallet_id); + account +} + +let account = pallet_account(b"py/reviv"); +// 0x6d6f646c70792f7265766976000000...0000 +``` + +- **Writes** (`setResolver`, `setSubnodeOwner`, `setSubnodeResolver`, `setText`): the same calldata is sent as a signed Substrate extrinsic that invokes `pallet-revive::call(...)`. Fees and nonce are handled by the normal transaction flow. Publishers only. + +### Publisher implementation + +The **publisher** is the entity that publishes a product — a CLI, build script, GitHub Action, web UI, IDE plugin, or any other form running autonomously or in tandem with a developer who supplies the signing key. Any form is valid provided it executes Steps 1-8 below against the parameters and transport defined above. + +#### Step 1 — Read the local product config + +The publisher reads a local config file authored and source-controlled by the developer. The on-disk encoding (e.g. JSON, YAML, TOML) is a tooling decision and not normative. As an illustration of the shape the publisher needs in hand before Step 2, a hypothetical TypeScript form: + +```typescript +type LocalProductConfig = { + productName: string; + displayName: string; + description: string; + icon: string; + app?: AppConfig; + widget?: WidgetConfig; + worker?: WorkerConfig; +}; + +type AppConfig = { + root: string; + appVersion: SemVer; +}; + +type WidgetConfig = { + root: string; + appVersion: SemVer; + description?: string; + dimensions: { + height: number[]; + width?: number; + }; +}; + +type WorkerConfig = { + root: string; + appVersion: SemVer; + entrypoint: string; + includes: { + chat?: boolean; + pocket?: boolean; + }; +}; +``` + +Each executable field (`app`, `widget`, `worker`) is optional — omitting it means that executable is not part of this publish operation. + +#### Step 2 — Validate the local config + +The publisher validates the local config before any network I/O: + +- All referenced files (icon, executables) exist and are readable. +- Icon `format` is one of the values allowed by `Icon.format`. +- `appVersion` is a 3- or 4-element tuple of the right shape. +- Each executable's kind-specific fields are present, well-typed, and satisfy schema-level constraints. +- Pessimistic size preflight: compose each manifest with a placeholder CID of the fixed encoded length (per the Constants table). Abort if any composed manifest exceeds the dotNS text-record budget. + +Local validation failures abort the publish with a human-readable error. No partial state is written on-chain. + +#### Step 3 — Preflight on-chain state + +Before submitting any Bulletin transactions, the publisher confirms both chains are ready. + +**3.1 Ownership of the base name.** Read `IDotnsRegistry.owner(namehash(".dot"))`. If it is not the publisher's signing-key address, abort. + +**3.2 Resolver on the base name.** The dotNS registrar installs the reverse-resolver contract as every fresh node's default resolver. The reverse resolver only implements `nameOf` / `setReverseName` — it cannot store text records — so the publisher MUST redirect the slot to the content resolver. Read `IDotnsRegistry.resolver(namehash(".dot"))`; if it is not the content-resolver address, call `IDotnsRegistry.setResolver(...)`. On re-publish this is a no-op read. + +**3.3 Subnames for each executable.** For each executable being published, ensure the corresponding subname exists with the publisher as owner. If not, call `IDotnsRegistry.setSubnodeOwner(...)`. Each fresh subnode also needs its resolver redirected to the content resolver. + +**3.4 Bulletin storage authorization.** Confirm the signing key is authorized to submit `TransactionStorage.store_with_cid_config(...)` extrinsics on the Bulletin chain. + +#### Step 4 — Upload assets to the Bulletin chain + +The Bulletin chain's transaction-storage pallet stores one chunk per signed extrinsic, returning a content identifier derived from the bytes under the given `codec` / `hashing`: + +``` +TransactionStorage.store_with_cid_config({ cid: { codec, hashing }, data }) +``` + +Larger artifacts are merkleized first: bytes are chunked and arranged into a Merkle DAG with a single root CID (serialised as a CAR — Content-Addressed aRchive). Two kinds of artifacts go through the same flow: + +1. **The product icon.** Read the icon file from disk, merkleize (typically a single chunk for small images), and upload each chunk. The resulting root CID becomes the root manifest's `icon.cid`. +2. **Each executable.** Read the executable directory, merkleize into a CAR, and upload each chunk. The publisher MUST probe each chunk's CID against the chain or its IPFS gateway and skip any that are already present. The resulting root CID becomes the executable manifest's `cid`. + +Assets that fail to upload abort the publish. Re-running the publish is safe: chunks already on-chain are re-addressable by their CID and skipped on retry. + +#### Step 5 — Compose manifests + +With every CID in hand, the publisher constructs: + +- One **root manifest** JSON conforming to `RootManifest`, with the icon's `cid` and `format` substituted in. +- One **executable manifest** JSON per executable conforming to the matching variant, with the executable's `cid` substituted in. + +All payloads start with `$v: 1`. + +#### Step 6 — Validate the manifests + +Before any dotNS write, the publisher: + +1. Parses each composed JSON back through the v1 JSON Schema to confirm conformance. +2. Computes the UTF-8 byte length of each manifest and rejects any that exceed the dotNS text-record budget (the exact figure is still TBD — see [Unresolved Questions](#unresolved-questions)). + +Either check failing aborts the publish before on-chain writes begin. + +#### Step 7 — Write the manifests + +`IDotnsContentResolver.setText(node, key, value)` is a hard override: it overwrites the previous value in full. + +To enable rollback, the publisher first snapshots every text record it will touch. The publisher then submits one `setText(...)` per row, writing the newly composed JSON. The writes SHOULD be batched into a single signed extrinsic via `Utility.batchAll`, so all manifests are written in a single block or the entire batch fails atomically. + +**Rollback on partial failure.** If any `setText` fails after a previous one succeeded, the publisher MUST issue `setText(node, key, snapshot)` for every record already overwritten this run, then abort with a diagnostic. + +#### Step 8 — Verify + +After all writes confirm, the publisher re-runs the resolution flow described in the Host implementation section against the base name and asserts: + +- Every manifest is readable via `text(node, key)` and matches the JSON the publisher just wrote. +- Every manifest round-trips through schema validation. +- Every `cid` referenced from the manifests is reachable on the Bulletin chain. + +If any assertion fails, trigger the snapshot-restore path from Step 7's rollback, then abort with a diagnostic. + +### Host implementation + +How a Host resolves a product, from a dotNS name to validated manifests and launchable executable bytes. + +#### Resolving a product + +For a base name `B`: + +1. **Compute the node hash.** `node = namehash(B)` using the ENS-style namehash algorithm. +2. **Find the resolver.** Read `IDotnsRegistry.resolver(node)`. `address(0)` means the product does not exist. +3. **Read the root manifest.** Read `IDotnsContentResolver.text(node, "manifest")`. An empty string indicates the product does not exist. +4. **Parse and validate the root manifest.** Parse JSON, validate `$v`, validate against the v1 `RootManifest` schema. Failure at any step means the product is malformed or undiscoverable. +5. **(Optional) Read the author.** Call `IDotnsRegistry.owner(node)`. +6. **Probe executable subnames.** For each executable type the Host can render, compute the subnode's namehash and repeat steps 2-4 using `text(subnode, "executable")`. +7. **(Optional) Verify subname provenance.** Call `owner(subnode)` and verify equality with the base-name owner. +8. **Fetch executable bytes before launching.** `GET /ipfs/` and verify the fetched bytes resolve to `cid`. + +**Cache invalidation.** dotNS provides no push notifications. A Host that has cached a manifest detects a re-publish either by re-reading the relevant text record and observing a different value, or by seeing a higher `appVersion` in the executable manifest. Icon and executable bytes are cacheable indefinitely by `cid` (content-addressed). + +#### Conformance fixtures + +- Base name with no resolver → product does not exist. +- Base name whose resolver is the dotNS-default reverse resolver → product does not exist. +- Empty `text(node, "manifest")` → product does not exist. +- Malformed JSON in `manifest` → diagnostic; do not launch. +- Unknown `$v` in `manifest` → diagnostic; treat as undiscoverable. +- Root manifest fails `RootManifest` schema → diagnostic; do not launch. +- Unknown `icon.format` → render placeholder; product remains launchable. +- Icon CID unreachable or bytes mismatch → render placeholder; product remains launchable. +- Executable subname absent or empty `executable` text record → product does not provide that executable. +- Executable manifest fails its schema → skip that executable. +- Unknown `kind`, or `kind` does not match the subname label → skip that executable. +- Executable CID unreachable or bytes mismatch → refuse to launch that executable. +- Executable subname owned by a different account (when strict provenance is enabled) → skip that executable. + +## Drawbacks + +- **JSON over a binary codec.** Costs text-record budget that a binary format would not — accepted for parseability with off-the-shelf tooling. +- **No oversized-manifest fallback.** A publisher who exceeds the dotNS text-record budget MUST shrink the payload. +- **Multiple lookups per resolution.** A full resolution for a product with all three executable types costs ~8 dotNS reads plus up to 4 Bulletin fetches. Mitigate with parallelisation and caching. +- **Schema evolution locks out older Hosts.** A new `$v` is invisible to Hosts that do not yet recognise it. A co-versioning scheme is left to a follow-up RFC. + +## Alternatives + +- **Binary codec (SCALE/protobuf).** Lower wire cost but requires a codec library in every consumer. JSON with off-the-shelf parsers is simpler and fits within dotNS text-record budgets. +- **Single manifest per product.** Fewer lookups, but a single record grows with each executable type and cannot be independently updated. + +## Security + +- **Trust anchor.** The dotNS name is the identity; the manifest's `cid` fields bind that identity to specific bytes on Bulletin. Given an authenticated dotNS record, a Host that fetches by `cid` and verifies bytes is protected from tampering. +- **Icon supply chain.** The `format` allowlist (jpeg, png) constrains the rendering pipeline to raster decoders. Hosts MUST render icon bytes through a sandboxed image surface and never through paths that interpret the bytes as markup or script. +- **Size cap at publishing.** The publisher MUST validate every manifest against the v1 schema and reject payloads exceeding the dotNS text-record budget before submitting. dotNS enforces a wire-level cap on writes. +- **Subname squatting is structurally prevented.** `setSubnodeOwner` is gated by parent-ownership: only the owner of `.dot` can create the modality subnames. +- **No user data.** The manifest carries no user data; privacy exposure is limited to whatever dotNS RPC traffic reveals about which products a client is resolving. + +## Unresolved Questions + +- **Text-record byte budget on dotNS.** The hard ceiling on a manifest's size. Owner: dotNS team Proof-of-Concept. + +## Future Directions + +A manifest-aggregation RPC could eliminate the N+1 lookup pattern (one round-trip per subname) without changing the schema. A companion spec will pin down the dashboard grid (cell size, bounds, responsive behaviour) referenced by `WidgetManifest.dimensions`. Multi-widget products are deferred: a later revision will define a subname convention (e.g. `widget...dot`) and a discovery mechanism. From 423251343235f37951611456bf2efde083680603 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 8 Jun 2026 13:25:25 +0200 Subject: [PATCH 8/8] Revert "Add product manifest RFC and host implementation guide" This reverts commit 8dc4978caafd1bef8c60f67c895b25fb0581e181. --- docs/design/product-manifest.md | 142 ----------- docs/rfcs/product-manifest.md | 418 -------------------------------- 2 files changed, 560 deletions(-) delete mode 100644 docs/design/product-manifest.md delete mode 100644 docs/rfcs/product-manifest.md diff --git a/docs/design/product-manifest.md b/docs/design/product-manifest.md deleted file mode 100644 index a2db20ff..00000000 --- a/docs/design/product-manifest.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -title: "Product Manifest — Host Implementation Guide" -type: design ---- - -# Product Manifest — Host Implementation Guide - -This document complements [RFC — Product Manifest Format](../rfcs/product-manifest.md) with concrete data structures and a step-by-step guide for host implementors. The RFC is the normative source; this page is a quick-reference. - -## Data Structures - -The manifest system uses two layers of structured data: a **root manifest** at the product's dotNS base name and one **executable manifest** per modality subname. - -### Root Manifest - -```typescript -type RootManifest = { - $v: 1; - displayName: string; - description: string; - icon: Icon; -}; - -type Icon = { - cid: string; // Bulletin-chain CID - format: "jpeg" | "png"; -}; -``` - -### Executable Manifest - -```typescript -type ExecutableManifest = AppManifest | WidgetManifest | WorkerManifest; - -type CommonExecutableFields = { - $v: 1; - appVersion: SemVer; -}; - -type AppManifest = CommonExecutableFields & { - kind: "app"; -}; - -type WidgetManifest = CommonExecutableFields & { - kind: "widget"; - description?: string; - dimensions: { - height: number[]; // supported grid-step heights - width?: number; // defaults to 1 - }; -}; - -type WorkerManifest = CommonExecutableFields & { - kind: "worker"; - entrypoint: string; - includes: Record<"chat" | "pocket", boolean>; -}; - -type SemVer = [major: number, minor: number, patch: number, build?: string]; -``` - -### Subname Convention - -| Subname | Text-record key | Carries | -|-----------------------------|-----------------|----------------------------| -| `.dot` | `manifest` | Root manifest | -| `app..dot` | `executable` | App executable manifest | -| `widget..dot` | `executable` | Widget executable manifest | -| `worker..dot` | `executable` | Worker executable manifest | - -Absence of a subname means the product does not provide that executable. - -## Resolution Flow - -A host resolves a product from its dotNS base name `B` in eight steps: - -``` -1. node = namehash(B) -2. resolver = IDotnsRegistry.resolver(node) - └─ address(0) → product does not exist; stop -3. json = IDotnsContentResolver.text(node, "manifest") - └─ empty → product does not exist; stop -4. Parse JSON, validate $v and RootManifest schema - └─ failure → malformed; surface diagnostic -5. (optional) author = IDotnsRegistry.owner(node) -6. For each executable type the host can render: - subnode = namehash("..dot") - repeat steps 2–4 with text(subnode, "executable") -7. (optional) Verify owner(subnode) == owner(node) -8. Fetch bytes: GET /ipfs/ - verify fetched bytes match the CID -``` - -### dotNS Dry-Run Origin - -All reads use a `ReviveApi.call(origin, ...)` dry-run RPC. The origin MUST be the deterministic Revive system account, not a real keypair: - -```rust -fn pallet_account(pallet_id: &[u8; 8]) -> [u8; 32] { - let mut account = [0u8; 32]; - account[..4].copy_from_slice(b"modl"); - account[4..12].copy_from_slice(pallet_id); - account -} - -let origin = pallet_account(b"py/reviv"); -// 0x6d6f646c70792f7265766976000000...0000 -``` - -This account need not exist or hold a balance. - -## Bulletin Constants - -Fixed by the Bulletin chain protocol. - -| Constant | Value | Meaning | -|----------------|--------------------|---------------------------------------| -| CID version | `1` | CIDv1 | -| Multicodec | `0x55` (`raw`) | Stored bytes addressed as raw payload | -| Multihash code | `0x12` (`sha-256`) | Hash algorithm for the CID | -| Digest length | `32` bytes | SHA-256 output size | - -A Bulletin CID is `CIDv1(raw, sha256(data))`. - -## Error Handling - -| Condition | Host action | -|------------------------------------------|------------------------------------------| -| No resolver / empty root manifest | Product does not exist | -| Unknown `$v` | Undiscoverable; skip, surface diagnostic | -| Malformed JSON / schema validation fail | Do not launch; surface diagnostic | -| Unknown `icon.format` or icon CID fails | Render placeholder; product launchable | -| Missing executable subname | Product does not provide that executable | -| `kind` does not match subname label | Skip that executable | -| Executable CID unreachable / mismatch | Refuse to launch that executable | -| Subname owner differs (strict provenance)| Skip that executable | - -## Caching - -- **Manifests**: cache by base name. Detect re-publish by re-reading the text record or comparing `appVersion`. -- **Icon and executable bytes**: cacheable indefinitely by CID (content-addressed; same CID = same bytes). -- dotNS provides no push notifications; hosts must poll. diff --git a/docs/rfcs/product-manifest.md b/docs/rfcs/product-manifest.md deleted file mode 100644 index 88985694..00000000 --- a/docs/rfcs/product-manifest.md +++ /dev/null @@ -1,418 +0,0 @@ ---- -title: "Product Manifest Format" -owner: "@johnthecat" ---- - -# RFC — Product Manifest Format - -## Summary - -A two-level product manifest used by Polkadot Hosts to discover, validate, and launch product executables. - -- The **root manifest** carries product-wide metadata (displayName, icon, description) and lives at the product's dotNS base name. Authorship is read from dotNS itself (the on-chain owner of the name) rather than declared in the manifest. -- One or more **executable manifests** describe individual executables (App, Widget, Worker). Each pins a product-defined version and a Bulletin-chain CID for the executable artifact, and lives at a well-known subname of the base name (`app..dot`, `widget..dot`, `worker..dot`). - -Manifests are JSON, stored inline in dotNS text records; referenced binary content (executable bytes, icons) lives on the Bulletin chain and is addressed by CID. - -## Motivation - -### Background - -The Polkadot ecosystem renders third-party applications inside first-party user-agents — **Hosts** (Polkadot Desktop, Polkadot Mobile, the Polkadot Website). A third-party application is called a **Product**, owned by its developer rather than any single Host. - -A product can expose one or more **modalities**, each a distinct user-facing surface: - -- **App** — full-screen web application. -- **Widget** — small web application mounted on a dashboard. -- **Pocket** — passive surfaces such as cards, tickets, or certificates, served by a background JS worker. -- **Chat** — chat bots and chat-room integrations, also served by a background JS worker. - -A modality is delivered by an **executable**. v1 defines three executable types: - -- **App** — the web application backing the App modality. -- **Widget** — the web application backing the Widget modality. -- **Worker** — a single background process. It may back Pocket and/or Chat, or serve no user-facing surface at all and run purely as background logic (see [Why one Worker, not per modality](#executable-manifest-v1)). - -Throughout this RFC, *modality* means a user-facing surface; *executable* means a deployable artifact. - -Two on-chain systems sit under this RFC: - -- **Bulletin chain** — stores binary blobs. Each blob is content-addressed by an identifier ("CID") that doubles as its integrity check. Executable artifacts and product icons live there. -- **dotNS** — Polkadot's on-chain naming system, implemented as contracts on Asset Hub. A name owns text records that hold small structured metadata. Manifests live there. - -### Requirements - -For a Host to discover, verify, and launch a product, it needs a static, on-chain, authenticated description binding a dotNS name to a specific executable revision. Without a standardized manifest, every Host invents its own discovery convention. The manifest format must therefore: - -1. Provide static product-wide metadata: displayName, icon, and description. -2. Provide, for each executable, a version and content identifier sufficient to fetch and verify the artifact. -3. Be discoverable through a storage call or JSON-RPC call to a node. -4. Be encoded in a format any client environment can parse with off-the-shelf tooling. -5. Fit inside dotNS text records as a single inline payload. -6. Be versionable. - -## Detailed Design - -### Overview - -A product is rooted at a **dotNS base name** (e.g. `hackm3.dot`). The base name's text records carry the **root manifest**. Each executable is rooted at a well-known **subname** of the base name (e.g. `widget.hackm3.dot`), whose text records carry the corresponding **executable manifest**. - -**Terminology.** `` is the label portion of the base name (`hackm3` for `hackm3.dot`). - -``` -hackm3.dot → root manifest (displayName, icon, description) -app.hackm3.dot → executable manifest (App) -widget.hackm3.dot → executable manifest (Widget) -worker.hackm3.dot → executable manifest (Worker; serves Pocket and/or Chat) -``` - -A Host discovers a product's executables by querying these subnames. Absence of a subname means the product does not provide that executable. - -### Encoding and storage - -Manifests are encoded as **UTF-8 JSON** and stored **inline** in a single, well-known text record key on the (sub)name: the value of that text record is the manifest JSON itself. All binary references in the manifest are short CID strings; no binary payloads are inlined. - -The text-record key is fixed by this RFC: - -| Subject | Text-record key | -|------------------------------------|-----------------| -| Root manifest (on the base name) | `manifest` | -| Executable manifest (on a subname) | `executable` | - -Hosts MUST query exactly these keys; publishers MUST write under exactly these keys. Keeping them distinct means a wrong-layer query (e.g. `manifest` on `app..dot`) returns an empty value instead of partially parsing a payload of the wrong shape. - -A v1 root manifest is well under 1 KB; an executable manifest is ~200 B. Manifests fit within typical text-record budgets; the exact figure will be confirmed by the dotNS team's Proof-of-Concept (see [Unresolved Questions](#unresolved-questions)). v1 defines no preimage fallback: a manifest that cannot fit MUST be shrunk by the publisher. - -### Versioning - -Every manifest carries `$v` as its first field — a numeric schema-version discriminator. This RFC defines `$v: 1`. Hosts MUST treat any manifest whose `$v` they do not recognise as an undiscoverable product: skip it, surface a diagnostic, and keep working. - -### Root manifest (v1) - -The root manifest describes the product as a whole and is the resolution entry point: a Host parses it first, then probes for executable subnames. - -```typescript -type RootManifest = { - $v: 1; - displayName: string; // Human-readable product name. UTF-8. - description: string; // Short description shown in launchers/lists. - icon: Icon; // Product icon used by every Host surface. -}; - -type Icon = { - cid: string; // Raw Bulletin-chain CID; used verbatim to fetch icon bytes. - format: 'jpeg' | 'png'; -}; -``` - -The icon is always deployed on the Bulletin chain — there is no inline-icon variant in v1. Hosts MUST verify fetched icon bytes against `cid` per the chain's content-addressing rules. An unknown `format` MUST be treated as a malformed manifest. - -### Executable manifest (v1) - -An executable manifest describes one deployable artifact and lives on a well-known subname keyed by its executable type. - -```typescript -type ExecutableManifest = - | AppManifest - | WidgetManifest - | WorkerManifest; - -type CommonExecutableFields = { - $v: 1; - appVersion: SemVer; // Product-defined SemVer of this executable. -}; - -type AppManifest = CommonExecutableFields & { - kind: 'app'; -}; - -type WidgetManifest = CommonExecutableFields & { - kind: 'widget'; - description?: string; // Optional tagline shown on the widget card. - dimensions: { - height: number[]; // Supported grid-step heights the widget can render at. - width?: number; // Grid-step width. Optional; defaults to 1 column. - }; -}; - -type WorkerManifest = CommonExecutableFields & { - kind: 'worker'; - entrypoint: string; // Path to the worker entry module inside the executable directory. - includes: Record<'chat' | 'pocket', boolean>; // Which user-facing surfaces this worker serves. -}; - -type SemVer = [major: number, minor: number, patch: number, build?: string]; -// e.g. [1, 0, 0] or [1, 0, 0, ''] -``` - -- `app` — full-screen App. No extra fields beyond the common ones. -- `widget` — `dimensions.height` is the list of grid-step heights the widget can render at; the Host picks one per layout. `width` defaults to `1` column. The grid unit and bounds belong to the Host's dashboard spec (see [Future Directions](#future-directions)). By convention `8` in `height` signals a full-screen widget; this RFC does not normalise that convention. -- `worker` — background JS worker. `entrypoint` is the module the Host loads inside the worker. `includes` declares which user-facing surfaces this worker serves: `{ chat: true }` means a Host MAY expose "open chat" affordances for the product; `{ pocket: true }` means a Host MAY expose Pocket-artifact navigation; both `true` means the worker serves both. Both `false` is also valid: the worker exposes no user-facing surface and runs purely as background logic — for example caching, notification scheduling, or chain bookkeeping that backs the product's other executables. A Host simply exposes no Pocket or Chat affordances for such a worker; it still launches and runs the background process. - -Publishers MUST set `kind` to match the subname label the manifest is written under: `app` under `app..dot`, `widget` under `widget..dot`, `worker` under `worker..dot`. Hosts MUST reject a manifest whose `kind` does not match the subname it was read from. - -**Why one Worker, not per modality.** A Worker is the product's single background process, carrying its full Host-API surface (signing, notifications, chain access, long-lived caches). Those capabilities do not split cleanly along the Pocket-vs-Chat boundary, and two bundles would duplicate the surface and make the product's on-chain signing identity ambiguous. `includes` only advertises which user-facing affordances the same process serves; the executable remains a single artifact. - -### Executable structure (v1) - -The executable manifest's `cid` points at the bytes; this section defines what those bytes contain. Runtime APIs a Host exposes to a running executable (chain access, message passing, lifecycle hooks, etc.) are out of scope here — those belong in per-modality runtime contracts. - -**App and Widget.** Single-page web applications, packaged as a directory whose root contains an `index.html` file. The Host treats `index.html` as the entry point and loads it to launch the modality. Relative paths inside `index.html` (scripts, styles, images) resolve against the same Bulletin IPFS gateway root from which the executable was fetched. - -**Worker.** A directory of JavaScript files. The executable manifest's `entrypoint` field names the entry-point module as a path relative to the directory root (e.g. `index.js`, `src/worker.js`). The Host loads that module into a JS worker runtime to launch the modality. Other files referenced from the entry module (static imports, dynamic-import paths, asset URLs) resolve against the same Bulletin IPFS gateway root. - -### Subname convention - -| Subname | Carries | -|---------------------------|----------------------------| -| `app..dot` | App executable manifest | -| `widget..dot` | Widget executable manifest | -| `worker..dot` | Worker executable manifest | - -A product MAY publish any combination of these subnames; absence of a subname means the product does not provide that executable. - -For each executable type the Host can render, it MUST query the corresponding subname to discover whether the product provides that executable. A Host with no surface for an executable type (e.g. a CLI Host has no dashboard for widgets) MAY skip the corresponding subname. - -### Corner cases - -- **Icon unreachable or its bytes do not match the declared `format`.** Treat the icon as malformed and render a placeholder; do not sniff or auto-correct. The product remains launchable. -- **Missing root manifest but present executable subnames.** Product is not discoverable; executables MUST NOT be launched. -- **Unknown `kind` in an executable manifest.** Skip that executable rather than fail the whole product. -- **`kind` does not match the subname label** (e.g. `kind: 'app'` read from `worker..dot`). Treat the executable as malformed and skip it; do not coerce to the subname's label. -- **Manifest payload exceeds the dotNS text-record budget.** dotNS rejects the write at wire level (see [Security](#security)); Hosts never observe oversized records in practice. - -### Implementation basics - -The parameters, constants, and transport mechanics in this section are shared by both Publisher and Host implementations. Each role uses a subset of them; the role sections that follow call out which. - -#### Parameters - -Everything in this list is a **parameter** the implementation accepts as input; chains and contract deployments may change between environments. - -- **dotNS smart contract.** A smart contract called through Revive pallet (Asset Hub on current testnets). Publishers need the chain's RPC endpoint for both reads and writes; Hosts need it for dry-run reads only. -- **Contract addresses on the dotNS chain.** Publishers need the registry (`IDotnsRegistry`) and the content resolver (`IDotnsContentResolver`). Hosts need only the registry; the resolver address for any given node is discovered through `IDotnsRegistry.resolver(node)`, not configured. -- **Bulletin chain.** A separate Polkadot chain hosting the `TransactionStorage` pallet. Publishers submit upload extrinsics here; Hosts never contact Bulletin RPC directly. -- **Bulletin IPFS gateway.** HTTP base URL used to read bytes back from Bulletin by CID — e.g. `https://paseo-ipfs.polkadot.io` on testnets. Hosts use it to fetch executable and icon bytes; publishers use it for the Step 8 verify probe. -- **Signing key.** A Polkadot account key, used for dotNS and Bulletin transactions via the standard Polkadot transaction flow. Publishers only — Hosts only read and need no signing key. - -#### Constants - -Fixed by the Bulletin chain protocol; identical across every publisher and Host. - -| Constant | Value | Meaning | -|----------------|--------------------|-----------------------------------------------| -| CID version | `1` | CIDv1 | -| Multicodec | `0x55` (`raw`) | Stored bytes addressed as raw payload | -| Multihash code | `0x12` (`sha-256`) | Hash algorithm used to derive the CID | -| Digest length | `32` bytes | Output size of the SHA-256 digest | - -A Bulletin CID is therefore `CIDv1(raw, sha256(data))`. Its encoded length is fixed, which makes the publisher's Step 2 size preflight deterministic. - -#### dotNS transport - -**Names and nodes.** Prose in this RFC speaks in **subnames** — dotted labels like `widget.hackm3.dot`. The dotNS contract API does not: every read and write addresses a node by its **subnode**, the ENS-style namehash of the dotted label as a `bytes32`. Implementations compute `namehash(subname)` once at each call site and pass the resulting `bytes32` into the contract call. Calls that accept a parent node plus a child label (`setSubnodeOwner`, `setSubnodeResolver`) take the parent's `bytes32` namehash directly and the child label as a string; the contract derives the child subnode internally. - -Every dotNS contract call is composed as ABI-encoded calldata and dispatched through the dotNS chain's `pallet-revive`: - -- **Reads** (`owner`, `resolver`, `text`): wrap the calldata in a `ReviveApi.call(origin, ...)` dry-run RPC; ABI-decode the result. The dry-run requires an `origin` account, but nothing is signed, charged, or mutated — so it MUST NOT be a real keypair such as `//Alice` (using one couples reads to an account that may be unfunded, unknown, or absent in a given environment). Instead, derive the deterministic **Revive system account** using Substrate's standard `PalletId` account convention: the 4-byte tag `modl`, followed by the 8-byte `pallet-revive` ID `py/reviv`, zero-padded to a 32-byte `AccountId`. This account need not exist or hold a balance; it only names the dry-run caller, keeping reads environment-independent. - -The 32-byte derivation (Rust): - -```rust -fn pallet_account(pallet_id: &[u8; 8]) -> [u8; 32] { - let mut account = [0u8; 32]; - account[..4].copy_from_slice(b"modl"); - account[4..12].copy_from_slice(pallet_id); - account -} - -let account = pallet_account(b"py/reviv"); -// 0x6d6f646c70792f7265766976000000...0000 -``` - -- **Writes** (`setResolver`, `setSubnodeOwner`, `setSubnodeResolver`, `setText`): the same calldata is sent as a signed Substrate extrinsic that invokes `pallet-revive::call(...)`. Fees and nonce are handled by the normal transaction flow. Publishers only. - -### Publisher implementation - -The **publisher** is the entity that publishes a product — a CLI, build script, GitHub Action, web UI, IDE plugin, or any other form running autonomously or in tandem with a developer who supplies the signing key. Any form is valid provided it executes Steps 1-8 below against the parameters and transport defined above. - -#### Step 1 — Read the local product config - -The publisher reads a local config file authored and source-controlled by the developer. The on-disk encoding (e.g. JSON, YAML, TOML) is a tooling decision and not normative. As an illustration of the shape the publisher needs in hand before Step 2, a hypothetical TypeScript form: - -```typescript -type LocalProductConfig = { - productName: string; - displayName: string; - description: string; - icon: string; - app?: AppConfig; - widget?: WidgetConfig; - worker?: WorkerConfig; -}; - -type AppConfig = { - root: string; - appVersion: SemVer; -}; - -type WidgetConfig = { - root: string; - appVersion: SemVer; - description?: string; - dimensions: { - height: number[]; - width?: number; - }; -}; - -type WorkerConfig = { - root: string; - appVersion: SemVer; - entrypoint: string; - includes: { - chat?: boolean; - pocket?: boolean; - }; -}; -``` - -Each executable field (`app`, `widget`, `worker`) is optional — omitting it means that executable is not part of this publish operation. - -#### Step 2 — Validate the local config - -The publisher validates the local config before any network I/O: - -- All referenced files (icon, executables) exist and are readable. -- Icon `format` is one of the values allowed by `Icon.format`. -- `appVersion` is a 3- or 4-element tuple of the right shape. -- Each executable's kind-specific fields are present, well-typed, and satisfy schema-level constraints. -- Pessimistic size preflight: compose each manifest with a placeholder CID of the fixed encoded length (per the Constants table). Abort if any composed manifest exceeds the dotNS text-record budget. - -Local validation failures abort the publish with a human-readable error. No partial state is written on-chain. - -#### Step 3 — Preflight on-chain state - -Before submitting any Bulletin transactions, the publisher confirms both chains are ready. - -**3.1 Ownership of the base name.** Read `IDotnsRegistry.owner(namehash(".dot"))`. If it is not the publisher's signing-key address, abort. - -**3.2 Resolver on the base name.** The dotNS registrar installs the reverse-resolver contract as every fresh node's default resolver. The reverse resolver only implements `nameOf` / `setReverseName` — it cannot store text records — so the publisher MUST redirect the slot to the content resolver. Read `IDotnsRegistry.resolver(namehash(".dot"))`; if it is not the content-resolver address, call `IDotnsRegistry.setResolver(...)`. On re-publish this is a no-op read. - -**3.3 Subnames for each executable.** For each executable being published, ensure the corresponding subname exists with the publisher as owner. If not, call `IDotnsRegistry.setSubnodeOwner(...)`. Each fresh subnode also needs its resolver redirected to the content resolver. - -**3.4 Bulletin storage authorization.** Confirm the signing key is authorized to submit `TransactionStorage.store_with_cid_config(...)` extrinsics on the Bulletin chain. - -#### Step 4 — Upload assets to the Bulletin chain - -The Bulletin chain's transaction-storage pallet stores one chunk per signed extrinsic, returning a content identifier derived from the bytes under the given `codec` / `hashing`: - -``` -TransactionStorage.store_with_cid_config({ cid: { codec, hashing }, data }) -``` - -Larger artifacts are merkleized first: bytes are chunked and arranged into a Merkle DAG with a single root CID (serialised as a CAR — Content-Addressed aRchive). Two kinds of artifacts go through the same flow: - -1. **The product icon.** Read the icon file from disk, merkleize (typically a single chunk for small images), and upload each chunk. The resulting root CID becomes the root manifest's `icon.cid`. -2. **Each executable.** Read the executable directory, merkleize into a CAR, and upload each chunk. The publisher MUST probe each chunk's CID against the chain or its IPFS gateway and skip any that are already present. The resulting root CID becomes the executable manifest's `cid`. - -Assets that fail to upload abort the publish. Re-running the publish is safe: chunks already on-chain are re-addressable by their CID and skipped on retry. - -#### Step 5 — Compose manifests - -With every CID in hand, the publisher constructs: - -- One **root manifest** JSON conforming to `RootManifest`, with the icon's `cid` and `format` substituted in. -- One **executable manifest** JSON per executable conforming to the matching variant, with the executable's `cid` substituted in. - -All payloads start with `$v: 1`. - -#### Step 6 — Validate the manifests - -Before any dotNS write, the publisher: - -1. Parses each composed JSON back through the v1 JSON Schema to confirm conformance. -2. Computes the UTF-8 byte length of each manifest and rejects any that exceed the dotNS text-record budget (the exact figure is still TBD — see [Unresolved Questions](#unresolved-questions)). - -Either check failing aborts the publish before on-chain writes begin. - -#### Step 7 — Write the manifests - -`IDotnsContentResolver.setText(node, key, value)` is a hard override: it overwrites the previous value in full. - -To enable rollback, the publisher first snapshots every text record it will touch. The publisher then submits one `setText(...)` per row, writing the newly composed JSON. The writes SHOULD be batched into a single signed extrinsic via `Utility.batchAll`, so all manifests are written in a single block or the entire batch fails atomically. - -**Rollback on partial failure.** If any `setText` fails after a previous one succeeded, the publisher MUST issue `setText(node, key, snapshot)` for every record already overwritten this run, then abort with a diagnostic. - -#### Step 8 — Verify - -After all writes confirm, the publisher re-runs the resolution flow described in the Host implementation section against the base name and asserts: - -- Every manifest is readable via `text(node, key)` and matches the JSON the publisher just wrote. -- Every manifest round-trips through schema validation. -- Every `cid` referenced from the manifests is reachable on the Bulletin chain. - -If any assertion fails, trigger the snapshot-restore path from Step 7's rollback, then abort with a diagnostic. - -### Host implementation - -How a Host resolves a product, from a dotNS name to validated manifests and launchable executable bytes. - -#### Resolving a product - -For a base name `B`: - -1. **Compute the node hash.** `node = namehash(B)` using the ENS-style namehash algorithm. -2. **Find the resolver.** Read `IDotnsRegistry.resolver(node)`. `address(0)` means the product does not exist. -3. **Read the root manifest.** Read `IDotnsContentResolver.text(node, "manifest")`. An empty string indicates the product does not exist. -4. **Parse and validate the root manifest.** Parse JSON, validate `$v`, validate against the v1 `RootManifest` schema. Failure at any step means the product is malformed or undiscoverable. -5. **(Optional) Read the author.** Call `IDotnsRegistry.owner(node)`. -6. **Probe executable subnames.** For each executable type the Host can render, compute the subnode's namehash and repeat steps 2-4 using `text(subnode, "executable")`. -7. **(Optional) Verify subname provenance.** Call `owner(subnode)` and verify equality with the base-name owner. -8. **Fetch executable bytes before launching.** `GET /ipfs/` and verify the fetched bytes resolve to `cid`. - -**Cache invalidation.** dotNS provides no push notifications. A Host that has cached a manifest detects a re-publish either by re-reading the relevant text record and observing a different value, or by seeing a higher `appVersion` in the executable manifest. Icon and executable bytes are cacheable indefinitely by `cid` (content-addressed). - -#### Conformance fixtures - -- Base name with no resolver → product does not exist. -- Base name whose resolver is the dotNS-default reverse resolver → product does not exist. -- Empty `text(node, "manifest")` → product does not exist. -- Malformed JSON in `manifest` → diagnostic; do not launch. -- Unknown `$v` in `manifest` → diagnostic; treat as undiscoverable. -- Root manifest fails `RootManifest` schema → diagnostic; do not launch. -- Unknown `icon.format` → render placeholder; product remains launchable. -- Icon CID unreachable or bytes mismatch → render placeholder; product remains launchable. -- Executable subname absent or empty `executable` text record → product does not provide that executable. -- Executable manifest fails its schema → skip that executable. -- Unknown `kind`, or `kind` does not match the subname label → skip that executable. -- Executable CID unreachable or bytes mismatch → refuse to launch that executable. -- Executable subname owned by a different account (when strict provenance is enabled) → skip that executable. - -## Drawbacks - -- **JSON over a binary codec.** Costs text-record budget that a binary format would not — accepted for parseability with off-the-shelf tooling. -- **No oversized-manifest fallback.** A publisher who exceeds the dotNS text-record budget MUST shrink the payload. -- **Multiple lookups per resolution.** A full resolution for a product with all three executable types costs ~8 dotNS reads plus up to 4 Bulletin fetches. Mitigate with parallelisation and caching. -- **Schema evolution locks out older Hosts.** A new `$v` is invisible to Hosts that do not yet recognise it. A co-versioning scheme is left to a follow-up RFC. - -## Alternatives - -- **Binary codec (SCALE/protobuf).** Lower wire cost but requires a codec library in every consumer. JSON with off-the-shelf parsers is simpler and fits within dotNS text-record budgets. -- **Single manifest per product.** Fewer lookups, but a single record grows with each executable type and cannot be independently updated. - -## Security - -- **Trust anchor.** The dotNS name is the identity; the manifest's `cid` fields bind that identity to specific bytes on Bulletin. Given an authenticated dotNS record, a Host that fetches by `cid` and verifies bytes is protected from tampering. -- **Icon supply chain.** The `format` allowlist (jpeg, png) constrains the rendering pipeline to raster decoders. Hosts MUST render icon bytes through a sandboxed image surface and never through paths that interpret the bytes as markup or script. -- **Size cap at publishing.** The publisher MUST validate every manifest against the v1 schema and reject payloads exceeding the dotNS text-record budget before submitting. dotNS enforces a wire-level cap on writes. -- **Subname squatting is structurally prevented.** `setSubnodeOwner` is gated by parent-ownership: only the owner of `.dot` can create the modality subnames. -- **No user data.** The manifest carries no user data; privacy exposure is limited to whatever dotNS RPC traffic reveals about which products a client is resolving. - -## Unresolved Questions - -- **Text-record byte budget on dotNS.** The hard ceiling on a manifest's size. Owner: dotNS team Proof-of-Concept. - -## Future Directions - -A manifest-aggregation RPC could eliminate the N+1 lookup pattern (one round-trip per subname) without changing the schema. A companion spec will pin down the dashboard grid (cell size, bounds, responsive behaviour) referenced by `WidgetManifest.dimensions`. Multi-widget products are deferred: a later revision will define a subname convention (e.g. `widget...dot`) and a discovery mechanism.