From 95614da751510d755357beddc384b31415178b49 Mon Sep 17 00:00:00 2001 From: w Date: Fri, 25 Sep 2026 14:00:01 -0400 Subject: [PATCH 01/30] feat(truapi): show product-referenced profiles in host UI Add the `profile` service. `profile.present({ reference })` asks the host to show a profile the product holds a reference to. The host resolves, decrypts and renders it in its own UI, so profile bytes and the reference's key never return to the product; the call resolves once the presentation is shown. Hosts opt in through `ProfilePlatform`, a new optional trait on `OptionalPlatform`, so codegen emits the dispatcher and the optional `profile` callback group. The browser and Web Worker hosts install it; native hosts and the CLI answer `Unsupported`. The call is served by the local product runtime, not the Chat authority: in pairing mode Chat operations run on the paired wallet, and a profile must appear where the product is on screen. The core screens only the reference's shape (non-empty, at most 2048 bytes, printable ASCII without whitespace); parsing the format is the host's. --- .changeset/profile-present.md | 8 ++ js/packages/truapi-host/README.md | 6 ++ js/packages/truapi-host/src/test-support.ts | 10 ++ .../src/web/create-worker-host-runtime.ts | 2 + .../src/web/worker-provider.test.ts | 27 +++++ rust/crates/truapi-client/src/generated.rs | 32 +++++- .../tests/golden/host-callbacks-adapter.ts | 12 +++ .../tests/golden/host-callbacks.ts | 25 +++++ .../tests/golden/wasm_bridge.rs | 29 +++++ .../tests/golden/worker-callbacks.ts | 17 +++ rust/crates/truapi-platform/README.md | 10 +- rust/crates/truapi-platform/src/lib.rs | 40 +++++-- rust/crates/truapi-server/src/host_core.rs | 28 ++++- rust/crates/truapi-server/src/native.rs | 3 + rust/crates/truapi-server/src/runtime.rs | 54 +++++++++- .../truapi-server/src/runtime/services.rs | 20 ++++ .../crates/truapi-server/src/runtime/tests.rs | 101 ++++++++++++++++++ rust/crates/truapi-server/src/wasm.rs | 20 +++- rust/crates/truapi/src/api.rs | 4 + rust/crates/truapi/src/api/profile.rs | 36 +++++++ rust/crates/truapi/src/lib.rs | 4 + rust/crates/truapi/src/v01.rs | 2 + rust/crates/truapi/src/v01/profile.rs | 36 +++++++ rust/crates/truapi/src/versioned.rs | 1 + rust/crates/truapi/src/versioned/profile.rs | 9 ++ 25 files changed, 521 insertions(+), 15 deletions(-) create mode 100644 .changeset/profile-present.md create mode 100644 rust/crates/truapi/src/api/profile.rs create mode 100644 rust/crates/truapi/src/v01/profile.rs create mode 100644 rust/crates/truapi/src/versioned/profile.rs diff --git a/.changeset/profile-present.md b/.changeset/profile-present.md new file mode 100644 index 000000000..ea884695b --- /dev/null +++ b/.changeset/profile-present.md @@ -0,0 +1,8 @@ +--- +"@parity/truapi": minor +"@parity/truapi-host": minor +--- + +Add the `profile` service. `profile.present({ reference })` asks the host to show a referenced profile in host-owned +UI; the host resolves, decrypts and renders it, and nothing but acceptance returns to the product. Hosts opt in with the +optional `profile` callbacks (`ProfilePlatform`); a host that supplies none answers `Unsupported`. diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 80e7ed5b3..06a91365b 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -171,6 +171,7 @@ const callbacks: HostCallbacks = { chat, // optional: leave it out and chat products get `Unsupported` permissionStatus, // optional: reports live OS permission state pocket, // optional: serves the host's Pocket card collection + profile, // optional: shows product-referenced profiles in host UI }; ``` @@ -182,6 +183,11 @@ reading as usable. Omit it and a stored grant answers on its own. replacement, and `removePocketCard` takes one out. The host owns the collection: removing an absent card succeeds, and a card the host pins is refused with `Privileged`. +`profile.presentProfile` shows the profile a product references in host-owned UI and resolves once it is shown, not +when the user dismisses it. The reference is a bearer capability: the host fetches, decrypts and renders it, and the +profile's bytes never return to the product. The core forwards only references that are non-empty, at most 2048 bytes +and printable ASCII without whitespace; parsing the format is the host's. + Under `createWebWorkerPairingHostRuntime` the presence of each optional group is reported to the worker in its `init` message, so the core sees the same capability set on both sides of the boundary. diff --git a/js/packages/truapi-host/src/test-support.ts b/js/packages/truapi-host/src/test-support.ts index ed33f318a..37270a2a8 100644 --- a/js/packages/truapi-host/src/test-support.ts +++ b/js/packages/truapi-host/src/test-support.ts @@ -149,6 +149,16 @@ export function makeHostCallbacks( }, } : {}), + // And for profiles: the default fixture is a host that renders none, so + // Profile calls are answered `Unsupported`. + ...(overrides.profile + ? { + profile: { + presentProfile: async () => {}, + ...overrides.profile, + }, + } + : {}), // An unavailable authenticated search must not look like an empty result. ...(overrides.identityBackend ? { diff --git a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts index 8017fbb7c..996742a60 100644 --- a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -1558,6 +1558,7 @@ function createWebWorkerHostRuntime( chat: host.chat !== undefined, permissionStatus: host.permissionStatus !== undefined, pocket: host.pocket !== undefined, + profile: host.profile !== undefined, identityBackend: host.identityBackend !== undefined, coinageWallet: callbacks.nativeCoinage !== undefined, }, @@ -1688,6 +1689,7 @@ function buildRuntime( chat: callbacks.chat !== undefined, permissionStatus: callbacks.permissionStatus !== undefined, pocket: callbacks.pocket !== undefined, + profile: callbacks.profile !== undefined, identityBackend: callbacks.identityBackend !== undefined, coinageWallet: state.rawCallbacks.nativeCoinage !== undefined, diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index 1cc28a90e..e0e7ff1a1 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -291,6 +291,7 @@ describe("createWebWorkerPairingHostRuntime", () => { chat: false, permissionStatus: false, pocket: false, + profile: false, identityBackend: false, coinageWallet: false, }, @@ -465,6 +466,7 @@ describe("createWebWorkerPairingHostRuntime", () => { chat: true, permissionStatus: false, pocket: false, + profile: false, identityBackend: false, coinageWallet: false, }); @@ -488,6 +490,31 @@ describe("createWebWorkerPairingHostRuntime", () => { chat: false, permissionStatus: false, pocket: true, + profile: false, + identityBackend: false, + coinageWallet: false, + }); + }); + + it("reports the profile capability to the worker when the host serves it", async () => { + const worker = new FakeWorker(); + void createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks({ + profile: { presentProfile: async () => {} }, + }), + { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, + ); + + worker.emit({ kind: "loaded" }); + + // Without this the worker never builds the profile callbacks, so a host + // that renders profiles is answered `Unsupported` anyway. + expect(lastMessageOfKind(worker, "init").capabilities).toEqual({ + chat: false, + permissionStatus: false, + pocket: false, + profile: true, identityBackend: false, coinageWallet: false, }); diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 170956889..480da1686 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "87a3b34c06a7c823"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "c8972ad11436a788"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -1627,6 +1627,33 @@ impl RequestMethod for PreimageSubmit { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } +/// `profile_present` method marker. +pub struct ProfilePresent; +impl ProfilePresent { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Profile", + method: "present", + wire_name: "profile_present", + request_type: "truapi::versioned::profile::HostProfilePresentRequest", + response_type: "truapi::versioned::profile::HostProfilePresentResponse", + error_type: Some("truapi::versioned::profile::HostProfilePresentError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 20, + method_id: 0, + }), + }; +} +impl RequestMethod for ProfilePresent { + type Request = truapi::versioned::profile::HostProfilePresentRequest; + type Response = truapi::versioned::profile::HostProfilePresentResponse; + type Error = truapi::versioned::profile::HostProfilePresentError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + /// `renderer_render` method marker. pub struct RendererRender; impl RendererRender { @@ -2311,6 +2338,7 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ PermissionsAuthorizeDevicePermission::DESCRIPTOR, PreimageLookupSubscribe::DESCRIPTOR, PreimageSubmit::DESCRIPTOR, + ProfilePresent::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, SigningCreateTransaction::DESCRIPTOR, SigningCreateTransactionWithLegacyAccount::DESCRIPTOR, @@ -2387,6 +2415,7 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ PermissionsAuthorizeDevicePermission::DESCRIPTOR, PreimageLookupSubscribe::DESCRIPTOR, PreimageSubmit::DESCRIPTOR, + ProfilePresent::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, SigningCreateTransaction::DESCRIPTOR, SigningCreateTransactionWithLegacyAccount::DESCRIPTOR, @@ -2470,6 +2499,7 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ PocketRemoveCard::DESCRIPTOR, PreimageLookupSubscribe::DESCRIPTOR, PreimageSubmit::DESCRIPTOR, + ProfilePresent::DESCRIPTOR, RendererRender::DESCRIPTOR, RendererActionSubscribe::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts index 518124e98..89261f7f3 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -21,6 +21,7 @@ import { HostLocaleSubscribeItem, HostPocketListSubscribeItem, HostPocketRemoveCardRequest, + HostProfilePresentRequest, HostPushNotificationRequest, HostPushNotificationResponse, HostThemeSubscribeItem, @@ -147,6 +148,7 @@ export interface RawCallbacks { sendItem: (item?: Uint8Array) => void, sendError: (error: GenericError) => void, ): (() => void) | void; + presentProfile?(product: Uint8Array, request: Uint8Array): Promise; subscribeTheme( sendItem: (item?: Uint8Array) => void, sendError: (error: GenericError) => void, @@ -164,6 +166,7 @@ export function createWasmRawCallbacks( const identityBackend = callbacks.identityBackend; const permissionStatus = callbacks.permissionStatus; const pocket = callbacks.pocket; + const profile = callbacks.profile; const hop = callbacks.hop ?? unavailableHopProvider; const nativeChatFiles = callbacks.nativeChatFiles ?? unavailableNativeChatFilesHost; @@ -350,6 +353,15 @@ export function createWasmRawCallbacks( (item) => sendItem(HostLocalStorageChangeItem.enc(item)), sendError, ), + ...(profile + ? { + presentProfile: async (product, request) => + await profile.presentProfile( + ProductContext.dec(product), + HostProfilePresentRequest.dec(request), + ), + } + : {}), subscribeTheme: (sendItem, sendError) => driveResultStream( callbacks.theme.subscribeTheme(), diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 1c3b5d6d0..9bfe201ff 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -42,6 +42,7 @@ import type { HostLocaleSubscribeItem, HostPocketListSubscribeItem, HostPocketRemoveCardRequest, + HostProfilePresentRequest, HostPushNotificationRequest, HostPushNotificationResponse, HostThemeSubscribeItem, @@ -2233,6 +2234,28 @@ export interface ProductStorage { ): AsyncIterable>; } +/** + * Host-implemented adapter that shows a product-referenced profile in + * host-owned UI. Optional: a host that omits it leaves Profile requests + * answered `Unsupported`. See `OptionalPlatform`. + * + * The reference is a bearer capability. The host resolves, decrypts and + * renders it; profile bytes and the reference's key never return to the + * product. The core screens only the reference's shape, so parsing it and + * deciding what it may fetch are the host's. + */ +export interface ProfilePlatform { + /** + * Take one presentation and return once it is shown, never waiting for + * the user to dismiss it. Report an unparseable reference as + * `InvalidReference`; show load and fetch failures in the UI instead. + */ + presentProfile( + product: ProductContext, + request: HostProfilePresentRequest, + ): Promise; +} + /** * Host theme source. */ @@ -2287,6 +2310,7 @@ export interface HostCallbacks { identityBackend?: IdentityBackendHost; permissionStatus?: PermissionStatusHost; pocket?: PocketPlatform; + profile?: ProfilePlatform; } export interface RequiredHostCallbacks { @@ -2310,4 +2334,5 @@ export interface RequiredHostCallbacks { identityBackend?: Required; permissionStatus?: Required; pocket?: Required; + profile?: Required; } diff --git a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs index 5c2d7f33b..c6be6ed6b 100644 --- a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs @@ -65,6 +65,7 @@ pub(super) struct JsBridge { pub(super) write: Function, pub(super) clear: Function, pub(super) subscribe_storage: Function, + pub(super) present_profile: Function, pub(super) subscribe_theme: Function, pub(super) confirm_permission: Function, pub(super) confirm_user_action: Function, @@ -73,6 +74,7 @@ pub(super) struct JsBridge { pub(super) identity_backend_present: bool, pub(super) permission_status_present: bool, pub(super) pocket_present: bool, + pub(super) profile_present: bool, } impl JsBridge { @@ -128,6 +130,8 @@ impl JsBridge { write: get_function(callbacks, "write")?, clear: get_function(callbacks, "clear")?, subscribe_storage: get_function(callbacks, "subscribeStorage")?, + present_profile: get_optional_function(callbacks, "presentProfile")? + .unwrap_or_else(|| missing_callback("presentProfile")), subscribe_theme: get_function(callbacks, "subscribeTheme")?, confirm_permission: get_function(callbacks, "confirmPermission")?, confirm_user_action: get_function(callbacks, "confirmUserAction")?, @@ -145,6 +149,7 @@ impl JsBridge { .is_some(), pocket_present: get_optional_function(callbacks, "subscribePocketCards")?.is_some() && get_optional_function(callbacks, "removePocketCard")?.is_some(), + profile_present: get_optional_function(callbacks, "presentProfile")?.is_some(), }) } @@ -172,6 +177,11 @@ impl JsBridge { pub(super) fn has_pocket(&self) -> bool { self.pocket_present } + + /// Whether the host supplied every `profile` callback. + pub(super) fn has_profile(&self) -> bool { + self.profile_present + } } impl truapi_platform::AuthPresenter for WasmPlatform { @@ -717,6 +727,25 @@ impl truapi_platform::ProductStorage for WasmPlatform { } } +#[truapi_platform::async_trait] +impl truapi_platform::ProfilePlatform for WasmPlatform { + async fn present_profile( + &self, + product: &truapi_platform::ProductContext, + request: v01::HostProfilePresentRequest, + ) -> Result<(), v01::HostProfilePresentError> { + invoke_unit( + &self.bridge.present_profile, + vec![ + Uint8Array::from(product.encode().as_slice()).into(), + Uint8Array::from(request.encode().as_slice()).into(), + ], + ) + .await + .map_err(|reason| v01::HostProfilePresentError::Unknown { reason }) + } +} + impl truapi_platform::ThemeHost for WasmPlatform { fn subscribe_theme( &self, diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index 2fbbcfd5a..a701655fe 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -42,6 +42,7 @@ export const CALLBACK_NAMES = [ "read", "write", "clear", + "presentProfile", "confirmPermission", "confirmUserAction", ] as const; @@ -327,6 +328,18 @@ function pocketRawCallbacks( }; } +function profileRawCallbacks( + bridge: WorkerCallbackBridge, +): Required> { + return { + presentProfile: (product, request) => + bridge.callbackRequest("presentProfile", [ + product, + request, + ]) as ReturnType["presentProfile"]>, + }; +} + /** * Optional capabilities the main-thread host actually serves. A * capability left out here is not proxied into the worker, so the @@ -343,6 +356,8 @@ export interface OptionalCapabilities { permissionStatus?: boolean; /** Whether the host serves this capability. */ pocket?: boolean; + /** Whether the host serves this capability. */ + profile?: boolean; } export function createWorkerRawCallbacks( @@ -363,6 +378,8 @@ export function createWorkerRawCallbacks( if (capabilities.permissionStatus) Object.assign(callbacks, permissionStatusRawCallbacks(bridge)); if (capabilities.pocket) Object.assign(callbacks, pocketRawCallbacks(bridge)); + if (capabilities.profile) + Object.assign(callbacks, profileRawCallbacks(bridge)); return callbacks; } diff --git a/rust/crates/truapi-platform/README.md b/rust/crates/truapi-platform/README.md index 1f9c3a8ec..7099363e9 100644 --- a/rust/crates/truapi-platform/README.md +++ b/rust/crates/truapi-platform/README.md @@ -59,15 +59,19 @@ revokes the grant. - `PocketPlatform`: stream the product's Pocket card collection and remove a card from it. The host owns the collection and decides which cards are privileged. +- `ProfilePlatform`: show a product-referenced profile in host-owned UI. The + host resolves, decrypts and renders the reference; nothing returns to the + product but acceptance. `Platform` is a blanket-implemented supertrait that combines the capability -traits above except `ChatPlatform`, `PermissionStatusHost` and -`PocketPlatform`, which `OptionalPlatform` lists instead: a host supplies each +traits above except `ChatPlatform`, `PermissionStatusHost`, `PocketPlatform` +and `ProfilePlatform`, which `OptionalPlatform` lists instead: a host supplies each only when it can serve it. Codegen reads `OptionalPlatform` to emit each listed capability as an optional group on the host-callback surface. Omitting `ChatPlatform` makes the core answer Chat calls `Unsupported`, and -omitting `PocketPlatform` does the same for Pocket calls. +omitting `PocketPlatform` or `ProfilePlatform` does the same for Pocket or +Profile calls. Omitting `PermissionStatusHost` leaves device grants resolving from stored state alone, which is what a host with no OS permission model does anyway. Serving it gates both halves of the surface: a device permission request and a diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 220040217..23557eef9 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -44,12 +44,12 @@ use truapi::latest::{ HostFeatureSupportedResponse, HostLocalStorageChangeItem, HostLocaleSubscribeItem, HostNativeChatAttachmentMetadata, HostNavigateToError, HostPlatform, HostPocketListSubscribeItem, HostPocketRemoveCardError, HostPocketRemoveCardRequest, - HostPushNotificationRequest, HostPushNotificationResponse, HostSignPayloadRequest, - HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, - HostSignRawWithLegacyAccountRequest, HostThemeSubscribeItem, HostWorkerBeginOperationResponse, - HostWorkerOperationError, LegacyAccountTxPayload, NotificationId, ProductAccountId, - ProductAccountTxPayload, ProductProofContext, RemotePermission, RemotePermissionRequest, - RingLocation, + HostProfilePresentError, HostProfilePresentRequest, HostPushNotificationRequest, + HostPushNotificationResponse, HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, + HostSignRawRequest, HostSignRawWithLegacyAccountRequest, HostThemeSubscribeItem, + HostWorkerBeginOperationResponse, HostWorkerOperationError, LegacyAccountTxPayload, + NotificationId, ProductAccountId, ProductAccountTxPayload, ProductProofContext, + RemotePermission, RemotePermissionRequest, RingLocation, }; use truapi::v01::HostAccountSignVrfRequest; use url::{Host, Url}; @@ -3783,6 +3783,26 @@ pub trait PocketPlatform: Send + Sync { ) -> Result<(), HostPocketRemoveCardError>; } +/// Host-implemented adapter that shows a product-referenced profile in +/// host-owned UI. Optional: a host that omits it leaves Profile requests +/// answered `Unsupported`. See [`OptionalPlatform`]. +/// +/// The reference is a bearer capability. The host resolves, decrypts and +/// renders it; profile bytes and the reference's key never return to the +/// product. The core screens only the reference's shape, so parsing it and +/// deciding what it may fetch are the host's. +#[async_trait] +pub trait ProfilePlatform: Send + Sync { + /// Take one presentation and return once it is shown, never waiting for + /// the user to dismiss it. Report an unparseable reference as + /// `InvalidReference`; show load and fetch failures in the UI instead. + async fn present_profile( + &self, + product: &ProductContext, + request: HostProfilePresentRequest, + ) -> Result<(), HostProfilePresentError>; +} + /// What the operating system currently says about a device capability. /// /// Distinct from [`PermissionAuthorizationStatus`], which is the product-scoped @@ -3903,7 +3923,12 @@ impl Platform for T where /// selects the built-in Rust wallet. Codegen reads this list to emit each /// capability as an optional group on the host-callback surface. pub trait OptionalPlatform: - ChatPlatform + PermissionStatusHost + PocketPlatform + IdentityBackendHost + CoinageWalletHost + ChatPlatform + + PermissionStatusHost + + PocketPlatform + + ProfilePlatform + + IdentityBackendHost + + CoinageWalletHost { } @@ -3911,6 +3936,7 @@ impl OptionalPlatform for T where T: ChatPlatform + PermissionStatusHost + PocketPlatform + + ProfilePlatform + IdentityBackendHost + CoinageWalletHost { diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 3a13c3c16..323d9537c 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -21,7 +21,9 @@ use thiserror::Error; use tracing::{instrument, warn}; use truapi::v01; use truapi::{CallContext, CancellationReason}; -use truapi_platform::{ChatPlatform, CoinageWalletHost, PermissionStatusHost, PocketPlatform}; +use truapi_platform::{ + ChatPlatform, CoinageWalletHost, PermissionStatusHost, PocketPlatform, ProfilePlatform, +}; use truapi_platform::{ CoreAdmin, PairingHostAdmin, PairingHostConfig, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, ProductContext, SigningHostConfig, @@ -246,6 +248,16 @@ impl PairingHostRuntime { self.services.install_pocket_platform(platform) } + /// Install the host's [`ProfilePlatform`], which renders product-referenced + /// profiles in host-owned UI. + /// + /// Set-once. Returns whether this call installed it. Call it before + /// serving any product runtime. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.set_profile_platform"))] + pub fn set_profile_platform(&self, platform: Arc) -> bool { + self.services.install_profile_platform(platform) + } + /// Build a product-facing runtime from this pairing host. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.product_runtime"))] pub fn product_runtime( @@ -637,6 +649,16 @@ impl SigningHostRuntime { self.services.install_pocket_platform(platform) } + /// Install the host's [`ProfilePlatform`], which renders product-referenced + /// profiles in host-owned UI. + /// + /// Set-once. Returns whether this call installed it. Call it before + /// serving any product runtime. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.set_profile_platform"))] + pub fn set_profile_platform(&self, platform: Arc) -> bool { + self.services.install_profile_platform(platform) + } + /// Install the host's [`DevicePairingObserver`], told whenever a device /// finishes pairing with this signing host. /// @@ -1128,7 +1150,7 @@ impl SigningHostRuntime { /// host-fed action streams. Non-native connections use [`Self::from_services`]. /// /// `pocket_platform` is the same kind of optional adapter for the card -/// collection. +/// collection, and `profile_platform` for host-rendered profiles. #[derive(Clone)] pub(crate) struct ConnectionAdapters { pub(crate) platform: Arc, @@ -1144,6 +1166,7 @@ pub(crate) struct ConnectionAdapters { pub(crate) renderer: Arc>, pub(crate) pocket_platform: Option>, + pub(crate) profile_platform: Option>, } impl ConnectionAdapters { @@ -1157,6 +1180,7 @@ impl ConnectionAdapters { chat: Arc::new(ActionChannel::chat()), renderer: Arc::new(ActionChannel::renderer()), pocket_platform: services.pocket_platform(), + profile_platform: services.profile_platform(), } } } diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 49a01109f..dcd9a3b96 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -1651,6 +1651,9 @@ impl NativeProductExecution { chat: self.chat_connection.clone(), renderer: self.renderer_connection.clone(), pocket_platform: self.pocket.clone(), + // Native hosts do not render profiles yet; Profile calls answer + // `Unsupported` there. + profile_platform: None, } } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 0594dc7d2..fe9c1b6a9 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -78,7 +78,7 @@ pub use signing_host::StatementRenewalTarget; #[cfg(not(target_arch = "wasm32"))] pub use signing_host::TrackedStatementRenewalTarget; use tracing::{instrument, warn}; -use truapi::api::{Chat, Pocket, Renderer}; +use truapi::api::{Chat, Pocket, Profile, Renderer}; use truapi::versioned::account::{ HostAccountGetError, HostAccountSignVrfError, HostProductDeviceChatError, }; @@ -94,6 +94,9 @@ use truapi::versioned::pocket::{ HostPocketRemoveCardError, HostPocketRemoveCardRequest, HostPocketRemoveCardResponse, }; use truapi::versioned::preimage::RemotePreimageSubmitError; +use truapi::versioned::profile::{ + HostProfilePresentError, HostProfilePresentRequest, HostProfilePresentResponse, +}; use truapi::versioned::renderer::{ HostRendererActionSubscribeError, HostRendererActionSubscribeItem, HostRendererActionSubscribeRequest, @@ -278,6 +281,7 @@ pub struct ProductRuntimeHost { chat: Arc>, renderer: Arc>, pocket_platform: Option>, + profile_platform: Option>, /// Host-assigned ids of this connection's open pending operations, each /// holding one worker reference until it ends or the connection is torn /// down. @@ -323,6 +327,7 @@ impl ProductRuntimeHost { chat: adapters.chat, renderer: adapters.renderer, pocket_platform: adapters.pocket_platform, + profile_platform: adapters.profile_platform, open_operations: Mutex::new(HashSet::new()), } } @@ -452,6 +457,7 @@ impl ProductRuntimeHost { chat, renderer, pocket_platform: None, + profile_platform: None, open_operations: Mutex::new(HashSet::new()), }; (host, pairing_host) @@ -1185,6 +1191,15 @@ impl ProductRuntimeHost { } self.pocket_platform.clone().ok_or(CallError::Unsupported) } + + /// The host's profile presenter. Any product execution may ask the host to + /// show a profile: the host renders it in its own UI, attributed to the + /// calling product, and nothing returns to the product. + fn profile_platform( + &self, + ) -> Result, CallError> { + self.profile_platform.clone().ok_or(CallError::Unsupported) + } } #[truapi_platform::async_trait] @@ -1363,6 +1378,43 @@ impl Pocket for ProductRuntimeHost { } } +/// Longest profile reference the core forwards. Seity blob references are +/// about 150 bytes; the bound leaves room for other formats without letting a +/// product push arbitrary payloads into host UI. +const MAX_PROFILE_REFERENCE_BYTES: usize = 2048; + +#[truapi::async_trait] +impl Profile for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "profile.present"))] + async fn present( + &self, + _cx: &CallContext, + request: HostProfilePresentRequest, + ) -> Result> { + let platform = self.profile_platform()?; + let HostProfilePresentRequest::V1(request) = request; + // The reference is opaque here; parsing it is the host's. The core + // screens only its shape: bounded, non-empty, printable ASCII without + // whitespace, so no control or bidi character reaches host code. + if !is_screened_profile_reference(&request.reference) { + return Err(CallError::Domain(HostProfilePresentError::V1( + v01::HostProfilePresentError::InvalidReference, + ))); + } + platform + .present_profile(&self.product, request) + .await + .map(|()| HostProfilePresentResponse::V1) + .map_err(|error| CallError::Domain(HostProfilePresentError::V1(error))) + } +} + +fn is_screened_profile_reference(reference: &str) -> bool { + !reference.is_empty() + && reference.len() <= MAX_PROFILE_REFERENCE_BYTES + && reference.bytes().all(|byte| byte.is_ascii_graphic()) +} + /// Report a rejected card id as a removal domain error. fn pocket_field_error(error: ChatFieldError) -> CallError { CallError::Domain(HostPocketRemoveCardError::V1( diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index b4601fcfa..fe6cea4d7 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -47,6 +47,9 @@ pub(crate) struct RuntimeServices { /// Host Pocket adapter, installed once at startup by a host with a Pocket /// surface. Unset leaves every product Pocket call `Unsupported`. pocket_platform: OnceLock>, + /// Host profile presenter, installed once at startup by a host that can + /// render profiles. Unset leaves every product Profile call `Unsupported`. + profile_platform: OnceLock>, /// Optional native authenticated username index; only supplies candidates. identity_backend: OnceLock>, /// Host observer told when a device finishes pairing with this signing @@ -137,6 +140,7 @@ impl RuntimeServices { native_wallet, permission_status: OnceLock::new(), pocket_platform: OnceLock::new(), + profile_platform: OnceLock::new(), identity_backend: OnceLock::new(), device_pairing_observer: OnceLock::new(), asset_hub_chain_genesis_hash, @@ -216,6 +220,22 @@ impl RuntimeServices { self.pocket_platform.get().cloned() } + /// Install the host's profile presenter. + /// + /// Set-once, like every optional capability. Returns whether this call + /// installed it. + pub(crate) fn install_profile_platform( + &self, + platform: Arc, + ) -> bool { + self.profile_platform.set(platform).is_ok() + } + + /// The host's profile presenter, when one is installed. + pub(crate) fn profile_platform(&self) -> Option> { + self.profile_platform.get().cloned() + } + /// Install the host's device-pairing observer. /// /// Set-once, like every optional capability, so the surface that announces diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index 9f1e0b138..0d89d3f89 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -1223,6 +1223,107 @@ fn pocket_is_denied_to_apps_and_sessionless_workers_and_unsupported_without_an_a )); } +/// Records every profile presentation that reaches the host. +#[derive(Default)] +struct RecordingProfilePlatform { + presented: Mutex>, +} + +#[truapi::async_trait] +impl truapi_platform::ProfilePlatform for RecordingProfilePlatform { + async fn present_profile( + &self, + product: &ProductContext, + request: truapi::latest::HostProfilePresentRequest, + ) -> Result<(), truapi::latest::HostProfilePresentError> { + self.presented + .lock() + .expect("presented mutex poisoned") + .push((product.product_id.clone(), request.reference)); + Ok(()) + } +} + +fn profile_host(profile: Option>) -> ProductRuntimeHost { + let (host_config, product) = runtime_config("egui-chat.dot"); + let services = RuntimeServices::new( + stub_platform(), + host_config.host.host_info.clone(), + host_config.people_chain_genesis_hash, + host_config.bulletin_chain_genesis_hash, + host_config.asset_hub_chain_genesis_hash, + test_spawner(), + ); + let pairing_host = PairingHost::new(services.clone(), host_config); + let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); + adapters.profile_platform = + profile.map(|profile| profile as Arc); + ProductRuntimeHost::from_services(services, adapters, pairing_host, product) +} + +fn present_profile( + host: &ProductRuntimeHost, + reference: &str, +) -> Result> { + futures::executor::block_on(Profile::present( + host, + &CallContext::default(), + HostProfilePresentRequest::V1(v01::HostProfilePresentRequest { + reference: reference.to_string(), + }), + )) +} + +#[test] +fn profile_present_forwards_screened_references_and_is_unsupported_without_an_adapter() { + let profile = Arc::new(RecordingProfilePlatform::default()); + let host = profile_host(Some(profile.clone())); + let reference = format!("bafkreitest#{}", "ab".repeat(44)); + let longest = "a".repeat(2048); + + assert_eq!( + present_profile(&host, &reference).expect("a screened reference is presented"), + HostProfilePresentResponse::V1 + ); + assert_eq!( + present_profile(&host, &longest).expect("the bound is inclusive"), + HostProfilePresentResponse::V1 + ); + // Anything that could render deceptively or carry a payload into host UI + // is refused in the core, whatever the host would have done with it. + for rejected in [ + String::new(), + "a".repeat(2049), + "bafk ref#00".to_string(), + "bafk\u{202e}ref".to_string(), + "bafk\nref".to_string(), + ] { + assert!(matches!( + present_profile(&host, &rejected), + Err(CallError::Domain(HostProfilePresentError::V1( + v01::HostProfilePresentError::InvalidReference + ))) + )); + } + assert_eq!( + profile + .presented + .lock() + .expect("presented mutex poisoned") + .as_slice(), + [ + ("egui-chat.dot".to_string(), reference), + ("egui-chat.dot".to_string(), longest), + ], + "only screened references reach the host, attributed to the caller" + ); + + assert!(matches!( + present_profile(&profile_host(None), "bafkreitest#00"), + Err(CallError::Unsupported) + )); +} + #[test] fn chain_follow_ids_are_scoped_per_product_core() { let (host_config, product) = runtime_config("same.dot"); diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index aaeed3550..3ece1a9b8 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -27,7 +27,7 @@ use truapi::v01; use truapi_platform::{ ChainProvider, ChatPlatform, HopProvider, HostInfo, JsonRpcConnection, PairingHostConfig, PermissionStatusHost, PlatformInfo, PocketPlatform, ProductContext, ProductExecutionKind, - RuntimeConfigValidationError, + ProfilePlatform, RuntimeConfigValidationError, }; #[cfg(feature = "wasm-signing-host")] use truapi_platform::{CoinageWalletHost, IdentityBackendHost, SigningHostConfig}; @@ -956,6 +956,7 @@ struct WasmPlatformAdapters { chat_platform: Option>, status_host: Option>, pocket_platform: Option>, + profile_platform: Option>, #[cfg(feature = "wasm-signing-host")] identity_backend_host: Option>, #[cfg(feature = "wasm-signing-host")] @@ -967,6 +968,7 @@ fn wasm_platform(bridge: Arc) -> WasmPlatformAdapters { let has_chat = bridge.has_chat(); let has_permission_status = bridge.has_permission_status(); let has_pocket = bridge.has_pocket(); + let has_profile = bridge.has_profile(); #[cfg(feature = "wasm-signing-host")] let has_identity_backend = bridge.has_identity_backend(); #[cfg(feature = "wasm-signing-host")] @@ -975,6 +977,7 @@ fn wasm_platform(bridge: Arc) -> WasmPlatformAdapters { let chat = has_chat.then(|| platform.clone() as Arc); let status = has_permission_status.then(|| platform.clone() as Arc); let pocket = has_pocket.then(|| platform.clone() as Arc); + let profile = has_profile.then(|| platform.clone() as Arc); #[cfg(feature = "wasm-signing-host")] let identity_backend = has_identity_backend.then(|| platform.clone() as Arc); @@ -985,6 +988,7 @@ fn wasm_platform(bridge: Arc) -> WasmPlatformAdapters { chat_platform: chat, status_host: status, pocket_platform: pocket, + profile_platform: profile, #[cfg(feature = "wasm-signing-host")] identity_backend_host: identity_backend, #[cfg(feature = "wasm-signing-host")] @@ -1004,6 +1008,7 @@ fn connection_adapters_from_js( chat_platform, status_host, pocket_platform, + profile_platform, .. } = wasm_platform(Arc::new(JsBridge::from_js(callbacks)?)); Ok(Some(crate::host_core::ConnectionAdapters { @@ -1014,6 +1019,7 @@ fn connection_adapters_from_js( // native adapter and `ConnectionAdapters`' own default. permission_grants: std::sync::Arc::default(), pocket_platform, + profile_platform, chat: Arc::new(crate::runtime::ActionChannel::chat()), renderer: Arc::new(crate::runtime::ActionChannel::renderer()), })) @@ -1078,6 +1084,7 @@ impl WasmPairingHostRuntime { chat_platform, status_host, pocket_platform, + profile_platform, .. } = wasm_platform(bridge); let spawner: Spawner = Arc::new(|fut| { @@ -1092,6 +1099,9 @@ impl WasmPairingHostRuntime { if let Some(pocket_platform) = pocket_platform { runtime.set_pocket_platform(pocket_platform); } + if let Some(profile_platform) = profile_platform { + runtime.set_profile_platform(profile_platform); + } install_worker_demand_observer(runtime.worker_ledger(), &callbacks)?; Ok(Self { runtime: Rc::new(runtime), @@ -1384,6 +1394,7 @@ impl WasmSigningHostRuntime { chat_platform, status_host, pocket_platform, + profile_platform, identity_backend_host, native_wallet, } = wasm_platform(bridge); @@ -1407,6 +1418,9 @@ impl WasmSigningHostRuntime { if let Some(pocket_platform) = pocket_platform { runtime.set_pocket_platform(pocket_platform); } + if let Some(profile_platform) = profile_platform { + runtime.set_profile_platform(profile_platform); + } install_worker_demand_observer(runtime.worker_ledger(), &callbacks)?; Ok(Self { runtime: Rc::new(runtime), @@ -1728,6 +1742,7 @@ impl WasmProductRuntime { chat_platform, status_host, pocket_platform, + profile_platform, .. } = wasm_platform(bridge); let spawner: Spawner = Arc::new(|fut| { @@ -1744,6 +1759,9 @@ impl WasmProductRuntime { if let Some(pocket_platform) = pocket_platform { pairing.set_pocket_platform(pocket_platform); } + if let Some(profile_platform) = profile_platform { + pairing.set_profile_platform(profile_platform); + } install_worker_demand_observer(pairing.worker_ledger(), &callbacks)?; let core = pairing.product_runtime(product, frame_sink); Ok(Self::from_parts(core, channel.dispose)) diff --git a/rust/crates/truapi/src/api.rs b/rust/crates/truapi/src/api.rs index 9c01d39f4..20a268cc2 100644 --- a/rust/crates/truapi/src/api.rs +++ b/rust/crates/truapi/src/api.rs @@ -12,6 +12,7 @@ pub mod payment; pub mod permissions; pub mod pocket; pub mod preimage; +pub mod profile; pub mod renderer; pub mod resource_allocation; pub mod signing; @@ -32,6 +33,7 @@ pub use payment::Payment; pub use permissions::Permissions; pub use pocket::Pocket; pub use preimage::Preimage; +pub use profile::Profile; pub use renderer::Renderer; pub use resource_allocation::ResourceAllocation; pub use signing::Signing; @@ -54,6 +56,7 @@ pub trait TrUApi: + Permissions + Pocket + Preimage + + Profile + Renderer + ResourceAllocation + Signing @@ -79,6 +82,7 @@ impl TrUApi for T where + Permissions + Pocket + Preimage + + Profile + Renderer + ResourceAllocation + Signing diff --git a/rust/crates/truapi/src/api/profile.rs b/rust/crates/truapi/src/api/profile.rs new file mode 100644 index 000000000..ab6decdc3 --- /dev/null +++ b/rust/crates/truapi/src/api/profile.rs @@ -0,0 +1,36 @@ +//! Unified [`Profile`] trait. + +use crate::versioned::profile::{ + HostProfilePresentError, HostProfilePresentRequest, HostProfilePresentResponse, +}; +use crate::{CallContext, CallError}; +use crate::{wire, wire_trait}; + +/// Profiles shown in host-owned UI. +/// +/// The product hands over an opaque reference; the host resolves, decrypts and +/// renders it. Profile bytes never return to the product. +#[wire_trait(id = 20)] +#[crate::async_trait] +pub trait Profile: Send + Sync { + /// Show the referenced profile in host-owned UI. + /// + /// Resolves once the host has taken the presentation, not when the user + /// dismisses it. Loading and fetch failures are shown to the user, not + /// returned; a reference this host cannot parse is `InvalidReference`. + /// + /// ```ts + /// const result = await truapi.profile.present({ + /// reference: "bafkreigh2akiscaildc6ybwhxslp6rx2u4m2vpbhgvzhpsfkyzxiezxcnq#" + "00".repeat(44), + /// }); + /// console.log("profile presentation:", result); + /// ``` + #[wire(id = 0)] + async fn present( + &self, + _cx: &CallContext, + _request: HostProfilePresentRequest, + ) -> Result> { + Err(CallError::unavailable()) + } +} diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index eb353b7ef..2e3215d7d 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -186,6 +186,10 @@ pub mod latest { pub type HostPocketRemoveCardRequest = LatestOf; /// Pocket card removal failure. pub type HostPocketRemoveCardError = LatestOf; + /// Profile presentation request. + pub type HostProfilePresentRequest = LatestOf; + /// Profile presentation failure. + pub type HostProfilePresentError = LatestOf; /// Push notification scheduling request. pub type HostPushNotificationRequest = LatestOf; diff --git a/rust/crates/truapi/src/v01.rs b/rust/crates/truapi/src/v01.rs index cec1d7f01..1b75fad30 100644 --- a/rust/crates/truapi/src/v01.rs +++ b/rust/crates/truapi/src/v01.rs @@ -13,6 +13,7 @@ mod payment; mod permissions; mod pocket; mod preimage; +mod profile; mod renderer; mod resource_allocation; mod signing; @@ -35,6 +36,7 @@ pub use payment::*; pub use permissions::*; pub use pocket::*; pub use preimage::*; +pub use profile::*; pub use renderer::*; pub use resource_allocation::*; pub use signing::*; diff --git a/rust/crates/truapi/src/v01/profile.rs b/rust/crates/truapi/src/v01/profile.rs new file mode 100644 index 000000000..17786331e --- /dev/null +++ b/rust/crates/truapi/src/v01/profile.rs @@ -0,0 +1,36 @@ +use alloc::string::String; +use core::fmt; +use parity_scale_codec::{Decode, Encode}; + +/// Request to show a profile the calling product references in host-owned UI. +/// +/// The reference is a bearer capability: whoever holds it can read the profile +/// it names. The host resolves and renders it itself, so profile bytes, the +/// avatar image included, never reach the product. +#[derive(Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct HostProfilePresentRequest { + /// Opaque profile reference, e.g. a Seity `#` blob reference. + pub reference: String, +} + +impl fmt::Debug for HostProfilePresentRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HostProfilePresentRequest") + .field("reference", &"[REDACTED]") + .finish() + } +} + +/// Profile presentation failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum HostProfilePresentError { + /// The reference is malformed or names a format this host cannot open. + InvalidReference, + /// Catch-all. + Unknown { + /// Human-readable reason. + reason: String, + }, +} diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs index 7833fb93d..5218e5da9 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -47,6 +47,7 @@ pub mod payment; pub mod permissions; pub mod pocket; pub mod preimage; +pub mod profile; pub mod renderer; pub mod resource_allocation; pub mod signing; diff --git a/rust/crates/truapi/src/versioned/profile.rs b/rust/crates/truapi/src/versioned/profile.rs new file mode 100644 index 000000000..00903f6f8 --- /dev/null +++ b/rust/crates/truapi/src/versioned/profile.rs @@ -0,0 +1,9 @@ +//! Versioned wrappers for [`Profile`](crate::api::Profile) methods. + +use crate::v01; + +truapi_macros::versioned_type! { + pub enum HostProfilePresentRequest { V1 => v01::HostProfilePresentRequest } + pub enum HostProfilePresentResponse { V1 } + pub enum HostProfilePresentError { V1 => v01::HostProfilePresentError } +} From 0091b6fc81a5c25ccff191e0a473ea5bf26d99a6 Mon Sep 17 00:00:00 2001 From: Corey Hathaway Date: Sat, 26 Sep 2026 15:18:19 +0100 Subject: [PATCH 02/30] feat(truapi): disclose a profile to chat contacts, present a contact's by name On top of `profile.present`: - `disclose({ reference })` (method 1, App only) stores the user's own reference with the product that disclosed it; `retract()` (2) withdraws it, and only the discloser may. - `presentContact({ peerIdentity })` (3) names a chat contact; the host looks up the reference that contact's host sent and hands it to the existing `ProfilePlatform::present_profile`. The product never holds a contact's reference, so it cannot read, keep, forward or substitute it. Both kinds of reference live in core storage: `ProfileDisclosure` (wallet-owned) and `ProfileReferencesReceived { product_id }` (cleared with the chat product, like its roster). The chat relay that fills the latter comes next; its write helper is here and tested. The CLI installs a presenter that records each presentation to `TRUAPI_PROFILE_LOG` as a SHA-256 and format prefix, never the reference. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/profile-disclose.md | 10 + rust/crates/truapi-client/src/generated.rs | 92 ++++++- .../tests/golden/host-callbacks.ts | 18 +- rust/crates/truapi-host-cli/src/main.rs | 3 + rust/crates/truapi-host-cli/src/profile.rs | 94 +++++++ rust/crates/truapi-platform/src/lib.rs | 17 ++ rust/crates/truapi-platform/src/mock.rs | 4 + rust/crates/truapi-server/src/runtime.rs | 113 ++++++++- .../truapi-server/src/runtime/profile.rs | 139 +++++++++++ .../crates/truapi-server/src/runtime/tests.rs | 230 ++++++++++++++++++ rust/crates/truapi/src/api/profile.rs | 64 ++++- rust/crates/truapi/src/v01/profile.rs | 72 ++++++ rust/crates/truapi/src/versioned/profile.rs | 9 + 13 files changed, 861 insertions(+), 4 deletions(-) create mode 100644 .changeset/profile-disclose.md create mode 100644 rust/crates/truapi-host-cli/src/profile.rs create mode 100644 rust/crates/truapi-server/src/runtime/profile.rs diff --git a/.changeset/profile-disclose.md b/.changeset/profile-disclose.md new file mode 100644 index 000000000..4b8bed0fc --- /dev/null +++ b/.changeset/profile-disclose.md @@ -0,0 +1,10 @@ +--- +"@parity/truapi": minor +"@parity/truapi-host": minor +--- + +Add `profile.disclose`, `profile.retract` and `profile.presentContact`. A product discloses one opaque reference to +the user's chat contacts and may withdraw it; a product names a contact by peer identity and the host presents the +reference that contact disclosed, so no product holds a contact's reference. Disclosed and received references live in +core storage (`ProfileDisclosure`, `ProfileReferencesReceived`); the chat relay that fills the latter is not part of +this change. diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 480da1686..9ff49d68f 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "c8972ad11436a788"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "5e0d5318926dc17f"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -1654,6 +1654,87 @@ impl RequestMethod for ProfilePresent { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } +/// `profile_disclose` method marker. +pub struct ProfileDisclose; +impl ProfileDisclose { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Profile", + method: "disclose", + wire_name: "profile_disclose", + request_type: "truapi::versioned::profile::HostProfileDiscloseRequest", + response_type: "truapi::versioned::profile::HostProfileDiscloseResponse", + error_type: Some("truapi::versioned::profile::HostProfileDiscloseError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 20, + method_id: 1, + }), + }; +} +impl RequestMethod for ProfileDisclose { + type Request = truapi::versioned::profile::HostProfileDiscloseRequest; + type Response = truapi::versioned::profile::HostProfileDiscloseResponse; + type Error = truapi::versioned::profile::HostProfileDiscloseError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `profile_retract` method marker. +pub struct ProfileRetract; +impl ProfileRetract { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Profile", + method: "retract", + wire_name: "profile_retract", + request_type: "truapi::versioned::profile::HostProfileRetractRequest", + response_type: "truapi::versioned::profile::HostProfileRetractResponse", + error_type: Some("truapi::versioned::profile::HostProfileRetractError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 20, + method_id: 2, + }), + }; +} +impl RequestMethod for ProfileRetract { + type Request = truapi::versioned::profile::HostProfileRetractRequest; + type Response = truapi::versioned::profile::HostProfileRetractResponse; + type Error = truapi::versioned::profile::HostProfileRetractError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `profile_present_contact` method marker. +pub struct ProfilePresentContact; +impl ProfilePresentContact { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Profile", + method: "present_contact", + wire_name: "profile_present_contact", + request_type: "truapi::versioned::profile::HostProfilePresentContactRequest", + response_type: "truapi::versioned::profile::HostProfilePresentContactResponse", + error_type: Some("truapi::versioned::profile::HostProfilePresentContactError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 20, + method_id: 3, + }), + }; +} +impl RequestMethod for ProfilePresentContact { + type Request = truapi::versioned::profile::HostProfilePresentContactRequest; + type Response = truapi::versioned::profile::HostProfilePresentContactResponse; + type Error = truapi::versioned::profile::HostProfilePresentContactError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + /// `renderer_render` method marker. pub struct RendererRender; impl RendererRender { @@ -2339,6 +2420,9 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ PreimageLookupSubscribe::DESCRIPTOR, PreimageSubmit::DESCRIPTOR, ProfilePresent::DESCRIPTOR, + ProfileDisclose::DESCRIPTOR, + ProfileRetract::DESCRIPTOR, + ProfilePresentContact::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, SigningCreateTransaction::DESCRIPTOR, SigningCreateTransactionWithLegacyAccount::DESCRIPTOR, @@ -2416,6 +2500,9 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ PreimageLookupSubscribe::DESCRIPTOR, PreimageSubmit::DESCRIPTOR, ProfilePresent::DESCRIPTOR, + ProfileDisclose::DESCRIPTOR, + ProfileRetract::DESCRIPTOR, + ProfilePresentContact::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, SigningCreateTransaction::DESCRIPTOR, SigningCreateTransactionWithLegacyAccount::DESCRIPTOR, @@ -2500,6 +2587,9 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ PreimageLookupSubscribe::DESCRIPTOR, PreimageSubmit::DESCRIPTOR, ProfilePresent::DESCRIPTOR, + ProfileDisclose::DESCRIPTOR, + ProfileRetract::DESCRIPTOR, + ProfilePresentContact::DESCRIPTOR, RendererRender::DESCRIPTOR, RendererActionSubscribe::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 9bfe201ff..22a70100d 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -262,7 +262,19 @@ export type CoreStorageKey = | { tag: "NativeChatProducts"; value: { rootPublicKey: Uint8Array; genesisHash: Uint8Array }; - }; + } + /** + * The profile reference the user disclosed to their chat contacts, with + * the product that disclosed it. Wallet-owned: one per user, whichever + * product wrote it. The reference is a bearer capability. + */ + | { tag: "ProfileDisclosure"; value?: undefined } + /** + * Profile references this product's chat contacts disclosed, newest per + * contact. Product-indexed, like the roster they belong to, so clearing + * the product clears them. The references are bearer capabilities. + */ + | { tag: "ProfileReferencesReceived"; value: { productId: string } }; /** * Review shown before a product creates a ring-VRF proof (RFC 0004). @@ -1127,6 +1139,10 @@ export const CoreStorageKey: S.Codec = S.lazy( rootPublicKey: S.Bytes(32), genesisHash: S.Bytes(32), }) as S.Codec<{ rootPublicKey: Uint8Array; genesisHash: Uint8Array }>, + ProfileDisclosure: S._void, + ProfileReferencesReceived: S.Struct({ productId: S.str }) as S.Codec<{ + productId: string; + }>, }), ); diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 3325b91f9..3c9ab844e 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -24,6 +24,7 @@ mod network; mod platform; mod pocket; mod product_config; +mod profile; mod qr_scanner; mod register_name; mod script_project; @@ -1262,6 +1263,7 @@ async fn run_pairing_host( if let Some(pocket) = pocket_host { pairing_runtime.set_pocket_platform(pocket); } + pairing_runtime.set_profile_platform(profile::CliProfileHost::from_env()); // Resolved before the port is bound, so a bad URL still fails on the argument // rather than half-way through startup - but reported below, once the UI @@ -1796,6 +1798,7 @@ fn build_signing_runtime( if let Some(pocket) = pocket { runtime.set_pocket_platform(pocket); } + runtime.set_profile_platform(profile::CliProfileHost::from_env()); runtime.start_statement_allowance_renewal(); Ok((runtime, platform)) } diff --git a/rust/crates/truapi-host-cli/src/profile.rs b/rust/crates/truapi-host-cli/src/profile.rs new file mode 100644 index 000000000..7d4f4ffe3 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/profile.rs @@ -0,0 +1,94 @@ +//! Profile presenter for the CLI. +//! +//! The CLI has no UI to draw a profile in, so a presentation is accepted and +//! recorded: every `present` (and `present_contact`, which reaches the host as +//! a `present` of the reference the core substituted) is appended to the +//! transcript named by `TRUAPI_PROFILE_LOG`, one JSON object per line, so a +//! battery can assert what the host was handed. +//! +//! The reference is a bearer capability, so the transcript carries its +//! SHA-256 and format prefix, never the reference itself. + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Arc; + +use sha2::{Digest, Sha256}; +use truapi::latest::{HostProfilePresentError, HostProfilePresentRequest}; +use truapi_platform::{ProductContext, ProfilePlatform, async_trait}; + +/// A presenter that shows nothing and remembers everything it was asked. +pub struct CliProfileHost { + transcript: Option, +} + +impl CliProfileHost { + /// Build a presenter recording to `TRUAPI_PROFILE_LOG` when that names a + /// path. The transcript is truncated at startup so a run never reads an + /// earlier run's presentations as its own. + pub fn from_env() -> Arc { + let transcript = std::env::var_os("TRUAPI_PROFILE_LOG").map(PathBuf::from); + if let Some(path) = transcript.as_ref() + && let Err(error) = std::fs::write(path, b"") + { + tracing::warn!(?path, %error, "profile transcript could not be truncated"); + } + Arc::new(Self { transcript }) + } + + fn record(&self, line: serde_json::Value) { + let Some(path) = self.transcript.as_ref() else { + return; + }; + let appended = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .and_then(|mut file| file.write_all(format!("{line}\n").as_bytes())); + if let Err(error) = appended { + tracing::warn!(?path, %error, "profile transcript could not be appended to"); + } + } +} + +/// The part of a reference before its first `:` or `#`, which names its format +/// without revealing its secret. +fn reference_kind(reference: &str) -> &str { + let end = reference + .find(['#', ':']) + .unwrap_or(reference.len()) + .min(32); + &reference[..end] +} + +#[async_trait] +impl ProfilePlatform for CliProfileHost { + async fn present_profile( + &self, + product: &ProductContext, + request: HostProfilePresentRequest, + ) -> Result<(), HostProfilePresentError> { + let digest = hex::encode(Sha256::digest(request.reference.as_bytes())); + tracing::info!(product = %product.product_id, %digest, "profile presented"); + self.record(serde_json::json!({ + "event": "present", + "product": product.product_id, + "kind": reference_kind(&request.reference), + "sha256": digest, + })); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_reference_kind_never_includes_its_secret() { + assert_eq!(reference_kind("seity-contacts:v1:abcd"), "seity-contacts"); + assert_eq!(reference_kind("bafk2bz#00ff"), "bafk2bz"); + assert_eq!(reference_kind(&"a".repeat(80)).len(), 32); + } +} diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 23557eef9..c539a3146 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -1912,6 +1912,19 @@ pub enum CoreStorageKey { /// Host-selected Chat network. genesis_hash: [u8; 32], }, + /// The profile reference the user disclosed to their chat contacts, with + /// the product that disclosed it. Wallet-owned: one per user, whichever + /// product wrote it. The reference is a bearer capability. + #[codec(index = 17)] + ProfileDisclosure, + /// Profile references this product's chat contacts disclosed, newest per + /// contact. Product-indexed, like the roster they belong to, so clearing + /// the product clears them. The references are bearer capabilities. + #[codec(index = 18)] + ProfileReferencesReceived { + /// Chat product whose contacts sent the references. + product_id: String, + }, } /// Stable metadata describing one strictly decoded [`CoreStorageKey`]. @@ -1968,6 +1981,10 @@ pub fn describe_core_storage_key( CoreStorageKey::MainPurseCoinage { .. } => ("MainPurseCoinage", None), CoreStorageKey::NativeChatDevice { .. } => ("NativeChatDevice", None), CoreStorageKey::NativeChatProducts { .. } => ("NativeChatProducts", None), + CoreStorageKey::ProfileDisclosure => ("ProfileDisclosure", None), + CoreStorageKey::ProfileReferencesReceived { product_id } => { + ("ProfileReferencesReceived", Some(product_id)) + } CoreStorageKey::NativeChatFileChunk { product_id, .. } => { ("NativeChatFileChunk", Some(product_id)) } diff --git a/rust/crates/truapi-platform/src/mock.rs b/rust/crates/truapi-platform/src/mock.rs index faf473415..77ed5f2a5 100644 --- a/rust/crates/truapi-platform/src/mock.rs +++ b/rust/crates/truapi-platform/src/mock.rs @@ -827,6 +827,10 @@ fn core_key(key: &CoreStorageKey) -> String { hex_key(root_public_key), hex_key(genesis_hash) ), + CoreStorageKey::ProfileDisclosure => "core:profile-disclosure".to_string(), + CoreStorageKey::ProfileReferencesReceived { product_id } => { + format!("core:profile-references-received:{product_id}") + } CoreStorageKey::NativeChatFileChunk { root_public_key, genesis_hash, diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index fe9c1b6a9..0d031535d 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -28,6 +28,7 @@ mod native_chat; mod pairing_host; pub(crate) mod product_manifest; mod product_subtree; +mod profile; mod renderer; mod ring_vrf_registry; /// Role-neutral runtime services shared by product-facing runtimes. @@ -95,7 +96,11 @@ use truapi::versioned::pocket::{ }; use truapi::versioned::preimage::RemotePreimageSubmitError; use truapi::versioned::profile::{ - HostProfilePresentError, HostProfilePresentRequest, HostProfilePresentResponse, + HostProfileDiscloseError, HostProfileDiscloseRequest, HostProfileDiscloseResponse, + HostProfilePresentContactError, HostProfilePresentContactRequest, + HostProfilePresentContactResponse, HostProfilePresentError, HostProfilePresentRequest, + HostProfilePresentResponse, HostProfileRetractError, HostProfileRetractRequest, + HostProfileRetractResponse, }; use truapi::versioned::renderer::{ HostRendererActionSubscribeError, HostRendererActionSubscribeItem, @@ -1407,6 +1412,112 @@ impl Profile for ProductRuntimeHost { .map(|()| HostProfilePresentResponse::V1) .map_err(|error| CallError::Domain(HostProfilePresentError::V1(error))) } + + #[instrument(skip_all, fields(runtime.method = "profile.disclose"))] + async fn disclose( + &self, + _cx: &CallContext, + request: HostProfileDiscloseRequest, + ) -> Result> { + // The user's own profile is disclosed from where they manage it, an + // App, not from a background Worker. + if self.product.execution_kind != truapi_platform::ProductExecutionKind::App { + return Err(CallError::Denied); + } + let HostProfileDiscloseRequest::V1(request) = request; + if !is_screened_profile_reference(&request.reference) { + return Err(CallError::Domain(HostProfileDiscloseError::V1( + v01::HostProfileDiscloseError::InvalidReference, + ))); + } + let disclosure = profile::Disclosure { + product_id: self.product_id(), + reference: request.reference, + }; + profile::write_disclosure(self.platform.as_ref(), &disclosure) + .await + .map(|()| HostProfileDiscloseResponse::V1) + .map_err(|reason| { + CallError::Domain(HostProfileDiscloseError::V1( + v01::HostProfileDiscloseError::Unknown { reason }, + )) + }) + } + + #[instrument(skip_all, fields(runtime.method = "profile.retract"))] + async fn retract( + &self, + _cx: &CallContext, + _request: HostProfileRetractRequest, + ) -> Result> { + if self.product.execution_kind != truapi_platform::ProductExecutionKind::App { + return Err(CallError::Denied); + } + let unknown = |reason| { + CallError::Domain(HostProfileRetractError::V1( + v01::HostProfileRetractError::Unknown { reason }, + )) + }; + let storage = self.platform.as_ref(); + match profile::read_disclosure(storage).await.map_err(unknown)? { + None => Ok(HostProfileRetractResponse::V1), + // One product may not withdraw what another disclosed. + Some(disclosure) if disclosure.product_id != self.product_id() => { + Err(CallError::Domain(HostProfileRetractError::V1( + v01::HostProfileRetractError::NotDiscloser, + ))) + } + Some(_) => profile::clear_disclosure(storage) + .await + .map(|()| HostProfileRetractResponse::V1) + .map_err(unknown), + } + } + + #[instrument(skip_all, fields(runtime.method = "profile.present_contact"))] + async fn present_contact( + &self, + _cx: &CallContext, + request: HostProfilePresentContactRequest, + ) -> Result> { + let platform = self.profile_platform()?; + let HostProfilePresentContactRequest::V1(request) = request; + let domain = |error| CallError::Domain(HostProfilePresentContactError::V1(error)); + let received = profile::received_reference( + self.platform.as_ref(), + &self.product_id(), + &request.peer_identity, + ) + .await + .map_err(|reason| domain(v01::HostProfilePresentContactError::Unknown { reason }))? + .ok_or_else(|| domain(v01::HostProfilePresentContactError::NotShared))?; + // A stored reference passed the same screen when it arrived; check + // again rather than trust storage. + if !is_screened_profile_reference(&received.reference) { + return Err(domain( + v01::HostProfilePresentContactError::InvalidReference, + )); + } + platform + .present_profile( + &self.product, + v01::HostProfilePresentRequest { + reference: received.reference, + }, + ) + .await + .map(|()| HostProfilePresentContactResponse::V1) + .map_err(|error| { + domain(match error { + v01::HostProfilePresentError::InvalidReference => { + v01::HostProfilePresentContactError::InvalidReference + } + v01::HostProfilePresentError::Unknown { reason } => { + v01::HostProfilePresentContactError::Unknown { reason } + } + }) + }) + } } fn is_screened_profile_reference(reference: &str) -> bool { diff --git a/rust/crates/truapi-server/src/runtime/profile.rs b/rust/crates/truapi-server/src/runtime/profile.rs new file mode 100644 index 000000000..43ab4814c --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/profile.rs @@ -0,0 +1,139 @@ +//! Profile disclosure state: the reference the user disclosed to their chat +//! contacts, and the references this product's contacts disclosed to them. +//! +//! Both are bearer capabilities. They live in core storage, never in product +//! storage, and never cross back to a product: `present_contact` names a +//! contact and the host substitutes the reference. + +use parity_scale_codec::{Decode, Encode}; +use truapi_platform::{CoreStorage, CoreStorageKey}; + +/// The user's own disclosed reference and the product that disclosed it. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub(crate) struct Disclosure { + pub(crate) product_id: String, + pub(crate) reference: String, +} + +/// One contact's disclosed reference, as their host sent it. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub(crate) struct ReceivedReference { + pub(crate) peer_identity: [u8; 32], + /// The product on the contact's side that disclosed it. + pub(crate) discloser_product_id: String, + pub(crate) reference: String, +} + +/// Versioned so the slot can change shape without a silent misread. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +enum StoredReferences { + #[codec(index = 0)] + V1(Vec), +} + +/// A contact roster is bounded; so is what the host keeps for it. +#[allow(dead_code, reason = "written by the chat relay, which lands next")] +const MAX_RECEIVED_REFERENCES: usize = 4096; + +fn storage_error(error: impl core::fmt::Debug) -> String { + format!("profile storage failed: {error:?}") +} + +pub(crate) async fn read_disclosure( + storage: &(impl CoreStorage + ?Sized), +) -> Result, String> { + let Some(raw) = storage + .read_core_storage(CoreStorageKey::ProfileDisclosure) + .await + .map_err(storage_error)? + else { + return Ok(None); + }; + Disclosure::decode(&mut raw.as_slice()) + .map(Some) + .map_err(|error| format!("stored profile disclosure is unreadable: {error}")) +} + +pub(crate) async fn write_disclosure( + storage: &(impl CoreStorage + ?Sized), + disclosure: &Disclosure, +) -> Result<(), String> { + storage + .write_core_storage(CoreStorageKey::ProfileDisclosure, disclosure.encode()) + .await + .map_err(storage_error) +} + +pub(crate) async fn clear_disclosure(storage: &(impl CoreStorage + ?Sized)) -> Result<(), String> { + storage + .clear_core_storage(CoreStorageKey::ProfileDisclosure) + .await + .map_err(storage_error) +} + +async fn read_received( + storage: &(impl CoreStorage + ?Sized), + product_id: &str, +) -> Result, String> { + let key = CoreStorageKey::ProfileReferencesReceived { + product_id: product_id.to_string(), + }; + let Some(raw) = storage + .read_core_storage(key) + .await + .map_err(storage_error)? + else { + return Ok(Vec::new()); + }; + match StoredReferences::decode(&mut raw.as_slice()) { + Ok(StoredReferences::V1(entries)) => Ok(entries), + Err(error) => Err(format!("stored profile references are unreadable: {error}")), + } +} + +/// The reference a contact disclosed to this product's user, if any. +pub(crate) async fn received_reference( + storage: &(impl CoreStorage + ?Sized), + product_id: &str, + peer_identity: &[u8; 32], +) -> Result, String> { + Ok(read_received(storage, product_id) + .await? + .into_iter() + .find(|entry| &entry.peer_identity == peer_identity)) +} + +/// Record what a contact's host sent: the newest reference replaces the old +/// one, and `None` (a retraction) removes it. +#[allow(dead_code, reason = "written by the chat relay, which lands next")] +pub(crate) async fn record_received_reference( + storage: &(impl CoreStorage + ?Sized), + product_id: &str, + peer_identity: [u8; 32], + discloser_product_id: String, + reference: Option, +) -> Result<(), String> { + let mut entries = read_received(storage, product_id).await?; + entries.retain(|entry| entry.peer_identity != peer_identity); + if let Some(reference) = reference { + if entries.len() >= MAX_RECEIVED_REFERENCES { + return Err("too many contact profile references".to_string()); + } + entries.push(ReceivedReference { + peer_identity, + discloser_product_id, + reference, + }); + } + let key = CoreStorageKey::ProfileReferencesReceived { + product_id: product_id.to_string(), + }; + if entries.is_empty() { + storage.clear_core_storage(key).await.map_err(storage_error) + } else { + storage + .write_core_storage(key, StoredReferences::V1(entries).encode()) + .await + .map_err(storage_error) + } +} diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index 0d89d3f89..926127743 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -1324,6 +1324,236 @@ fn profile_present_forwards_screened_references_and_is_unsupported_without_an_ad )); } +/// A product runtime on a shared platform, so several products see one core +/// storage the way they do on a real host. +fn profile_host_on( + platform: Arc, + product: ProductContext, + profile: Option>, +) -> ProductRuntimeHost { + let (host_config, _) = runtime_config(&product.product_id); + let services = RuntimeServices::new( + platform, + host_config.host.host_info.clone(), + host_config.people_chain_genesis_hash, + host_config.bulletin_chain_genesis_hash, + host_config.asset_hub_chain_genesis_hash, + test_spawner(), + ); + let pairing_host = PairingHost::new(services.clone(), host_config); + let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); + adapters.profile_platform = + profile.map(|profile| profile as Arc); + ProductRuntimeHost::from_services(services, adapters, pairing_host, product) +} + +fn disclose( + host: &ProductRuntimeHost, + reference: &str, +) -> Result> { + futures::executor::block_on(Profile::disclose( + host, + &CallContext::default(), + HostProfileDiscloseRequest::V1(v01::HostProfileDiscloseRequest { + reference: reference.to_string(), + }), + )) +} + +fn retract( + host: &ProductRuntimeHost, +) -> Result> { + futures::executor::block_on(Profile::retract( + host, + &CallContext::default(), + HostProfileRetractRequest::V1, + )) +} + +fn present_contact( + host: &ProductRuntimeHost, + peer_identity: [u8; 32], +) -> Result> { + futures::executor::block_on(Profile::present_contact( + host, + &CallContext::default(), + HostProfilePresentContactRequest::V1(v01::HostProfilePresentContactRequest { + peer_identity, + }), + )) +} + +const CONTACTS_REFERENCE: &str = "seity-contacts:v1:5c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb535c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb53"; + +#[test] +fn profile_disclose_stores_the_reference_and_only_its_discloser_may_retract_it() { + let platform = stub_platform(); + let seity = profile_host_on( + platform.clone(), + ProductContext::new("seity.dot".to_string()).expect("valid product"), + None, + ); + let other = profile_host_on( + platform.clone(), + ProductContext::new("other.dot".to_string()).expect("valid product"), + None, + ); + + assert_eq!( + disclose(&seity, CONTACTS_REFERENCE).expect("an App discloses a screened reference"), + HostProfileDiscloseResponse::V1 + ); + let stored = futures::executor::block_on(profile::read_disclosure(platform.as_ref())) + .expect("readable") + .expect("stored"); + assert_eq!(stored.product_id, "seity.dot"); + assert_eq!(stored.reference, CONTACTS_REFERENCE); + + assert!(matches!( + retract(&other), + Err(CallError::Domain(HostProfileRetractError::V1( + v01::HostProfileRetractError::NotDiscloser + ))) + )); + assert_eq!( + retract(&seity).expect("the discloser retracts"), + HostProfileRetractResponse::V1 + ); + assert_eq!( + futures::executor::block_on(profile::read_disclosure(platform.as_ref())).expect("readable"), + None + ); + assert_eq!( + retract(&seity).expect("retracting nothing is not an error"), + HostProfileRetractResponse::V1 + ); +} + +#[test] +fn profile_disclose_is_for_apps_and_screened_references_only() { + let platform = stub_platform(); + let worker = profile_host_on( + platform.clone(), + ProductContext::new_with_execution( + "seity.dot".to_string(), + truapi_platform::ProductExecutionKind::Worker, + ) + .expect("valid product"), + None, + ); + assert!(matches!( + disclose(&worker, CONTACTS_REFERENCE), + Err(CallError::Denied) + )); + assert!(matches!(retract(&worker), Err(CallError::Denied))); + + let app = profile_host_on( + platform.clone(), + ProductContext::new("seity.dot".to_string()).expect("valid product"), + None, + ); + for rejected in [ + String::new(), + "a".repeat(2049), + "seity contacts".to_string(), + ] { + assert!(matches!( + disclose(&app, &rejected), + Err(CallError::Domain(HostProfileDiscloseError::V1( + v01::HostProfileDiscloseError::InvalidReference + ))) + )); + } + assert_eq!( + futures::executor::block_on(profile::read_disclosure(platform.as_ref())).expect("readable"), + None, + "nothing unscreened is stored" + ); +} + +#[test] +fn profile_present_contact_substitutes_the_reference_the_contact_sent() { + let platform = stub_platform(); + let presented = Arc::new(RecordingProfilePlatform::default()); + let chat = profile_host_on( + platform.clone(), + ProductContext::new("egui-chat.dot".to_string()).expect("valid product"), + Some(presented.clone()), + ); + let alice = [0xa1; 32]; + let bob = [0xb0; 32]; + // What the relay does when Alice's host sends her reference. + futures::executor::block_on(profile::record_received_reference( + platform.as_ref(), + "egui-chat.dot", + alice, + "seity.dot".to_string(), + Some(CONTACTS_REFERENCE.to_string()), + )) + .expect("recorded"); + + assert_eq!( + present_contact(&chat, alice).expect("a contact who shared is presented"), + HostProfilePresentContactResponse::V1 + ); + assert_eq!( + presented + .presented + .lock() + .expect("presented mutex poisoned") + .as_slice(), + [("egui-chat.dot".to_string(), CONTACTS_REFERENCE.to_string())], + "the host presents the stored reference, attributed to the caller" + ); + assert!(matches!( + present_contact(&chat, bob), + Err(CallError::Domain(HostProfilePresentContactError::V1( + v01::HostProfilePresentContactError::NotShared + ))) + )); + + // Another product's contacts are not this product's. + let other = profile_host_on( + platform.clone(), + ProductContext::new("other-chat.dot".to_string()).expect("valid product"), + Some(presented.clone()), + ); + assert!(matches!( + present_contact(&other, alice), + Err(CallError::Domain(HostProfilePresentContactError::V1( + v01::HostProfilePresentContactError::NotShared + ))) + )); + + // A retraction from Alice's host removes what this host holds. + futures::executor::block_on(profile::record_received_reference( + platform.as_ref(), + "egui-chat.dot", + alice, + "seity.dot".to_string(), + None, + )) + .expect("recorded"); + assert!(matches!( + present_contact(&chat, alice), + Err(CallError::Domain(HostProfilePresentContactError::V1( + v01::HostProfilePresentContactError::NotShared + ))) + )); + + assert!(matches!( + present_contact( + &profile_host_on( + platform, + ProductContext::new("egui-chat.dot".to_string()).expect("valid product"), + None, + ), + alice, + ), + Err(CallError::Unsupported) + )); +} + #[test] fn chain_follow_ids_are_scoped_per_product_core() { let (host_config, product) = runtime_config("same.dot"); diff --git a/rust/crates/truapi/src/api/profile.rs b/rust/crates/truapi/src/api/profile.rs index ab6decdc3..f67e4f811 100644 --- a/rust/crates/truapi/src/api/profile.rs +++ b/rust/crates/truapi/src/api/profile.rs @@ -1,7 +1,11 @@ //! Unified [`Profile`] trait. use crate::versioned::profile::{ - HostProfilePresentError, HostProfilePresentRequest, HostProfilePresentResponse, + HostProfileDiscloseError, HostProfileDiscloseRequest, HostProfileDiscloseResponse, + HostProfilePresentContactError, HostProfilePresentContactRequest, + HostProfilePresentContactResponse, HostProfilePresentError, HostProfilePresentRequest, + HostProfilePresentResponse, HostProfileRetractError, HostProfileRetractRequest, + HostProfileRetractResponse, }; use crate::{CallContext, CallError}; use crate::{wire, wire_trait}; @@ -33,4 +37,62 @@ pub trait Profile: Send + Sync { ) -> Result> { Err(CallError::unavailable()) } + /// Give the user's chat contacts this reference to their profile. + /// + /// The host stores it as the user's own and relays it to each contact, + /// replacing whatever it sent before; the product never learns who they + /// are. App executions only. A reference this core cannot screen is + /// `InvalidReference`. + /// + /// ```ts + /// const result = await truapi.profile.disclose({ + /// reference: "seity-contacts:v1:" + "00".repeat(64), + /// }); + /// console.log("profile disclosed:", result); + /// ``` + #[wire(id = 1)] + async fn disclose( + &self, + _cx: &CallContext, + _request: HostProfileDiscloseRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Withdraw the reference this product disclosed. Contacts are told to + /// drop what they hold. A product that did not disclose it is refused. + /// + /// ```ts + /// const result = await truapi.profile.retract(); + /// console.log("profile retracted:", result); + /// ``` + #[wire(id = 2)] + async fn retract( + &self, + _cx: &CallContext, + _request: HostProfileRetractRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Show a chat contact's profile in host-owned UI. + /// + /// The product names the contact; the host looks up the reference that + /// contact shared and presents it as `present` would. The reference never + /// reaches the product. A contact who shared nothing is `NotShared`. + /// + /// ```ts + /// const result = await truapi.profile.presentContact({ + /// peerIdentity: new Uint8Array(32), + /// }); + /// console.log("contact profile presentation:", result); + /// ``` + #[wire(id = 3)] + async fn present_contact( + &self, + _cx: &CallContext, + _request: HostProfilePresentContactRequest, + ) -> Result> { + Err(CallError::unavailable()) + } } diff --git a/rust/crates/truapi/src/v01/profile.rs b/rust/crates/truapi/src/v01/profile.rs index 17786331e..50328696b 100644 --- a/rust/crates/truapi/src/v01/profile.rs +++ b/rust/crates/truapi/src/v01/profile.rs @@ -34,3 +34,75 @@ pub enum HostProfilePresentError { reason: String, }, } + +/// Request to give the user's chat contacts a profile reference. +/// +/// The reference is a bearer capability for everyone the host relays it to. +/// The host stores it as the user's own and never parses it. +#[derive(Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct HostProfileDiscloseRequest { + /// Opaque profile reference, e.g. a Seity contacts reference. + pub reference: String, +} + +impl fmt::Debug for HostProfileDiscloseRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HostProfileDiscloseRequest") + .field("reference", &"[REDACTED]") + .finish() + } +} + +/// Profile disclosure failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum HostProfileDiscloseError { + /// The reference is empty, too long, or not printable ASCII. + InvalidReference, + /// Catch-all. + Unknown { + /// Human-readable reason. + reason: String, + }, +} + +/// Profile retraction failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum HostProfileRetractError { + /// Another product disclosed the reference the host holds. + NotDiscloser, + /// Catch-all. + Unknown { + /// Human-readable reason. + reason: String, + }, +} + +/// Request to show a chat contact's profile in host-owned UI. +/// +/// The product names the contact, never a reference: the host looks up the +/// reference that contact's host sent, so the product cannot read, keep or +/// substitute it. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct HostProfilePresentContactRequest { + /// The contact's authenticated root identity, as the chat API names it. + pub peer_identity: [u8; 32], +} + +/// Contact profile presentation failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum HostProfilePresentContactError { + /// This contact has not shared a profile with the user. + NotShared, + /// The host holds a reference it cannot parse. + InvalidReference, + /// Catch-all. + Unknown { + /// Human-readable reason. + reason: String, + }, +} diff --git a/rust/crates/truapi/src/versioned/profile.rs b/rust/crates/truapi/src/versioned/profile.rs index 00903f6f8..2c23bbbce 100644 --- a/rust/crates/truapi/src/versioned/profile.rs +++ b/rust/crates/truapi/src/versioned/profile.rs @@ -6,4 +6,13 @@ truapi_macros::versioned_type! { pub enum HostProfilePresentRequest { V1 => v01::HostProfilePresentRequest } pub enum HostProfilePresentResponse { V1 } pub enum HostProfilePresentError { V1 => v01::HostProfilePresentError } + pub enum HostProfileDiscloseRequest { V1 => v01::HostProfileDiscloseRequest } + pub enum HostProfileDiscloseResponse { V1 } + pub enum HostProfileDiscloseError { V1 => v01::HostProfileDiscloseError } + pub enum HostProfileRetractRequest { V1 } + pub enum HostProfileRetractResponse { V1 } + pub enum HostProfileRetractError { V1 => v01::HostProfileRetractError } + pub enum HostProfilePresentContactRequest { V1 => v01::HostProfilePresentContactRequest } + pub enum HostProfilePresentContactResponse { V1 } + pub enum HostProfilePresentContactError { V1 => v01::HostProfilePresentContactError } } From 298cf4286c07cf0c5dc40116d3e957cc1caf5d1b Mon Sep 17 00:00:00 2001 From: Corey Hathaway Date: Sat, 26 Sep 2026 15:35:45 +0100 Subject: [PATCH 03/30] feat(chat): relay disclosed profile references host to host The Chat v2 half of profile disclosure, built on the actor's host-private outbox that payments and rich files already use: - Wire: `ProfileReference { discloser_product_id, reference: Option }` at V2 content index 21 (a new shared wire index; needs agreeing with native Chat before it ships). - Send: `publish_profile_reference`, run on Initialize, seals one message per ready peer whose watermark differs from the user's disclosure, as an `OutgoingKind::ProfileReference` the product submits as opaque ciphertext. The watermark (trailing `profile_shared`, absent from older snapshots) advances at queue time; a withdrawal is sent to peers that hold one. - Receive: the frame is classified and screened, stored per peer in `ProfileReferencesReceived` for the chat product, and cut from the opened plaintext (forcing a re-encode), so the product never sees it. Only live frames update it, never compacted history. Products cannot prepare one. Co-Authored-By: Claude Opus 5.5 (1M context) --- rust/crates/truapi-chat-v2/src/lib.rs | 79 +++++++ .../truapi-server/src/runtime/chat_device.rs | 45 ++++ .../truapi-server/src/runtime/native_chat.rs | 1 + .../src/runtime/native_chat/actor.rs | 25 ++- .../src/runtime/native_chat/actor/history.rs | 43 +++- .../src/runtime/native_chat/actor/profile.rs | 209 ++++++++++++++++++ .../src/runtime/native_chat/actor/receive.rs | 13 ++ .../src/runtime/native_chat/actor/tests.rs | 194 ++++++++++++++++ .../truapi-server/src/runtime/profile.rs | 2 - 9 files changed, 606 insertions(+), 5 deletions(-) create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs diff --git a/rust/crates/truapi-chat-v2/src/lib.rs b/rust/crates/truapi-chat-v2/src/lib.rs index 72317d31a..583df5882 100644 --- a/rust/crates/truapi-chat-v2/src/lib.rs +++ b/rust/crates/truapi-chat-v2/src/lib.rs @@ -377,6 +377,13 @@ pub enum V2ChatMessageContent { request_id: String, device: V2PeerDevice, }, + /// A profile reference the sender's host discloses to this contact, or + /// `None` to withdraw it. Host-originated and host-consumed: products + /// never send or see it. V2 wire enum index 21. + ProfileReference { + discloser_product_id: String, + reference: Option, + }, /// The envelope was valid enough to recover id/timestamp, but the versioned /// content wrapper is not yet represented by this SDK surface. UnsupportedVersion { version_index: u8 }, @@ -977,6 +984,29 @@ pub fn encode_device_removed_message( }) } +/// Encode a v2 profile-reference message (content index 21). +pub fn encode_profile_reference_message( + message_id: &str, + timestamp: u64, + discloser_product_id: &str, + reference: Option<&str>, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(21); + encode_string(out, discloser_product_id)?; + match reference { + Some(reference) => { + out.push(1); + encode_string(out, reference) + } + None => { + out.push(0); + Ok(()) + } + } + }) +} + /// Encode a v2 compacted-messages reference (content index 19). pub fn encode_compacted_messages_message( message_id: &str, @@ -1272,6 +1302,23 @@ pub fn decode_message(data: &[u8]) -> Result { }, } } + 21 => { + let discloser_product_id = cursor.read_string("discloser_product_id")?; + let reference = match cursor.read_u8("reference_option")? { + 0 => None, + 1 => Some(cursor.read_string("reference")?), + value => { + return Err(ChatError::InvalidEncoding(format!( + "invalid profile reference option {value}" + ))); + } + }; + cursor.finish()?; + V2ChatMessageContent::ProfileReference { + discloser_product_id, + reference, + } + } index => V2ChatMessageContent::UnsupportedContent { content_index: index, }, @@ -3598,6 +3645,38 @@ mod tests { } ); } + #[test] + fn profile_reference_wire_roundtrips_disclosure_and_withdrawal() { + let disclosed = encode_profile_reference_message( + "profile", + 5, + "seity.dot", + Some("seity-contacts:v1:00"), + ) + .unwrap(); + let decoded = decode_message(&disclosed).unwrap(); + assert_eq!(decoded.message_id, "profile"); + assert_eq!( + decoded.content, + V2ChatMessageContent::ProfileReference { + discloser_product_id: "seity.dot".into(), + reference: Some("seity-contacts:v1:00".into()), + } + ); + let withdrawn = encode_profile_reference_message("profile", 6, "seity.dot", None).unwrap(); + assert_eq!( + decode_message(&withdrawn).unwrap().content, + V2ChatMessageContent::ProfileReference { + discloser_product_id: "seity.dot".into(), + reference: None, + } + ); + // A malformed option byte is refused, not guessed at. + let mut bad = withdrawn.clone(); + *bad.last_mut().unwrap() = 7; + assert!(decode_message(&bad).is_err()); + } + #[test] fn current_multi_device_wire_roundtrips() { let added = encode_device_added_message("add", 1, &[1; 32], &[2; 32]).unwrap(); diff --git a/rust/crates/truapi-server/src/runtime/chat_device.rs b/rust/crates/truapi-server/src/runtime/chat_device.rs index a83513c59..660abb352 100644 --- a/rust/crates/truapi-server/src/runtime/chat_device.rs +++ b/rust/crates/truapi-server/src/runtime/chat_device.rs @@ -105,6 +105,30 @@ pub(crate) enum OpenedDeviceMessage { /// Native notification metadata, never ordinary guest content. This Host has /// no mobile push provider; retain only ordering and replay evidence. PushToken { timestamp: u64, digest: [u8; 32] }, + /// A profile reference the peer's host disclosed, or `None` withdrawing it. + /// Host-consumed: the reference is a bearer capability and never reaches + /// the product. + ProfileReference(ProfileReferenceFrame), +} + +/// A screened profile reference frame. +pub(crate) struct ProfileReferenceFrame { + /// Native message identifier. + pub(crate) message_id: String, + /// Sender timestamp. + pub(crate) timestamp: u64, + /// Product on the sender's side that disclosed the reference. + pub(crate) discloser_product_id: String, + /// The reference, or `None` for a withdrawal. + pub(crate) reference: Option, +} + +/// The same bound and alphabet the core screens a product's reference with. +const MAX_PROFILE_REFERENCE_BYTES: usize = 2048; +const MAX_PROFILE_PRODUCT_ID_BYTES: usize = 256; + +fn screened_ascii(value: &str, max: usize) -> bool { + !value.is_empty() && value.len() <= max && value.bytes().all(|byte| byte.is_ascii_graphic()) } /// Lifecycle metadata to validate against durable Host roster and replay state. @@ -544,6 +568,27 @@ pub(crate) fn classify_message( } V2ChatMessageContent::ContactAdded => DeviceLifecycle::ContactAdded, V2ChatMessageContent::LeftChat => DeviceLifecycle::LeftChat, + V2ChatMessageContent::ProfileReference { + discloser_product_id, + reference, + } => { + validate_id(&message.message_id)?; + if !screened_ascii(&discloser_product_id, MAX_PROFILE_PRODUCT_ID_BYTES) + || reference.as_deref().is_some_and(|reference| { + !screened_ascii(reference, MAX_PROFILE_REFERENCE_BYTES) + }) + { + return Err(ChatDeviceError::InvalidEncoding); + } + return Ok(OpenedDeviceMessage::ProfileReference( + ProfileReferenceFrame { + message_id: message.message_id, + timestamp: message.timestamp, + discloser_product_id, + reference, + }, + )); + } ordinary => { validate_ordinary(&ordinary)?; return Ok(OpenedDeviceMessage::Ordinary(core::mem::take(bytes))); diff --git a/rust/crates/truapi-server/src/runtime/native_chat.rs b/rust/crates/truapi-server/src/runtime/native_chat.rs index 3207a458e..708bb5820 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat.rs @@ -392,6 +392,7 @@ impl NativeChatRegistry { match &mut request { Request::Initialize => { chat.drive_files(&context).await?; + chat.publish_profile_reference(&context).await?; } Request::Bind { username } => { binding = Some(chat.bind(&context, std::mem::take(username)).await?); diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor.rs index 273548329..e2787ce7d 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor.rs @@ -4,6 +4,7 @@ mod files; mod history; +mod profile; mod receive; #[cfg(test)] mod tests; @@ -146,6 +147,8 @@ enum OutgoingKind { Payment([u8; 32]), Acknowledgment, Rich([u8; 32]), + /// Appended last so earlier snapshots still decode. + ProfileReference([u8; 32]), } #[derive(Clone, Encode, Decode)] @@ -210,6 +213,8 @@ struct State { rich_messages: Vec, marker: [u8; 4], boundary: BoundaryState, + /// Trailing, and absent from snapshots written before it existed. + profile_shared: Vec, } impl State { @@ -232,6 +237,7 @@ impl State { rich_messages: Vec::new(), marker: *b"HCN3", boundary: BoundaryState::default(), + profile_shared: Vec::new(), }) } fn peer(&self, identity: &[u8; 32]) -> Result<&Peer, Error> { @@ -303,6 +309,12 @@ impl Decode for State { } BoundaryState::decode(input)? }; + // Added after the boundary state: a snapshot that ends here predates it. + let profile_shared = if input.remaining_len()? == Some(0) { + Vec::new() + } else { + >::decode(input)? + }; Ok(Self { secret, index, @@ -321,6 +333,7 @@ impl Decode for State { rich_messages, marker: *b"HCN3", boundary, + profile_shared, }) } } @@ -625,7 +638,9 @@ impl NativeChatActor { state.boundary.legacy_pending || matches!( entry.kind, - OutgoingKind::Payment(_) | OutgoingKind::Rich(_) + OutgoingKind::Payment(_) + | OutgoingKind::Rich(_) + | OutgoingKind::ProfileReference(_) ) }) .map(|entry| entry.prepared(state)) @@ -696,6 +711,9 @@ impl NativeChatActor { state .outbox .retain(|entry| matches!(entry.kind, OutgoingKind::Payment(_))); + // Profile references queued before the migration are dropped with + // it, so forget what was sent and let the reconcile resend. + state.profile_shared.clear(); state.messages.clear(); state.acknowledgments.clear(); state.sent.clear(); @@ -1183,7 +1201,10 @@ impl NativeChatActor { .filter(|entry| { matches!(entry.kind, OutgoingKind::Payment(_)) || (!state.boundary.legacy_pending - && matches!(entry.kind, OutgoingKind::Rich(_))) + && matches!( + entry.kind, + OutgoingKind::Rich(_) | OutgoingKind::ProfileReference(_) + )) }) .cloned() .collect::>() diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs index ade7e3fea..cca34324e 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs @@ -184,6 +184,10 @@ impl NativeChatActor { let mut rich = Vec::new(); let mut bytes_seen = 0usize; let mut had_history = false; + // Profile references are the Host's, not the product's: collected here, + // stored after the open commits, and cut out of what the product sees. + let mut profile_references = Vec::new(); + let mut stripped = false; while let Some((mut bytes, depth)) = work.pop() { context.require_current()?; bytes_seen = bytes_seen @@ -279,6 +283,17 @@ impl NativeChatActor { expanded.push(core::mem::take(&mut *bytes)); } } + OpenedDeviceMessage::ProfileReference(frame) => { + if !super::receive::valid_peer_timestamp(frame.timestamp, current_unix_secs()) { + return Err(Error::InvalidStatement); + } + // Never forwarded, whatever the depth; only a live frame + // updates what this Host holds, never compacted history. + stripped = true; + if depth == 0 { + profile_references.push(frame); + } + } } } let rich = self.prepare_rich(context, peer, &request_id, rich).await?; @@ -292,9 +307,11 @@ impl NativeChatActor { files::merge_received(state, rich) }) .await?; + self.record_profile_references(context, peer, profile_references) + .await?; // Preserve the original canonical request when no HOP expansion was // needed, except references already transferred by legacy migration. - let plaintext = if had_history { + let plaintext = if had_history || stripped { wire::encode_transport_request_plaintext(&request_id, &expanded) .map_err(|_| Error::InvalidStatement)? } else { @@ -362,9 +379,33 @@ impl NativeChatActor { validate_deliveries(&state.boundary.history) }) .await?; + self.record_profile_references(context, peer, profile_references) + .await?; self.continue_open(context, id, 0).await } + /// Keep the newest profile reference each frame carries for `peer`, in + /// this product's received-reference slot. `None` withdraws it. + async fn record_profile_references( + &self, + context: &NativeChatContext, + peer: [u8; 32], + frames: Vec, + ) -> Result<(), Error> { + for frame in frames { + crate::runtime::profile::record_received_reference( + &*context.services.platform, + &self.product, + peer, + frame.discloser_product_id, + frame.reference, + ) + .await + .map_err(|_| Error::StorageUnavailable)?; + } + Ok(()) + } + pub(in crate::runtime::native_chat) async fn continue_open( self: &Arc, context: &NativeChatContext, diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs new file mode 100644 index 000000000..4097a5d10 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Host-originated profile references: the user's disclosed reference, sealed +//! to each established peer's devices and handed to the product as opaque +//! prepared statements, like payments and rich files. +//! +//! A per-peer watermark records what this Host last queued for that peer, so +//! the initial share, a new contact, a replacement and a withdrawal are one +//! reconcile: every peer whose watermark differs from the disclosure is sent +//! the disclosure. The watermark advances when the message is queued. + +use super::*; +use crate::runtime::native_chat::background::require_authorized; +use crate::runtime::profile::{Disclosure, read_disclosure}; + +/// What this Host last queued to one peer. +#[derive(Clone, PartialEq, Eq, Encode, Decode)] +pub(super) struct ProfileWatermark { + pub(super) peer: [u8; 32], + /// Digest of the disclosure sent, identifying it without keeping it. + pub(super) digest: [u8; 32], + /// Product that disclosed it, repeated on a withdrawal. + pub(super) discloser_product_id: String, +} + +fn disclosure_digest(disclosure: &Disclosure) -> [u8; 32] { + hash( + &( + b"native-chat-profile-v1", + &disclosure.product_id, + &disclosure.reference, + ) + .encode(), + ) +} + +/// What one peer should be sent now: the disclosure, or a withdrawal of the +/// one it holds. `None` when it already holds what it should. +fn wanted( + disclosure: Option<&Disclosure>, + current: Option<&ProfileWatermark>, +) -> Option<(String, Option, Option<[u8; 32]>)> { + match (disclosure, current) { + (Some(disclosure), current) => { + let digest = disclosure_digest(disclosure); + if current.is_some_and(|watermark| watermark.digest == digest) { + return None; + } + Some(( + disclosure.product_id.clone(), + Some(disclosure.reference.clone()), + Some(digest), + )) + } + (None, Some(watermark)) => Some((watermark.discloser_product_id.clone(), None, None)), + (None, None) => None, + } +} + +impl NativeChatActor { + /// Queue a profile reference (or withdrawal) for every ready peer whose + /// watermark differs from the user's current disclosure. + pub(in crate::runtime::native_chat) async fn publish_profile_reference( + self: &Arc, + context: &NativeChatContext, + ) -> Result { + context.require_current()?; + if self + .store + .read(|state| state.boundary.legacy_pending) + .await? + { + return Ok(false); + } + let disclosure = read_disclosure(&*context.services.platform) + .await + .map_err(|_| Error::StorageUnavailable)?; + let stale = self + .store + .read({ + let disclosure = disclosure.clone(); + move |state| { + state + .peers + .iter() + .filter(|peer| peer.ready()) + .filter(|peer| { + let current = state + .profile_shared + .iter() + .find(|watermark| watermark.peer == peer.identity); + wanted(disclosure.as_ref(), current).is_some() + }) + .map(|peer| peer.identity) + .collect::>() + } + }) + .await?; + if stale.is_empty() { + return Ok(false); + } + require_authorized(context, &self.product).await?; + let actor = self.clone(); + let valid = context.session_valid.clone(); + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + for identity in stale { + let peer = state.peer(&identity)?.clone(); + if !peer.ready() { + continue; + } + let current = state + .profile_shared + .iter() + .find(|watermark| watermark.peer == identity); + let Some((discloser, reference, digest)) = wanted(disclosure.as_ref(), current) + else { + continue; + }; + let tag = hash(&(identity, &discloser, &reference).encode()); + let request_id = format!("profile-{}", hex::encode(&tag[..8])); + let bytes = wire::encode_profile_reference_message( + &request_id, + current_unix_secs().saturating_mul(1000), + &discloser, + reference.as_deref(), + ) + .map_err(|_| Error::InvalidRequest)?; + let messages = Zeroizing::new(vec![bytes]); + let statement = actor.multi_statement( + state, + &peer, + &peer.active_devices(), + &request_id, + &messages, + )?; + // Only the newest disclosure is worth delivering. + state.outbox.retain(|entry| { + entry.peer != identity + || !matches!(entry.kind, OutgoingKind::ProfileReference(_)) + }); + state.queue(Outgoing { + peer: identity, + request_id, + digest: hash(&messages.encode()), + kind: OutgoingKind::ProfileReference(tag), + roster_revision: peer.revision, + statement, + last_attempt: 0, + })?; + state + .profile_shared + .retain(|watermark| watermark.peer != identity); + if let Some(digest) = digest { + state.profile_shared.push(ProfileWatermark { + peer: identity, + digest, + discloser_product_id: discloser, + }); + } + } + Ok(()) + }) + .await?; + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn disclosure(reference: &str) -> Disclosure { + Disclosure { + product_id: "seity.dot".into(), + reference: reference.into(), + } + } + + #[test] + fn a_peer_is_sent_only_what_it_does_not_hold() { + let current = disclosure("seity-contacts:v1:aa"); + let held = ProfileWatermark { + peer: [1; 32], + digest: disclosure_digest(¤t), + discloser_product_id: "seity.dot".into(), + }; + assert!(wanted(Some(¤t), Some(&held)).is_none()); + let (_, reference, _) = wanted(Some(&disclosure("seity-contacts:v1:bb")), Some(&held)) + .expect("a replacement is sent"); + assert_eq!(reference.as_deref(), Some("seity-contacts:v1:bb")); + let (discloser, reference, digest) = + wanted(None, Some(&held)).expect("a withdrawal is sent to a holder"); + assert_eq!( + (discloser.as_str(), reference, digest), + ("seity.dot", None, None) + ); + assert!( + wanted(None, None).is_none(), + "nothing to withdraw from a new peer" + ); + assert!( + wanted(Some(¤t), None).is_some(), + "a new peer is sent the disclosure" + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/receive.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/receive.rs index 64c65dd69..e3884625e 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/receive.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/receive.rs @@ -959,6 +959,19 @@ fn exchange_digest(messages: &[OpenedDeviceMessage]) -> Result<[u8; 32], Error> hasher.update(digest); continue; } + OpenedDeviceMessage::ProfileReference(frame) => { + hasher.update(&[6]); + let bytes = ( + frame.message_id.as_str(), + frame.timestamp, + frame.discloser_product_id.as_str(), + frame.reference.as_deref(), + ) + .encode(); + hasher.update(&(bytes.len() as u32).to_le_bytes()); + hasher.update(&bytes); + continue; + } OpenedDeviceMessage::DeviceControl(control) => { let mut bytes = (control.message_id.as_str(), control.timestamp).encode(); match &control.content { diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs index 9a93a7743..51d17d0cf 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs @@ -1587,3 +1587,197 @@ fn state_decode_accepts_old_prefix_and_tagged_extension_but_rejects_corruption() assert!(State::decode(&mut truncated_extension.as_slice()).is_err()); assert!(State::decode(&mut &legacy[..legacy.len() - 1]).is_err()); } + +const PROFILE_REFERENCE: &str = "seity-contacts:v1:5c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb535c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb53"; + +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +#[test] +fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retract() { + block_on(async { + use crate::runtime::profile::{Disclosure, clear_disclosure, write_disclosure}; + let fixture = Fixture::new(); + set_product_grants( + &fixture.platform, + PRODUCT, + truapi_platform::PermissionAuthorizationStatus::Authorized, + ) + .await; + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + let profile_entries = |state: &State| { + state + .outbox + .iter() + .filter(|entry| matches!(entry.kind, OutgoingKind::ProfileReference(_))) + .count() + }; + + assert!( + !actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "nothing disclosed, nothing sent" + ); + + write_disclosure( + fixture.platform.as_ref(), + &Disclosure { + product_id: "seity.dot".into(), + reference: PROFILE_REFERENCE.into(), + }, + ) + .await + .unwrap(); + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert_eq!( + view.prepared.len(), + 1, + "one opaque statement for the product to submit" + ); + assert_eq!(view.prepared[0].peer_identity, identity.account); + assert!(view.prepared[0].requires_ack); + assert!( + !contains( + &view.prepared[0].statement.encode(), + PROFILE_REFERENCE.as_bytes() + ), + "the product carries ciphertext, never the reference" + ); + assert!( + !actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "the watermark stops a second send of the same disclosure" + ); + + write_disclosure( + fixture.platform.as_ref(), + &Disclosure { + product_id: "seity.dot".into(), + reference: format!("{PROFILE_REFERENCE}ff"), + }, + ) + .await + .unwrap(); + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + assert_eq!( + actor.store.read(profile_entries).await.unwrap(), + 1, + "a replacement supersedes the queued disclosure" + ); + + clear_disclosure(fixture.platform.as_ref()).await.unwrap(); + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "a holder is sent the withdrawal" + ); + assert!( + actor + .store + .read(|state| state.profile_shared.is_empty()) + .await + .unwrap() + ); + assert!( + !actor + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + }); +} + +#[test] +fn a_received_profile_reference_is_kept_by_the_host_and_cut_from_what_the_product_opens() { + block_on(async { + use crate::runtime::profile::received_reference; + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + let registry = NativeChatRegistry::default(); + + let text = wire::encode_text_message("hello", fixture.timestamp, "hi").unwrap(); + let frame = wire::encode_profile_reference_message( + "profile-1", + fixture.timestamp, + "seity.dot", + Some(PROFILE_REFERENCE), + ) + .unwrap(); + let plaintext = + wire::encode_transport_request_plaintext("incoming-profile", &[frame, text.clone()]) + .unwrap(); + let packet = native_packet(&actor, &identity, &peer, &plaintext, false, false); + let (opened, _) = actor + .open_statement(&fixture.context, ®istry, packet) + .await + .unwrap(); + assert_eq!(opened.len(), 1); + assert!(!contains( + &opened[0].plaintext, + PROFILE_REFERENCE.as_bytes() + )); + let wire::V2StatementTransportData::Request { messages, .. } = + wire::decode_transport_plaintext(&opened[0].plaintext).unwrap() + else { + panic!("the product still receives the request to acknowledge"); + }; + assert_eq!( + messages, + vec![text], + "ordinary content passes through untouched" + ); + let held = received_reference(fixture.platform.as_ref(), PRODUCT, &identity.account) + .await + .unwrap() + .expect("the host keeps what the contact disclosed"); + assert_eq!(held.reference, PROFILE_REFERENCE); + assert_eq!(held.discloser_product_id, "seity.dot"); + + let withdrawal = wire::encode_profile_reference_message( + "profile-2", + fixture.timestamp, + "seity.dot", + None, + ) + .unwrap(); + let plaintext = + wire::encode_transport_request_plaintext("incoming-withdrawal", &[withdrawal]).unwrap(); + let packet = native_packet(&actor, &identity, &peer, &plaintext, false, false); + actor + .open_statement(&fixture.context, ®istry, packet) + .await + .unwrap(); + assert_eq!( + received_reference(fixture.platform.as_ref(), PRODUCT, &identity.account) + .await + .unwrap(), + None + ); + }); +} diff --git a/rust/crates/truapi-server/src/runtime/profile.rs b/rust/crates/truapi-server/src/runtime/profile.rs index 43ab4814c..dc9e9de25 100644 --- a/rust/crates/truapi-server/src/runtime/profile.rs +++ b/rust/crates/truapi-server/src/runtime/profile.rs @@ -32,7 +32,6 @@ enum StoredReferences { } /// A contact roster is bounded; so is what the host keeps for it. -#[allow(dead_code, reason = "written by the chat relay, which lands next")] const MAX_RECEIVED_REFERENCES: usize = 4096; fn storage_error(error: impl core::fmt::Debug) -> String { @@ -105,7 +104,6 @@ pub(crate) async fn received_reference( /// Record what a contact's host sent: the newest reference replaces the old /// one, and `None` (a retraction) removes it. -#[allow(dead_code, reason = "written by the chat relay, which lands next")] pub(crate) async fn record_received_reference( storage: &(impl CoreStorage + ?Sized), product_id: &str, From e89fd093a9015da74516c35cd3727167ca018f0b Mon Sep 17 00:00:00 2001 From: Corey Hathaway Date: Sat, 26 Sep 2026 16:25:45 +0100 Subject: [PATCH 04/30] docs(rfc): profile disclosure to chat contacts The RFC for `disclose` / `retract` / `presentContact` and the Chat v2 relay on the host-private outbox, unnumbered and in draft. Its open questions are the known gaps of the prototype: the content-type index, several disclosing products, consent, other devices, reconcile timing, and where references are resolved. Each gap is also marked where it lives in the code. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/rfcs/profile-disclosure.md | 161 ++++++++++++++++++ rust/crates/truapi-chat-v2/src/lib.rs | 2 + .../tests/golden/host-callbacks.ts | 3 + rust/crates/truapi-platform/src/lib.rs | 3 + rust/crates/truapi-server/src/runtime.rs | 1 + .../truapi-server/src/runtime/native_chat.rs | 2 + .../src/runtime/native_chat/actor/profile.rs | 3 + 7 files changed, 175 insertions(+) create mode 100644 docs/rfcs/profile-disclosure.md diff --git a/docs/rfcs/profile-disclosure.md b/docs/rfcs/profile-disclosure.md new file mode 100644 index 000000000..3c3624cbf --- /dev/null +++ b/docs/rfcs/profile-disclosure.md @@ -0,0 +1,161 @@ +--- +title: "Profile disclosure to chat contacts" +owner: "@corey-hathaway" +status: draft +--- + +# RFC — Profile disclosure to chat contacts + +## Summary + +A product hands the host one opaque profile reference for the user's chat contacts. The host relays it to each +contact over Chat v2 and keeps the references contacts relay back. A chat product then asks the host to show a +contact's profile by naming the contact, and the host presents the reference that contact disclosed through the +existing `profile.present` path. No product holds another user's reference. + +## Motivation + +`profile.present` shows a profile from a reference the calling product already holds. A chat product has no honest +way to hold one for a contact: the reference is a bearer capability, so a product that carries it can read, keep and +forward the profile, and can show any reference against any contact. The reference has to travel host to host and stay +inside the hosts, and Chat v2 leaves ordinary delivery to products. + +## Requirements + +- **Blind:** the disclosing product never learns who the contacts are. +- **Sealed:** no product reads a reference in transit or at rest, on either side. +- **Bound:** a presented profile is the one that contact's host sent, not one a product chose. +- **Stable:** a change to the referenced profile does not require relaying again. +- **Withdrawable:** the discloser can retract, and contacts drop what they hold. + +## Approach + +The design has four parts: + +- The `Profile` trait gains `disclose`, `retract` and `present_contact`. +- Core storage holds the user's disclosure and the references received per chat product. +- The Chat v2 actor relays disclosures through its host-private outbox. +- `present_contact` substitutes the stored reference into `present`. + +### Trait + +```rust +#[wire_trait(id = 20)] +#[crate::async_trait] +pub trait Profile: Send + Sync { + /// Show the referenced profile in host-owned UI. + #[wire(id = 0)] + async fn present( + &self, + _cx: &CallContext, + _request: HostProfilePresentRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Give the user's chat contacts this reference. App executions only. + #[wire(id = 1)] + async fn disclose( + &self, + _cx: &CallContext, + _request: HostProfileDiscloseRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Withdraw the reference this product disclosed. + #[wire(id = 2)] + async fn retract( + &self, + _cx: &CallContext, + _request: HostProfileRetractRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Show the profile a chat contact disclosed. + #[wire(id = 3)] + async fn present_contact( + &self, + _cx: &CallContext, + _request: HostProfilePresentContactRequest, + ) -> Result> { + Err(CallError::unavailable()) + } +} + +pub struct HostProfileDiscloseRequest { + /// Opaque reference, screened like a `present` reference. + pub reference: String, +} +pub enum HostProfileDiscloseError { + /// The reference is empty, too long, or not printable ASCII. + InvalidReference, + /// Catch-all. + Unknown { reason: String }, +} +pub enum HostProfileRetractError { + /// Another product disclosed the reference the host holds. + NotDiscloser, + /// Catch-all. + Unknown { reason: String }, +} +pub struct HostProfilePresentContactRequest { + /// The contact's authenticated root identity, as the Chat v2 API names it. + pub peer_identity: [u8; 32], +} +pub enum HostProfilePresentContactError { + /// The contact has not disclosed a profile to the user. + NotShared, + /// The stored reference no longer passes screening. + InvalidReference, + /// Catch-all. + Unknown { reason: String }, +} +``` + +### Storage + +Two core-storage slots hold references, and neither is visible to products. `ProfileDisclosure` is wallet-owned and +holds the disclosing product id and the reference. `ProfileReferencesReceived { product_id }` holds, per chat product, +the newest reference each contact disclosed with its discloser; clearing the product clears it with the roster it +belongs to. Hosts treat both as secret material. + +### Relay + +A disclosure travels as a new Chat v2 content type, `ProfileReference { discloser_product_id, reference: Option }`, +where `None` withdraws. The Chat actor seals it to each ready peer's devices through the same host-private outbox that +carries payments and rich files, so the chat product submits and retries opaque ciphertext it cannot read, and cannot +prepare the content type itself. A per-peer watermark records what was last sent; each reconcile sends the current +disclosure to every peer whose watermark differs, which covers the first share, a new contact, a replacement and a +withdrawal. On receipt the host screens the frame, stores it for that peer and removes it from the plaintext returned to +the product. Frames from compacted history are dropped. + +Stability comes from the reference format rather than the relay: a reference that names a mutable record, such as a +registry slot, keeps working when the record changes, so a relay happens only when the reference itself changes. + +### Presentation + +`present_contact` looks up the caller's received reference for the named peer, screens it again, and hands it to +`ProfilePlatform::present_profile`. Host adapters are unchanged: they see a `present` whichever method produced it. + +## Trade-offs + +- One reference for all contacts, so withdrawing it from one contact means rotating it for all of them. +- A retraction cannot make a contact's host forget a reference it already resolved. +- The watermark advances when the message is queued, so a message that never arrives is not resent until the + disclosure changes. +- Dropped: carrying the reference in ordinary chat content, which puts a bearer capability in product hands. + +## Open questions + +- The content-type index. The prototype uses V2 index 21, which native Chat has to agree to. +- Several disclosing products. There is one `ProfileDisclosure` slot, so the last product to disclose replaces the + others and the earlier one can no longer retract. The alternative is one slot per product, with the host relaying the + one from a product the user designates, as RFC 0024 designates a personhood provider. +- Consent. `disclose` has no prompt; the alternative is a prompt-once authorization beside `ChatAuthority`. +- Devices. Only the host that took `disclose` knows the disclosure, so contacts that reach the user's other devices are + not sent it. +- Reconcile timing. The relay runs when the chat product initializes, not when `disclose` returns. +- Resolution. Hosts parse references today; a shared resolver in the core would need the reference format specified + here rather than by the publishing product. diff --git a/rust/crates/truapi-chat-v2/src/lib.rs b/rust/crates/truapi-chat-v2/src/lib.rs index 583df5882..03ffbc28b 100644 --- a/rust/crates/truapi-chat-v2/src/lib.rs +++ b/rust/crates/truapi-chat-v2/src/lib.rs @@ -380,6 +380,8 @@ pub enum V2ChatMessageContent { /// A profile reference the sender's host discloses to this contact, or /// `None` to withdraw it. Host-originated and host-consumed: products /// never send or see it. V2 wire enum index 21. + /// + /// Known gap (docs/rfcs/profile-disclosure.md): index 21 is not yet agreed with native Chat. ProfileReference { discloser_product_id: String, reference: Option, diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 22a70100d..38d8fb2cb 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -267,6 +267,9 @@ export type CoreStorageKey = * The profile reference the user disclosed to their chat contacts, with * the product that disclosed it. Wallet-owned: one per user, whichever * product wrote it. The reference is a bearer capability. + * + * Known gap (docs/rfcs/profile-disclosure.md): one slot, so the last product to disclose replaces + * the others. */ | { tag: "ProfileDisclosure"; value?: undefined } /** diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index c539a3146..d7e758a8a 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -1915,6 +1915,9 @@ pub enum CoreStorageKey { /// The profile reference the user disclosed to their chat contacts, with /// the product that disclosed it. Wallet-owned: one per user, whichever /// product wrote it. The reference is a bearer capability. + /// + /// Known gap (docs/rfcs/profile-disclosure.md): one slot, so the last product to disclose replaces + /// the others. #[codec(index = 17)] ProfileDisclosure, /// Profile references this product's chat contacts disclosed, newest per diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 0d031535d..bdeb1ebdc 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -1421,6 +1421,7 @@ impl Profile for ProductRuntimeHost { ) -> Result> { // The user's own profile is disclosed from where they manage it, an // App, not from a background Worker. + // Known gap (docs/rfcs/profile-disclosure.md): no consent prompt yet. if self.product.execution_kind != truapi_platform::ProductExecutionKind::App { return Err(CallError::Denied); } diff --git a/rust/crates/truapi-server/src/runtime/native_chat.rs b/rust/crates/truapi-server/src/runtime/native_chat.rs index 708bb5820..75a481a23 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat.rs @@ -392,6 +392,8 @@ impl NativeChatRegistry { match &mut request { Request::Initialize => { chat.drive_files(&context).await?; + // Known gap (docs/rfcs/profile-disclosure.md): the relay runs here only, not when + // `disclose` returns, and only on this device. chat.publish_profile_reference(&context).await?; } Request::Bind { username } => { diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs index 4097a5d10..e175b4646 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs @@ -7,6 +7,9 @@ //! the initial share, a new contact, a replacement and a withdrawal are one //! reconcile: every peer whose watermark differs from the disclosure is sent //! the disclosure. The watermark advances when the message is queued. +//! +//! Known gap (docs/rfcs/profile-disclosure.md): advancing at queue time means a message that never +//! arrives is not resent until the disclosure changes. use super::*; use crate::runtime::native_chat::background::require_authorized; From dcd38a0ba3e816ff2567dd73668cfe8560e48fc8 Mon Sep 17 00:00:00 2001 From: w Date: Sat, 26 Sep 2026 16:08:26 -0400 Subject: [PATCH 05/30] refactor(truapi): move the Profile service to wire trait 22 Trait 20 is also claimed by Contacts (#17) and Game (#990), both headed for main; whichever lands second is likely to take 21. 22 keeps Profile clear of both. --- docs/rfcs/profile-disclosure.md | 2 +- rust/crates/truapi-client/src/generated.rs | 10 +++++----- rust/crates/truapi/src/api/profile.rs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/rfcs/profile-disclosure.md b/docs/rfcs/profile-disclosure.md index 3c3624cbf..86dcca7f7 100644 --- a/docs/rfcs/profile-disclosure.md +++ b/docs/rfcs/profile-disclosure.md @@ -40,7 +40,7 @@ The design has four parts: ### Trait ```rust -#[wire_trait(id = 20)] +#[wire_trait(id = 22)] #[crate::async_trait] pub trait Profile: Send + Sync { /// Show the referenced profile in host-owned UI. diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 9ff49d68f..251aa3002 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "5e0d5318926dc17f"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "7eb6dbf2b5c734ff"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -1642,7 +1642,7 @@ impl ProfilePresent { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 20, + trait_id: 22, method_id: 0, }), }; @@ -1669,7 +1669,7 @@ impl ProfileDisclose { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 20, + trait_id: 22, method_id: 1, }), }; @@ -1696,7 +1696,7 @@ impl ProfileRetract { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 20, + trait_id: 22, method_id: 2, }), }; @@ -1723,7 +1723,7 @@ impl ProfilePresentContact { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 20, + trait_id: 22, method_id: 3, }), }; diff --git a/rust/crates/truapi/src/api/profile.rs b/rust/crates/truapi/src/api/profile.rs index f67e4f811..aeccd76ab 100644 --- a/rust/crates/truapi/src/api/profile.rs +++ b/rust/crates/truapi/src/api/profile.rs @@ -14,7 +14,7 @@ use crate::{wire, wire_trait}; /// /// The product hands over an opaque reference; the host resolves, decrypts and /// renders it. Profile bytes never return to the product. -#[wire_trait(id = 20)] +#[wire_trait(id = 22)] #[crate::async_trait] pub trait Profile: Send + Sync { /// Show the referenced profile in host-owned UI. From 77bf245dbb279cf988295094e82123d11b4d1cba Mon Sep 17 00:00:00 2001 From: w Date: Sun, 27 Sep 2026 16:41:00 -0400 Subject: [PATCH 06/30] docs(truapi): use a hex peer identity in the presentContact example peer_identity is [u8; 32], which the TypeScript client types as a 0x-prefixed hex string, so the generated playground example failed tsc. --- rust/crates/truapi/src/api/profile.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/truapi/src/api/profile.rs b/rust/crates/truapi/src/api/profile.rs index aeccd76ab..bb09fa7eb 100644 --- a/rust/crates/truapi/src/api/profile.rs +++ b/rust/crates/truapi/src/api/profile.rs @@ -83,7 +83,7 @@ pub trait Profile: Send + Sync { /// /// ```ts /// const result = await truapi.profile.presentContact({ - /// peerIdentity: new Uint8Array(32), + /// peerIdentity: "0x0000000000000000000000000000000000000000000000000000000000000000", /// }); /// console.log("contact profile presentation:", result); /// ``` From 571f348f45be6a7d3c9f94e51d63ce791beab034 Mon Sep 17 00:00:00 2001 From: w Date: Sun, 27 Sep 2026 22:28:18 -0400 Subject: [PATCH 07/30] fix(profile): order, scope and bound the Chat profile relay - Queue profile references best effort. They have their own outbox budget, one per peer, so they neither crowd out nor are blocked by other Chat traffic; a reference with no room waits for a later reconcile instead of failing Chat Initialize. - Drop an unacknowledged reference after one statement lifetime instead of re-signing it forever; a peer that predates content index 21 never acknowledges it. The watermark stays, so it is not resent until the disclosure changes. - Order received references by frame timestamp. Withdrawals are kept as rows and a frame that is not strictly newer is ignored, so an older disclosure opened after its withdrawal cannot restore it. Senders stamp each frame to a peer later than the last and give it a fresh request id. - Scope ProfileDisclosure and ProfileReferencesReceived to the wallet root key and Chat network, like the Chat roster. Profile calls with no one signed in answer NotConnected. - Ask once per product before disclose stores anything, through a new ProfileDisclosure confirmation review and permission; a refusal is PermissionDenied. - Correct the changeset: the relay is part of this change. --- .changeset/profile-disclose.md | 16 +- docs/rfcs/profile-disclosure.md | 64 ++- .../truapi/ConfirmationReviewMapping.kt | 4 + .../TrUAPI/TrUAPIConfirmationPresenter.swift | 5 +- js/packages/truapi-host/README.md | 5 + rust/crates/truapi-client/src/generated.rs | 2 +- .../tests/golden/host-callbacks.ts | 77 +++- rust/crates/truapi-host-cli/src/platform.rs | 7 + rust/crates/truapi-platform/src/lib.rs | 53 ++- rust/crates/truapi-platform/src/mock.rs | 24 +- .../src/host_logic/permissions.rs | 53 ++- rust/crates/truapi-server/src/runtime.rs | 90 +++-- .../src/runtime/native_chat/actor.rs | 28 +- .../src/runtime/native_chat/actor/history.rs | 5 +- .../src/runtime/native_chat/actor/profile.rs | 131 ++++-- .../src/runtime/native_chat/actor/tests.rs | 378 +++++++++++++++++- .../truapi-server/src/runtime/profile.rs | 114 ++++-- .../crates/truapi-server/src/runtime/tests.rs | 291 +++++++++++--- rust/crates/truapi-server/src/test_support.rs | 15 +- rust/crates/truapi/src/api/profile.rs | 6 +- rust/crates/truapi/src/v01/profile.rs | 9 + 21 files changed, 1157 insertions(+), 220 deletions(-) diff --git a/.changeset/profile-disclose.md b/.changeset/profile-disclose.md index 4b8bed0fc..2cf5eab05 100644 --- a/.changeset/profile-disclose.md +++ b/.changeset/profile-disclose.md @@ -3,8 +3,14 @@ "@parity/truapi-host": minor --- -Add `profile.disclose`, `profile.retract` and `profile.presentContact`. A product discloses one opaque reference to -the user's chat contacts and may withdraw it; a product names a contact by peer identity and the host presents the -reference that contact disclosed, so no product holds a contact's reference. Disclosed and received references live in -core storage (`ProfileDisclosure`, `ProfileReferencesReceived`); the chat relay that fills the latter is not part of -this change. +Add `profile.disclose`, `profile.retract` and `profile.presentContact`. A product discloses one opaque reference to the +user's chat contacts and may withdraw it; a product names a contact by peer identity and the host presents the reference +that contact disclosed, so no product holds a contact's reference. The first `disclose` from a product asks the user +once through `userConfirmation.confirmPermission` with a new `ProfileDisclosure` review, remembered as the +`ProfileDisclosure` permission; a refusal is `PermissionDenied`. Hosts must render that review. + +This change includes the Chat relay. The host sends the disclosure to every ready Chat v2 contact as a host-private +message and keeps, per contact, the newest frame their host sent back, withdrawals included, whatever order the chat +product opens them in. Both live in wallet- and network-scoped core storage (`ProfileDisclosure`, +`ProfileReferencesReceived`). Delivery is best effort: relayed references never take outbox room from other Chat traffic +and are dropped, not re-signed, after one statement lifetime. diff --git a/docs/rfcs/profile-disclosure.md b/docs/rfcs/profile-disclosure.md index 86dcca7f7..7d76d425f 100644 --- a/docs/rfcs/profile-disclosure.md +++ b/docs/rfcs/profile-disclosure.md @@ -8,17 +8,17 @@ status: draft ## Summary -A product hands the host one opaque profile reference for the user's chat contacts. The host relays it to each -contact over Chat v2 and keeps the references contacts relay back. A chat product then asks the host to show a -contact's profile by naming the contact, and the host presents the reference that contact disclosed through the -existing `profile.present` path. No product holds another user's reference. +A product hands the host one opaque profile reference for the user's chat contacts. The host relays it to each contact +over Chat v2 and keeps the references contacts relay back. A chat product then asks the host to show a contact's profile +by naming the contact, and the host presents the reference that contact disclosed through the existing `profile.present` +path. No product holds another user's reference. ## Motivation -`profile.present` shows a profile from a reference the calling product already holds. A chat product has no honest -way to hold one for a contact: the reference is a bearer capability, so a product that carries it can read, keep and -forward the profile, and can show any reference against any contact. The reference has to travel host to host and stay -inside the hosts, and Chat v2 leaves ordinary delivery to products. +`profile.present` shows a profile from a reference the calling product already holds. A chat product has no honest way +to hold one for a contact: the reference is a bearer capability, so a product that carries it can read, keep and forward +the profile, and can show any reference against any contact. The reference has to travel host to host and stay inside +the hosts, and Chat v2 leaves ordinary delivery to products. ## Requirements @@ -30,9 +30,10 @@ inside the hosts, and Chat v2 leaves ordinary delivery to products. ## Approach -The design has four parts: +The design has five parts: - The `Profile` trait gains `disclose`, `retract` and `present_contact`. +- `disclose` asks the user once per product before anything is stored. - Core storage holds the user's disclosure and the references received per chat product. - The Chat v2 actor relays disclosures through its host-private outbox. - `present_contact` substitutes the stored reference into `present`. @@ -91,12 +92,18 @@ pub struct HostProfileDiscloseRequest { pub enum HostProfileDiscloseError { /// The reference is empty, too long, or not printable ASCII. InvalidReference, + /// The user declined, now or earlier, to let this product disclose a profile. + PermissionDenied, + /// No user is signed in. + NotConnected, /// Catch-all. Unknown { reason: String }, } pub enum HostProfileRetractError { /// Another product disclosed the reference the host holds. NotDiscloser, + /// No user is signed in. + NotConnected, /// Catch-all. Unknown { reason: String }, } @@ -109,17 +116,28 @@ pub enum HostProfilePresentContactError { NotShared, /// The stored reference no longer passes screening. InvalidReference, + /// No user is signed in. + NotConnected, /// Catch-all. Unknown { reason: String }, } ``` +### Consent + +Every contact receives the reference, so a product may disclose only once the user has allowed it. The first `disclose` +from a product raises `UserConfirmationReview::ProfileDisclosure { product_id }` through the host's +`confirm_permission`, beside `ChatAuthority`; the answer is remembered per product as +`PermissionAuthorizationRequest::ProfileDisclosure`, and a refusal, then or remembered, is `PermissionDenied` with +nothing stored. `retract` never asks: withdrawing only narrows what contacts hold. + ### Storage -Two core-storage slots hold references, and neither is visible to products. `ProfileDisclosure` is wallet-owned and -holds the disclosing product id and the reference. `ProfileReferencesReceived { product_id }` holds, per chat product, -the newest reference each contact disclosed with its discloser; clearing the product clears it with the roster it -belongs to. Hosts treat both as secret material. +Two core-storage slots hold references, and neither is visible to products. Both are scoped to the signed-in wallet and +the Chat network, as the Chat roster is. `ProfileDisclosure { root_public_key, genesis_hash }` holds the disclosing +product id and the reference. `ProfileReferencesReceived { root_public_key, genesis_hash, product_id }` holds, per chat +product, what each contact's host last sent: its discloser, its frame timestamp, and the reference, or `None` once +withdrawn. Clearing the product clears it. Hosts treat both as secret material. ### Relay @@ -131,6 +149,16 @@ disclosure to every peer whose watermark differs, which covers the first share, withdrawal. On receipt the host screens the frame, stores it for that peer and removes it from the plaintext returned to the product. Frames from compacted history are dropped. +The product decides the order it opens statements in, so frames are ordered by their timestamp, not by arrival. Each +frame a host sends a peer is timestamped later than the one before it, even if its clock steps back. The receiving host +applies a frame only if it is strictly newer than the one it holds, and keeps a withdrawal as a row rather than deleting +it, so a disclosure opened after its own withdrawal cannot bring the reference back. + +Delivery is best effort. References have their own outbox budget, one per peer, so they never take a slot payments or +rich files need, and a reference that finds no room waits for a later reconcile rather than failing the chat product's +initialization. A queued reference is offered for one statement lifetime and then dropped without being re-signed: a +host that predates the content type rejects the whole statement and never acknowledges it. + Stability comes from the reference format rather than the relay: a reference that names a mutable record, such as a registry slot, keeps working when the record changes, so a relay happens only when the reference itself changes. @@ -143,8 +171,8 @@ registry slot, keeps working when the record changes, so a relay happens only wh - One reference for all contacts, so withdrawing it from one contact means rotating it for all of them. - A retraction cannot make a contact's host forget a reference it already resolved. -- The watermark advances when the message is queued, so a message that never arrives is not resent until the - disclosure changes. +- The watermark advances when the message is queued, so a message that never arrives, or that a peer's host does not + acknowledge within one statement lifetime, is not resent until the disclosure changes. - Dropped: carrying the reference in ordinary chat content, which puts a bearer capability in product hands. ## Open questions @@ -153,9 +181,9 @@ registry slot, keeps working when the record changes, so a relay happens only wh - Several disclosing products. There is one `ProfileDisclosure` slot, so the last product to disclose replaces the others and the earlier one can no longer retract. The alternative is one slot per product, with the host relaying the one from a product the user designates, as RFC 0024 designates a personhood provider. -- Consent. `disclose` has no prompt; the alternative is a prompt-once authorization beside `ChatAuthority`. +- Consent covers the product, not the reference: once allowed, a product may replace its disclosure without asking. - Devices. Only the host that took `disclose` knows the disclosure, so contacts that reach the user's other devices are not sent it. - Reconcile timing. The relay runs when the chat product initializes, not when `disclose` returns. -- Resolution. Hosts parse references today; a shared resolver in the core would need the reference format specified - here rather than by the publishing product. +- Resolution. Hosts parse references today; a shared resolver in the core would need the reference format specified here + rather than by the publishing product. diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ConfirmationReviewMapping.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ConfirmationReviewMapping.kt index aca8c560b..5c3bf14d6 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ConfirmationReviewMapping.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ConfirmationReviewMapping.kt @@ -101,6 +101,10 @@ fun UserConfirmationReview.toConfirmation(callingProductId: String): TrUAPIConfi is UserConfirmationReview.ProductSubtree -> TrUAPIConfirmation.ProductSubtree(requesterProductId = v1.productId) + + // No prompt exists for profile disclosure yet; refusing it is the caller's fallback. + is UserConfirmationReview.ProfileDisclosure -> + throw UnsupportedReviewException("profile disclosure has no prompt on this host") } @OptIn(ExperimentalStdlibApi::class) diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift index 5b232eacf..2b35ddbd0 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift @@ -98,10 +98,13 @@ private extension TrUAPIConfirmationPresenter { ) case let .productSubtree(subtreeReview): await confirmAction(promptMapper.makeActionRequest(from: subtreeReview)) + // No prompt exists for profile disclosure yet, so `confirmPermission` + // refuses it. case .identityDisclosure, .chatAuthority, .accountAccess, - .accountAlias: + .accountAlias, + .profileDisclosure: await confirmPermission(review: review, from: requesterName) != .deny case let .createProof(proofReview): try await confirmCreateProof( diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 15fa72664..7a36b5a0f 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -188,6 +188,11 @@ when the user dismisses it. The reference is a bearer capability: the host fetch profile's bytes never return to the product. The core forwards only references that are non-empty, at most 2048 bytes and printable ASCII without whitespace; parsing the format is the host's. +`profile.disclose` needs no `profile` group, but the first call from a product asks the user through +`userConfirmation.confirmPermission` with a `ProfileDisclosure` review naming that product: every Chat contact receives +the reference. The answer is kept like any other permission, as `ProfileDisclosure`. A host that cannot render the +review should reject the call rather than answer `Deny`: the product is refused, but no refusal is remembered. + Under `createWebWorkerPairingHostRuntime` the presence of each optional group is reported to the worker in its `init` message, so the core sees the same capability set on both sides of the boundary. diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 251aa3002..c52f883f7 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "7eb6dbf2b5c734ff"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "034025152ab6b451"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 87c2c9b2a..a01e77069 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -264,20 +264,32 @@ export type CoreStorageKey = value: { rootPublicKey: Uint8Array; genesisHash: Uint8Array }; } /** - * The profile reference the user disclosed to their chat contacts, with - * the product that disclosed it. Wallet-owned: one per user, whichever - * product wrote it. The reference is a bearer capability. + * The profile reference the user disclosed to their chat contacts on one + * Chat network, with the product that disclosed it. Wallet-owned: one per + * wallet and network, whichever product wrote it. The reference is a + * bearer capability. * * Known gap (docs/rfcs/profile-disclosure.md): one slot, so the last product to disclose replaces * the others. */ - | { tag: "ProfileDisclosure"; value?: undefined } + | { + tag: "ProfileDisclosure"; + value: { rootPublicKey: Uint8Array; genesisHash: Uint8Array }; + } /** - * Profile references this product's chat contacts disclosed, newest per - * contact. Product-indexed, like the roster they belong to, so clearing + * Profile references the contacts on one Chat product's roster disclosed, + * the newest per contact, withdrawals included. Scoped like the + * `NativeChatDevice` roster it shadows, and product-indexed so clearing * the product clears them. The references are bearer capabilities. */ - | { tag: "ProfileReferencesReceived"; value: { productId: string } }; + | { + tag: "ProfileReferencesReceived"; + value: { + rootPublicKey: Uint8Array; + genesisHash: Uint8Array; + productId: string; + }; + }; /** * Review shown before a product creates a ring-VRF proof (RFC 0004). @@ -743,7 +755,12 @@ export type PermissionAuthorizationRequest = | { tag: "StatementStoreAllowance"; value: { derivationIndex?: DerivationIndex }; - }; + } + /** + * Product-scoped permission to disclose a profile reference to the user's + * Chat contacts. + */ + | { tag: "ProfileDisclosure"; value?: undefined }; /** * Authorization status for a permission request. @@ -815,6 +832,18 @@ export interface ProductSubtreeReview { productId: string; } +/** + * Review shown before a product first discloses a profile reference to the + * user's Chat contacts. The host relays it to every contact, so the prompt + * names the product, never the contacts or the reference. + */ +export interface ProfileDisclosureReview { + /** + * Product asking to disclose the profile. + */ + productId: string; +} + /** * Review shown before allocating resources for a product. Names the * beneficiary product so the user knows which product receives the @@ -1028,7 +1057,12 @@ export type UserConfirmationReview = /** * Confirm this exact main-purse payment; never eligible for auto-approval. */ - | { tag: "MainPurseChatPayment"; value: MainPurseChatPaymentReview }; + | { tag: "MainPurseChatPayment"; value: MainPurseChatPaymentReview } + /** + * Allow a product to disclose a profile reference to the user's Chat + * contacts. + */ + | { tag: "ProfileDisclosure"; value: ProfileDisclosureReview }; /** * Review shown before a product asks to access another product account. @@ -1158,8 +1192,17 @@ export const CoreStorageKey: S.Codec = S.lazy( rootPublicKey: S.Bytes(32), genesisHash: S.Bytes(32), }) as S.Codec<{ rootPublicKey: Uint8Array; genesisHash: Uint8Array }>, - ProfileDisclosure: S._void, - ProfileReferencesReceived: S.Struct({ productId: S.str }) as S.Codec<{ + ProfileDisclosure: S.Struct({ + rootPublicKey: S.Bytes(32), + genesisHash: S.Bytes(32), + }) as S.Codec<{ rootPublicKey: Uint8Array; genesisHash: Uint8Array }>, + ProfileReferencesReceived: S.Struct({ + rootPublicKey: S.Bytes(32), + genesisHash: S.Bytes(32), + productId: S.str, + }) as S.Codec<{ + rootPublicKey: Uint8Array; + genesisHash: Uint8Array; productId: string; }>, }), @@ -1483,6 +1526,7 @@ export const PermissionAuthorizationRequest: S.Codec, + ProfileDisclosure: S._void, }), ); @@ -1550,6 +1594,16 @@ export const ProductSubtreeReview: S.Codec = S.lazy( S.Struct({ productId: S.str }) as S.Codec, ); +/** + * Review shown before a product first discloses a profile reference to the + * user's Chat contacts. The host relays it to every contact, so the prompt + * names the product, never the contacts or the reference. + */ +export const ProfileDisclosureReview: S.Codec = S.lazy( + (): S.Codec => + S.Struct({ productId: S.str }) as S.Codec, +); + /** * Review shown before allocating resources for a product. Names the * beneficiary product so the user knows which product receives the @@ -1673,6 +1727,7 @@ export const UserConfirmationReview: S.Codec = S.lazy( ProductSubtree: ProductSubtreeReview, ChatAuthority: ChatAuthorityReview, MainPurseChatPayment: MainPurseChatPaymentReview, + ProfileDisclosure: ProfileDisclosureReview, }), ); diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index d27f96ec2..06e54aa75 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -1183,6 +1183,13 @@ fn approval_summary(review: &UserConfirmationReview) -> (&'static str, String) { hex::encode(review.operation_id), ), ), + UserConfirmationReview::ProfileDisclosure(review) => ( + "share your profile with your Chat contacts", + format!( + "Product {} requested permission to share a reference to your profile with every Chat contact. Contacts who receive it can read that profile.", + review.product_id + ), + ), } } diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index e0bb7ae1f..3484d3624 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -1232,6 +1232,10 @@ pub enum PermissionAuthorizationRequest { /// `None` selects the legacy allowance account; `Some` selects a product account. derivation_index: Option, }, + /// Product-scoped permission to disclose a profile reference to the user's + /// Chat contacts. + #[codec(index = 6)] + ProfileDisclosure, } /// Authorization status for a permission request. @@ -1937,19 +1941,30 @@ pub enum CoreStorageKey { /// Host-selected Chat network. genesis_hash: [u8; 32], }, - /// The profile reference the user disclosed to their chat contacts, with - /// the product that disclosed it. Wallet-owned: one per user, whichever - /// product wrote it. The reference is a bearer capability. + /// The profile reference the user disclosed to their chat contacts on one + /// Chat network, with the product that disclosed it. Wallet-owned: one per + /// wallet and network, whichever product wrote it. The reference is a + /// bearer capability. /// /// Known gap (docs/rfcs/profile-disclosure.md): one slot, so the last product to disclose replaces /// the others. #[codec(index = 17)] - ProfileDisclosure, - /// Profile references this product's chat contacts disclosed, newest per - /// contact. Product-indexed, like the roster they belong to, so clearing + ProfileDisclosure { + /// Wallet whose user disclosed the reference. + root_public_key: [u8; 32], + /// Host-selected Chat network the reference is relayed on. + genesis_hash: [u8; 32], + }, + /// Profile references the contacts on one Chat product's roster disclosed, + /// the newest per contact, withdrawals included. Scoped like the + /// `NativeChatDevice` roster it shadows, and product-indexed so clearing /// the product clears them. The references are bearer capabilities. #[codec(index = 18)] ProfileReferencesReceived { + /// Wallet owning the Chat identity the references were sent to. + root_public_key: [u8; 32], + /// Host-selected Chat network. + genesis_hash: [u8; 32], /// Chat product whose contacts sent the references. product_id: String, }, @@ -2009,8 +2024,8 @@ pub fn describe_core_storage_key( CoreStorageKey::MainPurseCoinage { .. } => ("MainPurseCoinage", None), CoreStorageKey::NativeChatDevice { .. } => ("NativeChatDevice", None), CoreStorageKey::NativeChatProducts { .. } => ("NativeChatProducts", None), - CoreStorageKey::ProfileDisclosure => ("ProfileDisclosure", None), - CoreStorageKey::ProfileReferencesReceived { product_id } => { + CoreStorageKey::ProfileDisclosure { .. } => ("ProfileDisclosure", None), + CoreStorageKey::ProfileReferencesReceived { product_id, .. } => { ("ProfileReferencesReceived", Some(product_id)) } CoreStorageKey::NativeChatFileChunk { product_id, .. } => { @@ -2100,6 +2115,15 @@ impl CoreStorageKey { request: PermissionAuthorizationRequest::StatementStoreAllowance { derivation_index }, } } + + /// Persisted authorization key for disclosing a profile reference to the + /// user's Chat contacts. + pub fn profile_disclosure_authorization(product_id: &str) -> Self { + Self::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::ProfileDisclosure, + } + } } /// Canonical storage form for one remote-access domain pattern. @@ -3657,6 +3681,16 @@ pub struct ChatAuthorityReview { pub product_id: String, } +/// Review shown before a product first discloses a profile reference to the +/// user's Chat contacts. The host relays it to every contact, so the prompt +/// names the product, never the contacts or the reference. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct ProfileDisclosureReview { + /// Product asking to disclose the profile. + pub product_id: String, +} + /// Exact Host-resolved payment reviewed before debiting the user's main purse. /// /// This review never grants a reusable spending permission. Chat authority and @@ -3732,6 +3766,9 @@ pub enum UserConfirmationReview { ChatAuthority(ChatAuthorityReview), /// Confirm this exact main-purse payment; never eligible for auto-approval. MainPurseChatPayment(MainPurseChatPaymentReview), + /// Allow a product to disclose a profile reference to the user's Chat + /// contacts. + ProfileDisclosure(ProfileDisclosureReview), } /// Local user confirmation UI for sensitive core-owned operations. diff --git a/rust/crates/truapi-platform/src/mock.rs b/rust/crates/truapi-platform/src/mock.rs index 77ed5f2a5..86b420bb5 100644 --- a/rust/crates/truapi-platform/src/mock.rs +++ b/rust/crates/truapi-platform/src/mock.rs @@ -93,6 +93,8 @@ pub enum ConfirmKind { ChatAuthority, /// [`UserConfirmationReview::MainPurseChatPayment`]. MainPurseChatPayment, + /// [`UserConfirmationReview::ProfileDisclosure`]. + ProfileDisclosure, } impl ConfirmKind { @@ -114,6 +116,7 @@ impl ConfirmKind { UserConfirmationReview::ProductSubtree(_) => ConfirmKind::ProductSubtree, UserConfirmationReview::ChatAuthority(_) => ConfirmKind::ChatAuthority, UserConfirmationReview::MainPurseChatPayment(_) => ConfirmKind::MainPurseChatPayment, + UserConfirmationReview::ProfileDisclosure(_) => ConfirmKind::ProfileDisclosure, } } } @@ -827,10 +830,23 @@ fn core_key(key: &CoreStorageKey) -> String { hex_key(root_public_key), hex_key(genesis_hash) ), - CoreStorageKey::ProfileDisclosure => "core:profile-disclosure".to_string(), - CoreStorageKey::ProfileReferencesReceived { product_id } => { - format!("core:profile-references-received:{product_id}") - } + CoreStorageKey::ProfileDisclosure { + root_public_key, + genesis_hash, + } => format!( + "core:profile-disclosure:{}:{}", + hex_key(root_public_key), + hex_key(genesis_hash) + ), + CoreStorageKey::ProfileReferencesReceived { + root_public_key, + genesis_hash, + product_id, + } => format!( + "core:profile-references-received:{}:{}:{product_id}", + hex_key(root_public_key), + hex_key(genesis_hash) + ), CoreStorageKey::NativeChatFileChunk { root_public_key, genesis_hash, diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs index c77166554..71d6f13c4 100644 --- a/rust/crates/truapi-server/src/host_logic/permissions.rs +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -33,7 +33,8 @@ //! authorized for every remote permission while nothing is stored, and never //! reaches the prompt callback. A stored decision still wins, so a denial //! written through the admin surface revokes the grant. Device permissions, -//! identity disclosure, account access, and Chat authority are never covered. +//! identity disclosure, account access, Chat authority, and profile disclosure +//! are never covered. use std::collections::HashSet; use std::sync::Arc; @@ -47,8 +48,9 @@ use truapi_platform::{ BLESSED_REMOTE_DOMAINS, ChatAuthorityReview, CoreStorage, CoreStorageKey, DevicePermissionStatus, IdentityDisclosureReview, PermissionAuthorizationRequest, PermissionAuthorizationStatus, PermissionDecision, PermissionStatusHost, Permissions, - ProductContext, UserConfirmation, UserConfirmationReview, has_trusted_remote_permissions, - is_valid_remote_domain_pattern, normalize_remote_domain, remote_domain_candidates, + ProductContext, ProfileDisclosureReview, UserConfirmation, UserConfirmationReview, + has_trusted_remote_permissions, is_valid_remote_domain_pattern, normalize_remote_domain, + remote_domain_candidates, }; /// Persisted answer for a single permission request. Keep `Authorized` at @@ -415,6 +417,13 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a ) .await } + PermissionAuthorizationRequest::ProfileDisclosure => { + authorization_status( + self.storage, + CoreStorageKey::profile_disclosure_authorization(self.product_id()), + ) + .await + } } } @@ -479,6 +488,9 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a derivation_index.clone(), ) } + PermissionAuthorizationRequest::ProfileDisclosure => { + CoreStorageKey::profile_disclosure_authorization(self.product_id()) + } }; self.temporary_permissions.revoke(&key); set_authorization_status(self.storage, key, status).await @@ -555,6 +567,41 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a Ok(status) } + /// Resolve the product's grant to disclose a profile reference to the + /// user's Chat contacts, prompting once when no durable user decision + /// exists. + pub async fn check_or_prompt_profile_disclosure( + &self, + ) -> Result + where + P: UserConfirmation, + { + let request = PermissionAuthorizationRequest::ProfileDisclosure; + let cached = self.authorization_status(&request).await?; + if cached != PermissionAuthorizationStatus::NotDetermined { + return Ok(cached); + } + let decision = match self + .prompt + .confirm_permission(UserConfirmationReview::ProfileDisclosure( + ProfileDisclosureReview { + product_id: self.product_id().to_string(), + }, + )) + .await + { + Ok(decision) => decision, + Err(_) => return Ok(PermissionAuthorizationStatus::NotDetermined), + }; + let status = match decision { + PermissionDecision::AllowOnce => return Ok(PermissionAuthorizationStatus::Authorized), + PermissionDecision::AllowAlways => PermissionAuthorizationStatus::Authorized, + PermissionDecision::Deny => PermissionAuthorizationStatus::Denied, + }; + self.set_authorization_status(&request, status).await?; + Ok(status) + } + /// Resolves a device capability against both the OS state and the stored /// product decision, retaining the lifetime chosen through the platform's /// `device_permission` callback when the question is still open. diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 37a7f2dd4..c808e9df8 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -792,6 +792,19 @@ impl ProductRuntimeHost { .map_err(|err| format!("permission storage failed: {err:?}")) } + #[instrument( + skip_all, + fields(runtime.method = "permissions.profile_disclosure_authorization") + )] + async fn profile_disclosure_authorization( + &self, + ) -> Result { + self.permissions_service() + .check_or_prompt_profile_disclosure() + .await + .map_err(|err| format!("permission storage failed: {err:?}")) + } + async fn classify_legacy_address_signer( &self, cx: &CallContext, @@ -1251,6 +1264,17 @@ impl ProductRuntimeHost { ) -> Result, CallError> { self.profile_platform.clone().ok_or(CallError::Unsupported) } + + /// The signed-in wallet on the Chat network, which owns the user's + /// disclosure and what their contacts sent back: the same wallet and + /// network the Chat actor relays for. `None` with no one signed in. + fn profile_owner(&self) -> Option { + let session = self.authority.session_state().current()?; + Some(profile::ProfileOwner { + root_public_key: session.public_key, + genesis_hash: self.services.people_chain_genesis_hash, + }) + } } #[truapi_platform::async_trait] @@ -1467,28 +1491,37 @@ impl Profile for ProductRuntimeHost { ) -> Result> { // The user's own profile is disclosed from where they manage it, an // App, not from a background Worker. - // Known gap (docs/rfcs/profile-disclosure.md): no consent prompt yet. if self.product.execution_kind != truapi_platform::ProductExecutionKind::App { return Err(CallError::Denied); } + let domain = |error| CallError::Domain(HostProfileDiscloseError::V1(error)); let HostProfileDiscloseRequest::V1(request) = request; if !is_screened_profile_reference(&request.reference) { - return Err(CallError::Domain(HostProfileDiscloseError::V1( - v01::HostProfileDiscloseError::InvalidReference, - ))); + return Err(domain(v01::HostProfileDiscloseError::InvalidReference)); + } + let owner = self + .profile_owner() + .ok_or_else(|| domain(v01::HostProfileDiscloseError::NotConnected))?; + // Every contact receives the reference, so the user decides once per + // product whether it may hand one over. + match self.profile_disclosure_authorization().await { + Ok(PermissionAuthorizationStatus::Authorized) => {} + Ok( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => { + return Err(domain(v01::HostProfileDiscloseError::PermissionDenied)); + } + Err(reason) => return Err(domain(v01::HostProfileDiscloseError::Unknown { reason })), } let disclosure = profile::Disclosure { product_id: self.product_id(), reference: request.reference, }; - profile::write_disclosure(self.platform.as_ref(), &disclosure) + profile::write_disclosure(self.platform.as_ref(), owner, &disclosure) .await .map(|()| HostProfileDiscloseResponse::V1) - .map_err(|reason| { - CallError::Domain(HostProfileDiscloseError::V1( - v01::HostProfileDiscloseError::Unknown { reason }, - )) - }) + .map_err(|reason| domain(v01::HostProfileDiscloseError::Unknown { reason })) } #[instrument(skip_all, fields(runtime.method = "profile.retract"))] @@ -1500,21 +1533,22 @@ impl Profile for ProductRuntimeHost { if self.product.execution_kind != truapi_platform::ProductExecutionKind::App { return Err(CallError::Denied); } - let unknown = |reason| { - CallError::Domain(HostProfileRetractError::V1( - v01::HostProfileRetractError::Unknown { reason }, - )) - }; + let domain = |error| CallError::Domain(HostProfileRetractError::V1(error)); + let unknown = |reason| domain(v01::HostProfileRetractError::Unknown { reason }); + let owner = self + .profile_owner() + .ok_or_else(|| domain(v01::HostProfileRetractError::NotConnected))?; let storage = self.platform.as_ref(); - match profile::read_disclosure(storage).await.map_err(unknown)? { + match profile::read_disclosure(storage, owner) + .await + .map_err(unknown)? + { None => Ok(HostProfileRetractResponse::V1), // One product may not withdraw what another disclosed. Some(disclosure) if disclosure.product_id != self.product_id() => { - Err(CallError::Domain(HostProfileRetractError::V1( - v01::HostProfileRetractError::NotDiscloser, - ))) + Err(domain(v01::HostProfileRetractError::NotDiscloser)) } - Some(_) => profile::clear_disclosure(storage) + Some(_) => profile::clear_disclosure(storage, owner) .await .map(|()| HostProfileRetractResponse::V1) .map_err(unknown), @@ -1530,28 +1564,28 @@ impl Profile for ProductRuntimeHost { let platform = self.profile_platform()?; let HostProfilePresentContactRequest::V1(request) = request; let domain = |error| CallError::Domain(HostProfilePresentContactError::V1(error)); - let received = profile::received_reference( + let owner = self + .profile_owner() + .ok_or_else(|| domain(v01::HostProfilePresentContactError::NotConnected))?; + let reference = profile::received_reference( self.platform.as_ref(), + owner, &self.product_id(), &request.peer_identity, ) .await .map_err(|reason| domain(v01::HostProfilePresentContactError::Unknown { reason }))? + .and_then(|received| received.reference) .ok_or_else(|| domain(v01::HostProfilePresentContactError::NotShared))?; // A stored reference passed the same screen when it arrived; check // again rather than trust storage. - if !is_screened_profile_reference(&received.reference) { + if !is_screened_profile_reference(&reference) { return Err(domain( v01::HostProfilePresentContactError::InvalidReference, )); } platform - .present_profile( - &self.product, - v01::HostProfilePresentRequest { - reference: received.reference, - }, - ) + .present_profile(&self.product, v01::HostProfilePresentRequest { reference }) .await .map(|()| HostProfilePresentContactResponse::V1) .map_err(|error| { diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor.rs index e2787ce7d..37ad21401 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor.rs @@ -37,6 +37,8 @@ use crate::runtime::{ type Error = HostProductDeviceChatError; const MAX_PEERS: usize = 256; const MAX_OUTBOX: usize = 256; +/// Profile references are budgeted apart from other traffic, one per peer. +const MAX_PROFILE_OUTBOX: usize = MAX_PEERS; const MAX_RECEIPTS: usize = 4096; const MAX_HISTORY_BATCHES: usize = 256; const LIFETIME: u64 = 2 * 86_400; @@ -269,12 +271,26 @@ impl State { } return Ok(()); } - if self.outbox.len() >= MAX_OUTBOX { + let profile = matches!(outgoing.kind, OutgoingKind::ProfileReference(_)); + let limit = if profile { + MAX_PROFILE_OUTBOX + } else { + MAX_OUTBOX + }; + if self.outbox_used(profile) >= limit { return Err(Error::StorageUnavailable); } self.outbox.push(outgoing); Ok(()) } + /// Entries in one outbox budget: profile references, or all other + /// traffic. Neither can crowd out the other. + fn outbox_used(&self, profile: bool) -> usize { + self.outbox + .iter() + .filter(|entry| matches!(entry.kind, OutgoingKind::ProfileReference(_)) == profile) + .count() + } } impl Decode for State { @@ -441,7 +457,8 @@ impl NativeChatActor { fn validate_state(&self, state: &State) -> Result<(), Error> { if state.peers.len() > MAX_PEERS || state.invitations.len() > 16 - || state.outbox.len() > MAX_OUTBOX + || state.outbox_used(false) > MAX_OUTBOX + || state.outbox_used(true) > MAX_PROFILE_OUTBOX || state.received.len() > MAX_RECEIPTS || state.sent.len() > MAX_RECEIPTS || state.accepted_payments.len() > MAX_RECEIPTS @@ -1190,6 +1207,8 @@ impl NativeChatActor { } wallet.reconcile(context).await?; } + // Profile references are not renewed: one lifetime, then dropped. + self.retire_lapsed_profile_references(context).await?; // Only expiry may be renewed on a durable opaque payment handoff. The // product submits/retries the resulting statement; Host never delivers. let pending = self @@ -1201,10 +1220,7 @@ impl NativeChatActor { .filter(|entry| { matches!(entry.kind, OutgoingKind::Payment(_)) || (!state.boundary.legacy_pending - && matches!( - entry.kind, - OutgoingKind::Rich(_) | OutgoingKind::ProfileReference(_) - )) + && matches!(entry.kind, OutgoingKind::Rich(_))) }) .cloned() .collect::>() diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs index cca34324e..649f52abd 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs @@ -385,7 +385,8 @@ impl NativeChatActor { } /// Keep the newest profile reference each frame carries for `peer`, in - /// this product's received-reference slot. `None` withdraws it. + /// this product's received-reference slot. `None` withdraws it, and a + /// frame older than the one held changes nothing. async fn record_profile_references( &self, context: &NativeChatContext, @@ -395,9 +396,11 @@ impl NativeChatActor { for frame in frames { crate::runtime::profile::record_received_reference( &*context.services.platform, + super::profile::profile_owner(context), &self.product, peer, frame.discloser_product_id, + frame.timestamp, frame.reference, ) .await diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs index e175b4646..97677f26c 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs @@ -6,23 +6,43 @@ //! A per-peer watermark records what this Host last queued for that peer, so //! the initial share, a new contact, a replacement and a withdrawal are one //! reconcile: every peer whose watermark differs from the disclosure is sent -//! the disclosure. The watermark advances when the message is queued. +//! the disclosure. The watermark advances when the message is queued. Each +//! frame to a peer is timestamped later than the one before it, so the peer's +//! host keeps the newest whatever order it opens them in. +//! +//! Delivery is best effort. References have their own outbox budget, one per +//! peer, so they never take a slot user traffic needs; a reference that finds +//! no room is left for a later reconcile. A queued reference is offered for one +//! statement lifetime and then dropped rather than re-signed: a host that does +//! not know the content type never acknowledges it, and would otherwise hold +//! the slot for good. //! //! Known gap (docs/rfcs/profile-disclosure.md): advancing at queue time means a message that never //! arrives is not resent until the disclosure changes. use super::*; use crate::runtime::native_chat::background::require_authorized; -use crate::runtime::profile::{Disclosure, read_disclosure}; +use crate::runtime::profile::{Disclosure, ProfileOwner, read_disclosure}; /// What this Host last queued to one peer. #[derive(Clone, PartialEq, Eq, Encode, Decode)] pub(super) struct ProfileWatermark { pub(super) peer: [u8; 32], - /// Digest of the disclosure sent, identifying it without keeping it. - pub(super) digest: [u8; 32], + /// Digest of the disclosure sent, identifying it without keeping it; + /// `None` once a withdrawal was sent. + pub(super) digest: Option<[u8; 32]>, /// Product that disclosed it, repeated on a withdrawal. pub(super) discloser_product_id: String, + /// Timestamp of the frame sent. The next frame to this peer is later. + pub(super) timestamp: u64, +} + +/// The wallet and Chat network the user's disclosure belongs to. +pub(super) fn profile_owner(context: &NativeChatContext) -> ProfileOwner { + ProfileOwner { + root_public_key: context.session.public_key, + genesis_hash: context.genesis_hash, + } } fn disclosure_digest(disclosure: &Disclosure) -> [u8; 32] { @@ -45,7 +65,7 @@ fn wanted( match (disclosure, current) { (Some(disclosure), current) => { let digest = disclosure_digest(disclosure); - if current.is_some_and(|watermark| watermark.digest == digest) { + if current.is_some_and(|watermark| watermark.digest == Some(digest)) { return None; } Some(( @@ -54,14 +74,26 @@ fn wanted( Some(digest), )) } - (None, Some(watermark)) => Some((watermark.discloser_product_id.clone(), None, None)), - (None, None) => None, + (None, Some(watermark)) if watermark.digest.is_some() => { + Some((watermark.discloser_product_id.clone(), None, None)) + } + (None, _) => None, } } +/// A queued reference whose statement lifetime is over. +fn lapsed(entry: &Outgoing, now: u64) -> bool { + matches!(entry.kind, OutgoingKind::ProfileReference(_)) + && entry + .statement + .expiry + .is_none_or(|expiry| (expiry >> 32) <= now) +} + impl NativeChatActor { /// Queue a profile reference (or withdrawal) for every ready peer whose - /// watermark differs from the user's current disclosure. + /// watermark differs from the user's current disclosure, as far as the + /// outbox has room. `true` when anything was queued. pub(in crate::runtime::native_chat) async fn publish_profile_reference( self: &Arc, context: &NativeChatContext, @@ -74,7 +106,8 @@ impl NativeChatActor { { return Ok(false); } - let disclosure = read_disclosure(&*context.services.platform) + self.retire_lapsed_profile_references(context).await?; + let disclosure = read_disclosure(&*context.services.platform, profile_owner(context)) .await .map_err(|_| Error::StorageUnavailable)?; let stale = self @@ -109,6 +142,8 @@ impl NativeChatActor { if !valid() { return Err(Error::NotConnected); } + let now = current_unix_secs().saturating_mul(1000); + let mut queued = false; for identity in stale { let peer = state.peer(&identity)?.clone(); if !peer.ready() { @@ -122,11 +157,16 @@ impl NativeChatActor { else { continue; }; - let tag = hash(&(identity, &discloser, &reference).encode()); + // Later than anything sent to this peer before, even + // after the clock steps back, so its host can order them. + let timestamp = current.map_or(now, |watermark| { + now.max(watermark.timestamp.saturating_add(1)) + }); + let tag = hash(&(identity, &discloser, &reference, timestamp).encode()); let request_id = format!("profile-{}", hex::encode(&tag[..8])); let bytes = wire::encode_profile_reference_message( &request_id, - current_unix_secs().saturating_mul(1000), + timestamp, &discloser, reference.as_deref(), ) @@ -144,7 +184,7 @@ impl NativeChatActor { entry.peer != identity || !matches!(entry.kind, OutgoingKind::ProfileReference(_)) }); - state.queue(Outgoing { + match state.queue(Outgoing { peer: identity, request_id, digest: hash(&messages.encode()), @@ -152,22 +192,54 @@ impl NativeChatActor { roster_revision: peer.revision, statement, last_attempt: 0, - })?; + }) { + Ok(()) => queued = true, + // No room: this peer and the rest keep their + // watermarks, so a later reconcile retries them. + Err(Error::StorageUnavailable) => break, + Err(error) => return Err(error), + } state .profile_shared .retain(|watermark| watermark.peer != identity); - if let Some(digest) = digest { - state.profile_shared.push(ProfileWatermark { - peer: identity, - digest, - discloser_product_id: discloser, - }); - } + state.profile_shared.push(ProfileWatermark { + peer: identity, + digest, + discloser_product_id: discloser, + timestamp, + }); + } + Ok(queued) + }) + .await + } + + /// Drop queued references whose statement lifetime is over. Their + /// watermarks stay, so the same disclosure is not queued again: a peer + /// that did not acknowledge it in a lifetime is not helped by another + /// signature, only a changed disclosure is sent again. + pub(super) async fn retire_lapsed_profile_references( + &self, + context: &NativeChatContext, + ) -> Result<(), Error> { + let now = current_unix_secs(); + if !self + .store + .read(move |state| state.outbox.iter().any(|entry| lapsed(entry, now))) + .await? + { + return Ok(()); + } + let valid = context.session_valid.clone(); + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); } + state.outbox.retain(|entry| !lapsed(entry, now)); Ok(()) }) - .await?; - Ok(true) + .await } } @@ -187,8 +259,9 @@ mod tests { let current = disclosure("seity-contacts:v1:aa"); let held = ProfileWatermark { peer: [1; 32], - digest: disclosure_digest(¤t), + digest: Some(disclosure_digest(¤t)), discloser_product_id: "seity.dot".into(), + timestamp: 1, }; assert!(wanted(Some(¤t), Some(&held)).is_none()); let (_, reference, _) = wanted(Some(&disclosure("seity-contacts:v1:bb")), Some(&held)) @@ -200,6 +273,18 @@ mod tests { (discloser.as_str(), reference, digest), ("seity.dot", None, None) ); + let withdrawn = ProfileWatermark { + digest: None, + ..held + }; + assert!( + wanted(None, Some(&withdrawn)).is_none(), + "a withdrawal is sent once" + ); + assert!( + wanted(Some(¤t), Some(&withdrawn)).is_some(), + "a withdrawn peer is sent a new disclosure" + ); assert!( wanted(None, None).is_none(), "nothing to withdraw from a new peer" diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs index 51d17d0cf..7e827a897 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs @@ -1601,6 +1601,7 @@ fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retrac block_on(async { use crate::runtime::profile::{Disclosure, clear_disclosure, write_disclosure}; let fixture = Fixture::new(); + let owner = profile::profile_owner(&fixture.context); set_product_grants( &fixture.platform, PRODUCT, @@ -1629,6 +1630,7 @@ fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retrac write_disclosure( fixture.platform.as_ref(), + owner, &Disclosure { product_id: "seity.dot".into(), reference: PROFILE_REFERENCE.into(), @@ -1650,6 +1652,7 @@ fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retrac ); assert_eq!(view.prepared[0].peer_identity, identity.account); assert!(view.prepared[0].requires_ack); + let first_request = view.prepared[0].request_id.clone(); assert!( !contains( &view.prepared[0].statement.encode(), @@ -1667,6 +1670,7 @@ fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retrac write_disclosure( fixture.platform.as_ref(), + owner, &Disclosure { product_id: "seity.dot".into(), reference: format!("{PROFILE_REFERENCE}ff"), @@ -1686,7 +1690,9 @@ fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retrac "a replacement supersedes the queued disclosure" ); - clear_disclosure(fixture.platform.as_ref()).await.unwrap(); + clear_disclosure(fixture.platform.as_ref(), owner) + .await + .unwrap(); assert!( actor .publish_profile_reference(&fixture.context) @@ -1697,16 +1703,43 @@ fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retrac assert!( actor .store - .read(|state| state.profile_shared.is_empty()) + .read(|state| state + .profile_shared + .iter() + .all(|watermark| watermark.digest.is_none())) .await - .unwrap() + .unwrap(), + "the withdrawal is remembered, not forgotten" ); assert!( !actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "a withdrawal is sent once" + ); + + // Disclosing the first reference again is a new message, not a replay + // of the first one, which the peer's host would refuse as a conflict. + write_disclosure( + fixture.platform.as_ref(), + owner, + &Disclosure { + product_id: "seity.dot".into(), + reference: PROFILE_REFERENCE.into(), + }, + ) + .await + .unwrap(); + assert!( + actor .publish_profile_reference(&fixture.context) .await .unwrap() ); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert_eq!(view.prepared.len(), 1); + assert_ne!(view.prepared[0].request_id, first_request); }); } @@ -1752,32 +1785,345 @@ fn a_received_profile_reference_is_kept_by_the_host_and_cut_from_what_the_produc vec![text], "ordinary content passes through untouched" ); - let held = received_reference(fixture.platform.as_ref(), PRODUCT, &identity.account) + let owner = profile::profile_owner(&fixture.context); + let held = received_reference(fixture.platform.as_ref(), owner, PRODUCT, &identity.account) .await .unwrap() .expect("the host keeps what the contact disclosed"); - assert_eq!(held.reference, PROFILE_REFERENCE); + assert_eq!(held.reference.as_deref(), Some(PROFILE_REFERENCE)); assert_eq!(held.discloser_product_id, "seity.dot"); - let withdrawal = wire::encode_profile_reference_message( - "profile-2", - fixture.timestamp, - "seity.dot", + open_profile_frame( + &fixture, + &actor, + &identity, + &peer, + "incoming-withdrawal", + fixture.timestamp + 1, None, ) + .await; + assert_eq!( + received_reference(fixture.platform.as_ref(), owner, PRODUCT, &identity.account) + .await + .unwrap() + .and_then(|held| held.reference), + None + ); + }); +} + +/// Open one statement from `identity` carrying a single profile frame. +async fn open_profile_frame( + fixture: &Fixture, + actor: &Arc, + identity: &IdentityFixture, + peer: &DeviceFixture, + request_id: &str, + timestamp: u64, + reference: Option<&str>, +) { + let frame = wire::encode_profile_reference_message( + &format!("{request_id}-frame"), + timestamp, + "seity.dot", + reference, + ) + .unwrap(); + let plaintext = wire::encode_transport_request_plaintext(request_id, &[frame]).unwrap(); + let packet = native_packet(actor, identity, peer, &plaintext, false, false); + actor + .open_statement(&fixture.context, &NativeChatRegistry::default(), packet) + .await .unwrap(); - let plaintext = - wire::encode_transport_request_plaintext("incoming-withdrawal", &[withdrawal]).unwrap(); - let packet = native_packet(&actor, &identity, &peer, &plaintext, false, false); +} + +/// The reference the host holds for `identity`, a withdrawal reading as `None`. +async fn held_reference(fixture: &Fixture, identity: &IdentityFixture) -> Option { + crate::runtime::profile::received_reference( + fixture.platform.as_ref(), + profile::profile_owner(&fixture.context), + PRODUCT, + &identity.account, + ) + .await + .unwrap() + .and_then(|held| held.reference) +} + +#[test] +fn a_withdrawal_opened_before_an_older_disclosure_stays_withdrawn() { + block_on(async { + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + + // The product chooses the order it opens fetched statements in: here + // the contact's withdrawal first, then the disclosure it withdrew. + open_profile_frame( + &fixture, + &actor, + &identity, + &peer, + "withdrawal", + fixture.timestamp + 1, + None, + ) + .await; + open_profile_frame( + &fixture, + &actor, + &identity, + &peer, + "disclosure", + fixture.timestamp, + Some(PROFILE_REFERENCE), + ) + .await; + assert_eq!( + held_reference(&fixture, &identity).await, + None, + "the older disclosure cannot return" + ); + + let replacement = format!("{PROFILE_REFERENCE}ff"); + open_profile_frame( + &fixture, + &actor, + &identity, + &peer, + "replacement", + fixture.timestamp + 2, + Some(&replacement), + ) + .await; + assert_eq!( + held_reference(&fixture, &identity).await, + Some(replacement.clone()), + "a later one does" + ); + // Re-opening the withdrawal changes nothing either. + open_profile_frame( + &fixture, + &actor, + &identity, + &peer, + "withdrawal", + fixture.timestamp + 1, + None, + ) + .await; + assert_eq!(held_reference(&fixture, &identity).await, Some(replacement)); + }); +} + +fn filler(peer: [u8; 32], request_id: String, kind: OutgoingKind) -> Outgoing { + Outgoing { + peer, + request_id, + digest: [0; 32], + kind, + roster_revision: 1, + statement: signed_packet(&DeviceFixture::new(9), [9; 32], false, vec![1]), + last_attempt: 0, + } +} + +async fn disclose_for(fixture: &Fixture) { + crate::runtime::profile::write_disclosure( + fixture.platform.as_ref(), + profile::profile_owner(&fixture.context), + &crate::runtime::profile::Disclosure { + product_id: "seity.dot".into(), + reference: PROFILE_REFERENCE.into(), + }, + ) + .await + .unwrap(); +} + +#[test] +fn a_full_outbox_neither_blocks_profile_references_nor_is_blocked_by_them() { + block_on(async { + let fixture = Fixture::new(); + set_product_grants( + &fixture.platform, + PRODUCT, + truapi_platform::PermissionAuthorizationStatus::Authorized, + ) + .await; + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + seed_peer(&actor, &identity, &[&DeviceFixture::new(1)]).await; + disclose_for(&fixture).await; + let peer = identity.account; actor - .open_statement(&fixture.context, ®istry, packet) + .store + .update(move |state| { + for index in 0..MAX_OUTBOX { + state.queue(filler( + peer, + format!("rich-{index}"), + OutgoingKind::Rich([0; 32]), + ))?; + } + assert!(matches!( + state.queue(filler( + peer, + "rich-extra".into(), + OutgoingKind::Rich([0; 32]) + )), + Err(Error::StorageUnavailable) + )); + Ok(()) + }) .await .unwrap(); - assert_eq!( - received_reference(fixture.platform.as_ref(), PRODUCT, &identity.account) + assert!( + actor + .publish_profile_reference(&fixture.context) .await .unwrap(), - None + "user traffic filling the outbox does not stop the reference" + ); + + // References filling their own budget leave the rest to user traffic. + actor + .store + .update(move |state| { + state.outbox.retain(|entry| entry.request_id == "rich-0"); + for index in 0..MAX_PROFILE_OUTBOX { + state.queue(filler( + [index as u8; 32], + format!("profile-{index}"), + OutgoingKind::ProfileReference([0; 32]), + ))?; + } + state.queue(filler(peer, "rich-1".into(), OutgoingKind::Rich([0; 32]))) + }) + .await + .unwrap(); + }); +} + +#[test] +fn a_reference_with_no_outbox_room_waits_for_a_later_reconcile() { + block_on(async { + let fixture = Fixture::new(); + set_product_grants( + &fixture.platform, + PRODUCT, + truapi_platform::PermissionAuthorizationStatus::Authorized, + ) + .await; + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + seed_peer(&actor, &identity, &[&DeviceFixture::new(1)]).await; + disclose_for(&fixture).await; + let peer = identity.account; + // Stale entries for other identities hold every profile slot. + actor + .store + .update(move |state| { + state.outbox.extend((0..MAX_PROFILE_OUTBOX).map(|index| { + filler( + [index as u8; 32], + format!("stale-{index}"), + OutgoingKind::ProfileReference([0; 32]), + ) + })); + Ok(()) + }) + .await + .unwrap(); + + assert!( + !actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "no room is not a failure" + ); + assert!( + actor + .store + .read(move |state| state + .profile_shared + .iter() + .all(|watermark| watermark.peer != peer)) + .await + .unwrap(), + "the peer is left unsent" + ); + + actor + .store + .update(|state| { + state.outbox.clear(); + Ok(()) + }) + .await + .unwrap(); + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "a later reconcile sends it" + ); + }); +} + +#[test] +fn an_unacknowledged_reference_lapses_after_one_lifetime_without_resending() { + block_on(async { + let fixture = Fixture::new(); + set_product_grants( + &fixture.platform, + PRODUCT, + truapi_platform::PermissionAuthorizationStatus::Authorized, + ) + .await; + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + seed_peer(&actor, &identity, &[&DeviceFixture::new(1)]).await; + disclose_for(&fixture).await; + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + // A peer host that predates the content type never acknowledges it; + // the statement lifetime passes. + actor + .store + .update(|state| { + for entry in &mut state.outbox { + entry.statement.expiry = Some(1 << 32); + } + Ok(()) + }) + .await + .unwrap(); + + actor + .reconcile(&fixture.context, &NativeChatRegistry::default()) + .await + .unwrap(); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert!( + view.prepared.is_empty(), + "the lapsed reference is dropped, not re-signed" + ); + assert!( + !actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "and not queued again while the disclosure is unchanged" ); }); } diff --git a/rust/crates/truapi-server/src/runtime/profile.rs b/rust/crates/truapi-server/src/runtime/profile.rs index dc9e9de25..12a06ae9f 100644 --- a/rust/crates/truapi-server/src/runtime/profile.rs +++ b/rust/crates/truapi-server/src/runtime/profile.rs @@ -1,13 +1,41 @@ //! Profile disclosure state: the reference the user disclosed to their chat -//! contacts, and the references this product's contacts disclosed to them. +//! contacts, and the references their contacts disclosed to them. //! //! Both are bearer capabilities. They live in core storage, never in product //! storage, and never cross back to a product: `present_contact` names a -//! contact and the host substitutes the reference. +//! contact and the host substitutes the reference. Both belong to one wallet on +//! one Chat network, like the roster they travel over. use parity_scale_codec::{Decode, Encode}; use truapi_platform::{CoreStorage, CoreStorageKey}; +/// The wallet and Chat network a disclosure, and what contacts sent back, +/// belong to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ProfileOwner { + /// Root public key of the wallet. + pub(crate) root_public_key: [u8; 32], + /// Host-selected Chat network. + pub(crate) genesis_hash: [u8; 32], +} + +impl ProfileOwner { + fn disclosure_key(&self) -> CoreStorageKey { + CoreStorageKey::ProfileDisclosure { + root_public_key: self.root_public_key, + genesis_hash: self.genesis_hash, + } + } + + fn received_key(&self, product_id: &str) -> CoreStorageKey { + CoreStorageKey::ProfileReferencesReceived { + root_public_key: self.root_public_key, + genesis_hash: self.genesis_hash, + product_id: product_id.to_string(), + } + } +} + /// The user's own disclosed reference and the product that disclosed it. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub(crate) struct Disclosure { @@ -15,13 +43,18 @@ pub(crate) struct Disclosure { pub(crate) reference: String, } -/// One contact's disclosed reference, as their host sent it. +/// What one contact's host last sent. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub(crate) struct ReceivedReference { pub(crate) peer_identity: [u8; 32], /// The product on the contact's side that disclosed it. pub(crate) discloser_product_id: String, - pub(crate) reference: String, + /// Sender timestamp of the frame this reflects; only a later frame + /// replaces it. + pub(crate) timestamp: u64, + /// `None` once withdrawn. The withdrawal is kept, so an older disclosure + /// opened after it cannot bring the reference back. + pub(crate) reference: Option, } /// Versioned so the slot can change shape without a silent misread. @@ -40,9 +73,10 @@ fn storage_error(error: impl core::fmt::Debug) -> String { pub(crate) async fn read_disclosure( storage: &(impl CoreStorage + ?Sized), + owner: ProfileOwner, ) -> Result, String> { let Some(raw) = storage - .read_core_storage(CoreStorageKey::ProfileDisclosure) + .read_core_storage(owner.disclosure_key()) .await .map_err(storage_error)? else { @@ -55,30 +89,32 @@ pub(crate) async fn read_disclosure( pub(crate) async fn write_disclosure( storage: &(impl CoreStorage + ?Sized), + owner: ProfileOwner, disclosure: &Disclosure, ) -> Result<(), String> { storage - .write_core_storage(CoreStorageKey::ProfileDisclosure, disclosure.encode()) + .write_core_storage(owner.disclosure_key(), disclosure.encode()) .await .map_err(storage_error) } -pub(crate) async fn clear_disclosure(storage: &(impl CoreStorage + ?Sized)) -> Result<(), String> { +pub(crate) async fn clear_disclosure( + storage: &(impl CoreStorage + ?Sized), + owner: ProfileOwner, +) -> Result<(), String> { storage - .clear_core_storage(CoreStorageKey::ProfileDisclosure) + .clear_core_storage(owner.disclosure_key()) .await .map_err(storage_error) } async fn read_received( storage: &(impl CoreStorage + ?Sized), + owner: ProfileOwner, product_id: &str, ) -> Result, String> { - let key = CoreStorageKey::ProfileReferencesReceived { - product_id: product_id.to_string(), - }; let Some(raw) = storage - .read_core_storage(key) + .read_core_storage(owner.received_key(product_id)) .await .map_err(storage_error)? else { @@ -90,48 +126,54 @@ async fn read_received( } } -/// The reference a contact disclosed to this product's user, if any. +/// What a contact's host last sent this product's user, withdrawals included. pub(crate) async fn received_reference( storage: &(impl CoreStorage + ?Sized), + owner: ProfileOwner, product_id: &str, peer_identity: &[u8; 32], ) -> Result, String> { - Ok(read_received(storage, product_id) + Ok(read_received(storage, owner, product_id) .await? .into_iter() .find(|entry| &entry.peer_identity == peer_identity)) } -/// Record what a contact's host sent: the newest reference replaces the old -/// one, and `None` (a retraction) removes it. +/// Record a frame a contact's host sent, if it is newer than the one held: +/// a reference replaces the old one, and `None` withdraws it. A frame that is +/// not strictly newer is a replay or was overtaken, and changes nothing. pub(crate) async fn record_received_reference( storage: &(impl CoreStorage + ?Sized), + owner: ProfileOwner, product_id: &str, peer_identity: [u8; 32], discloser_product_id: String, + timestamp: u64, reference: Option, ) -> Result<(), String> { - let mut entries = read_received(storage, product_id).await?; - entries.retain(|entry| entry.peer_identity != peer_identity); - if let Some(reference) = reference { - if entries.len() >= MAX_RECEIVED_REFERENCES { + let mut entries = read_received(storage, owner, product_id).await?; + let received = ReceivedReference { + peer_identity, + discloser_product_id, + timestamp, + reference, + }; + match entries + .iter() + .position(|entry| entry.peer_identity == peer_identity) + { + Some(index) if entries[index].timestamp >= timestamp => return Ok(()), + Some(index) => entries[index] = received, + None if entries.len() >= MAX_RECEIVED_REFERENCES => { return Err("too many contact profile references".to_string()); } - entries.push(ReceivedReference { - peer_identity, - discloser_product_id, - reference, - }); - } - let key = CoreStorageKey::ProfileReferencesReceived { - product_id: product_id.to_string(), - }; - if entries.is_empty() { - storage.clear_core_storage(key).await.map_err(storage_error) - } else { - storage - .write_core_storage(key, StoredReferences::V1(entries).encode()) - .await - .map_err(storage_error) + None => entries.push(received), } + storage + .write_core_storage( + owner.received_key(product_id), + StoredReferences::V1(entries).encode(), + ) + .await + .map_err(storage_error) } diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index 391cc1226..995a0168c 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -1727,25 +1727,52 @@ fn present_contact( const CONTACTS_REFERENCE: &str = "seity-contacts:v1:5c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb535c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb53"; +const WALLET: [u8; 32] = [0x57; 32]; + +/// Sign `host` in as the wallet with root key `root_public_key`. +fn signed_in(host: ProductRuntimeHost, root_public_key: [u8; 32]) -> ProductRuntimeHost { + host.test_session_state() + .set_session(crate::host_logic::session::SessionInfo { + public_key: root_public_key, + ..session_info() + }); + host +} + +fn app_host(platform: &Arc, product_id: &str) -> ProductRuntimeHost { + signed_in( + profile_host_on( + platform.clone(), + ProductContext::new(product_id.to_string()).expect("valid product"), + None, + ), + WALLET, + ) +} + +fn owner_of(host: &ProductRuntimeHost) -> profile::ProfileOwner { + host.profile_owner().expect("signed in") +} + +fn consenting_platform() -> Arc { + Arc::new(StubPlatform { + profile_disclosure_confirmed: true, + ..Default::default() + }) +} + #[test] fn profile_disclose_stores_the_reference_and_only_its_discloser_may_retract_it() { - let platform = stub_platform(); - let seity = profile_host_on( - platform.clone(), - ProductContext::new("seity.dot".to_string()).expect("valid product"), - None, - ); - let other = profile_host_on( - platform.clone(), - ProductContext::new("other.dot".to_string()).expect("valid product"), - None, - ); + let platform = consenting_platform(); + let seity = app_host(&platform, "seity.dot"); + let other = app_host(&platform, "other.dot"); + let owner = owner_of(&seity); assert_eq!( disclose(&seity, CONTACTS_REFERENCE).expect("an App discloses a screened reference"), HostProfileDiscloseResponse::V1 ); - let stored = futures::executor::block_on(profile::read_disclosure(platform.as_ref())) + let stored = futures::executor::block_on(profile::read_disclosure(platform.as_ref(), owner)) .expect("readable") .expect("stored"); assert_eq!(stored.product_id, "seity.dot"); @@ -1762,7 +1789,8 @@ fn profile_disclose_stores_the_reference_and_only_its_discloser_may_retract_it() HostProfileRetractResponse::V1 ); assert_eq!( - futures::executor::block_on(profile::read_disclosure(platform.as_ref())).expect("readable"), + futures::executor::block_on(profile::read_disclosure(platform.as_ref(), owner)) + .expect("readable"), None ); assert_eq!( @@ -1772,16 +1800,71 @@ fn profile_disclose_stores_the_reference_and_only_its_discloser_may_retract_it() } #[test] -fn profile_disclose_is_for_apps_and_screened_references_only() { - let platform = stub_platform(); - let worker = profile_host_on( - platform.clone(), - ProductContext::new_with_execution( - "seity.dot".to_string(), - truapi_platform::ProductExecutionKind::Worker, - ) - .expect("valid product"), +fn profile_disclose_asks_once_per_product_and_a_refusal_stores_nothing() { + let platform = Arc::new(StubPlatform::default()); + // The first product is refused, the second allowed, each asked once. + platform + .permission_confirmation_decisions + .lock() + .expect("permission confirmation mutex poisoned") + .extend([ + truapi_platform::PermissionDecision::Deny, + truapi_platform::PermissionDecision::AllowAlways, + ]); + let refused = app_host(&platform, "refused.dot"); + let allowed = app_host(&platform, "seity.dot"); + let owner = owner_of(&refused); + let denied = + |result: Result>| { + matches!( + result, + Err(CallError::Domain(HostProfileDiscloseError::V1( + v01::HostProfileDiscloseError::PermissionDenied + ))) + ) + }; + + assert!(denied(disclose(&refused, CONTACTS_REFERENCE))); + assert!( + denied(disclose(&refused, CONTACTS_REFERENCE)), + "the refusal is remembered" + ); + assert_eq!( + futures::executor::block_on(profile::read_disclosure(platform.as_ref(), owner)) + .expect("readable"), None, + "a refused product discloses nothing" + ); + + disclose(&allowed, CONTACTS_REFERENCE).expect("the user allowed it"); + disclose(&allowed, CONTACTS_REFERENCE).expect("and is not asked again"); + assert_eq!( + platform + .profile_disclosure_reviews + .lock() + .expect("profile disclosure review list mutex poisoned") + .iter() + .map(|review| review.product_id.as_str()) + .collect::>(), + ["refused.dot", "seity.dot"], + "one prompt per product, naming it" + ); +} + +#[test] +fn profile_disclose_is_for_apps_and_screened_references_only() { + let platform = consenting_platform(); + let worker = signed_in( + profile_host_on( + platform.clone(), + ProductContext::new_with_execution( + "seity.dot".to_string(), + truapi_platform::ProductExecutionKind::Worker, + ) + .expect("valid product"), + None, + ), + WALLET, ); assert!(matches!( disclose(&worker, CONTACTS_REFERENCE), @@ -1789,11 +1872,7 @@ fn profile_disclose_is_for_apps_and_screened_references_only() { )); assert!(matches!(retract(&worker), Err(CallError::Denied))); - let app = profile_host_on( - platform.clone(), - ProductContext::new("seity.dot".to_string()).expect("valid product"), - None, - ); + let app = app_host(&platform, "seity.dot"); for rejected in [ String::new(), "a".repeat(2049), @@ -1807,32 +1886,124 @@ fn profile_disclose_is_for_apps_and_screened_references_only() { )); } assert_eq!( - futures::executor::block_on(profile::read_disclosure(platform.as_ref())).expect("readable"), + futures::executor::block_on(profile::read_disclosure(platform.as_ref(), owner_of(&app))) + .expect("readable"), None, "nothing unscreened is stored" ); + assert!( + platform + .profile_disclosure_reviews + .lock() + .expect("profile disclosure review list mutex poisoned") + .is_empty(), + "nor is the user asked about it" + ); + + let signed_out = profile_host_on( + platform.clone(), + ProductContext::new("seity.dot".to_string()).expect("valid product"), + None, + ); + assert!(matches!( + disclose(&signed_out, CONTACTS_REFERENCE), + Err(CallError::Domain(HostProfileDiscloseError::V1( + v01::HostProfileDiscloseError::NotConnected + ))) + )); + assert!(matches!( + retract(&signed_out), + Err(CallError::Domain(HostProfileRetractError::V1( + v01::HostProfileRetractError::NotConnected + ))) + )); } #[test] -fn profile_present_contact_substitutes_the_reference_the_contact_sent() { - let platform = stub_platform(); +fn profile_state_belongs_to_the_signed_in_wallet() { + let platform = consenting_platform(); let presented = Arc::new(RecordingProfilePlatform::default()); - let chat = profile_host_on( - platform.clone(), - ProductContext::new("egui-chat.dot".to_string()).expect("valid product"), - Some(presented.clone()), + let first = app_host(&platform, "seity.dot"); + let second = signed_in( + profile_host_on( + platform.clone(), + ProductContext::new("seity.dot".to_string()).expect("valid product"), + Some(presented.clone()), + ), + [0x58; 32], ); - let alice = [0xa1; 32]; - let bob = [0xb0; 32]; - // What the relay does when Alice's host sends her reference. + disclose(&first, CONTACTS_REFERENCE).expect("disclosed"); + assert_eq!( + futures::executor::block_on(profile::read_disclosure( + platform.as_ref(), + owner_of(&second) + )) + .expect("readable"), + None, + "another wallet has disclosed nothing" + ); + assert_eq!( + retract(&second).expect("nothing to retract"), + HostProfileRetractResponse::V1 + ); + assert!( + futures::executor::block_on(profile::read_disclosure( + platform.as_ref(), + owner_of(&first) + )) + .expect("readable") + .is_some(), + "and cannot withdraw the first wallet's" + ); + + // What one wallet's contact sent is not another wallet's. futures::executor::block_on(profile::record_received_reference( platform.as_ref(), - "egui-chat.dot", - alice, + owner_of(&first), + "seity.dot", + [0xa1; 32], "seity.dot".to_string(), + 1, Some(CONTACTS_REFERENCE.to_string()), )) .expect("recorded"); + assert!(matches!( + present_contact(&second, [0xa1; 32]), + Err(CallError::Domain(HostProfilePresentContactError::V1( + v01::HostProfilePresentContactError::NotShared + ))) + )); +} + +#[test] +fn profile_present_contact_substitutes_the_reference_the_contact_sent() { + let platform = stub_platform(); + let presented = Arc::new(RecordingProfilePlatform::default()); + let chat = signed_in( + profile_host_on( + platform.clone(), + ProductContext::new("egui-chat.dot".to_string()).expect("valid product"), + Some(presented.clone()), + ), + WALLET, + ); + let owner = owner_of(&chat); + let alice = [0xa1; 32]; + let bob = [0xb0; 32]; + let record = |timestamp, reference: Option<&str>| { + // What the relay does when Alice's host sends her a frame. + futures::executor::block_on(profile::record_received_reference( + platform.as_ref(), + owner, + "egui-chat.dot", + alice, + "seity.dot".to_string(), + timestamp, + reference.map(str::to_string), + )) + .expect("recorded"); + }; + record(1, Some(CONTACTS_REFERENCE)); assert_eq!( present_contact(&chat, alice).expect("a contact who shared is presented"), @@ -1855,10 +2026,13 @@ fn profile_present_contact_substitutes_the_reference_the_contact_sent() { )); // Another product's contacts are not this product's. - let other = profile_host_on( - platform.clone(), - ProductContext::new("other-chat.dot".to_string()).expect("valid product"), - Some(presented.clone()), + let other = signed_in( + profile_host_on( + platform.clone(), + ProductContext::new("other-chat.dot".to_string()).expect("valid product"), + Some(presented.clone()), + ), + WALLET, ); assert!(matches!( present_contact(&other, alice), @@ -1867,15 +2041,8 @@ fn profile_present_contact_substitutes_the_reference_the_contact_sent() { ))) )); - // A retraction from Alice's host removes what this host holds. - futures::executor::block_on(profile::record_received_reference( - platform.as_ref(), - "egui-chat.dot", - alice, - "seity.dot".to_string(), - None, - )) - .expect("recorded"); + // A retraction from Alice's host withdraws what this host holds. + record(2, None); assert!(matches!( present_contact(&chat, alice), Err(CallError::Domain(HostProfilePresentContactError::V1( @@ -1886,9 +2053,25 @@ fn profile_present_contact_substitutes_the_reference_the_contact_sent() { assert!(matches!( present_contact( &profile_host_on( - platform, + platform.clone(), ProductContext::new("egui-chat.dot".to_string()).expect("valid product"), - None, + Some(presented), + ), + alice, + ), + Err(CallError::Domain(HostProfilePresentContactError::V1( + v01::HostProfilePresentContactError::NotConnected + ))) + )); + assert!(matches!( + present_contact( + &signed_in( + profile_host_on( + platform, + ProductContext::new("egui-chat.dot".to_string()).expect("valid product"), + None, + ), + WALLET, ), alice, ), diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 1d7bf9a8e..712c89f2c 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -32,9 +32,9 @@ use truapi_platform::{ NativeChatFilesHost, NativeChatPickedFile, Navigation as PlatformNavigation, Notifications as PlatformNotifications, PairingHostConfig, Permissions as PlatformPermissions, PlatformInfo, PreimageHost, ProductContext, ProductOperations as PlatformProductOperations, - ProductStorage as PlatformProductStorage, ProductSubtreeReview, ResourceAllocationReview, - SignPayloadReview, SignRawReview, SignVrfReview, StatementStoreProductSignReview, ThemeHost, - UserConfirmation, UserConfirmationReview, + ProductStorage as PlatformProductStorage, ProductSubtreeReview, ProfileDisclosureReview, + ResourceAllocationReview, SignPayloadReview, SignRawReview, SignVrfReview, + StatementStoreProductSignReview, ThemeHost, UserConfirmation, UserConfirmationReview, }; use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519SecretKey}; @@ -114,6 +114,8 @@ pub(crate) struct StubPlatform { pub(crate) chat_authority_confirmed: bool, pub(crate) chat_authority_error: Option<&'static str>, pub(crate) chat_authority_reviews: Arc>>, + pub(crate) profile_disclosure_confirmed: bool, + pub(crate) profile_disclosure_reviews: Arc>>, /// One-shot payment decisions are independent of every reusable permission. /// The derived default denies spending. pub(crate) main_purse_chat_payment_confirmed: bool, @@ -2106,6 +2108,13 @@ impl UserConfirmation for StubPlatform { .push(review); (None, !self.product_subtree_denied) } + UserConfirmationReview::ProfileDisclosure(review) => { + self.profile_disclosure_reviews + .lock() + .expect("profile disclosure review list mutex poisoned") + .push(review); + (None, self.profile_disclosure_confirmed) + } }; if let Some(reason) = error { return Err(v01::GenericError { diff --git a/rust/crates/truapi/src/api/profile.rs b/rust/crates/truapi/src/api/profile.rs index bb09fa7eb..98054c36e 100644 --- a/rust/crates/truapi/src/api/profile.rs +++ b/rust/crates/truapi/src/api/profile.rs @@ -41,8 +41,10 @@ pub trait Profile: Send + Sync { /// /// The host stores it as the user's own and relays it to each contact, /// replacing whatever it sent before; the product never learns who they - /// are. App executions only. A reference this core cannot screen is - /// `InvalidReference`. + /// are. App executions only. The first disclosure asks the user once for + /// this product; a refusal, then or remembered, is `PermissionDenied`. A + /// reference this core cannot screen is `InvalidReference`, and with no + /// user signed in the call is `NotConnected`. /// /// ```ts /// const result = await truapi.profile.disclose({ diff --git a/rust/crates/truapi/src/v01/profile.rs b/rust/crates/truapi/src/v01/profile.rs index 50328696b..5b38f0f37 100644 --- a/rust/crates/truapi/src/v01/profile.rs +++ b/rust/crates/truapi/src/v01/profile.rs @@ -60,6 +60,11 @@ impl fmt::Debug for HostProfileDiscloseRequest { pub enum HostProfileDiscloseError { /// The reference is empty, too long, or not printable ASCII. InvalidReference, + /// The user declined to let this product disclose a profile to their + /// chat contacts. + PermissionDenied, + /// No user is signed in, so there are no contacts to disclose to. + NotConnected, /// Catch-all. Unknown { /// Human-readable reason. @@ -73,6 +78,8 @@ pub enum HostProfileDiscloseError { pub enum HostProfileRetractError { /// Another product disclosed the reference the host holds. NotDiscloser, + /// No user is signed in. + NotConnected, /// Catch-all. Unknown { /// Human-readable reason. @@ -100,6 +107,8 @@ pub enum HostProfilePresentContactError { NotShared, /// The host holds a reference it cannot parse. InvalidReference, + /// No user is signed in. + NotConnected, /// Catch-all. Unknown { /// Human-readable reason. From 911feab1c4e09fd5326a1f08567a570d178f697f Mon Sep 17 00:00:00 2001 From: w Date: Sun, 27 Sep 2026 23:06:52 -0400 Subject: [PATCH 08/30] feat(profile): draw placed contact avatars in host UI Add `profile.placeContactAvatars` (Profile method 4). A chat App tells the host where it draws contacts' avatars: its surface size and, per avatar, a slot id, the contact's peer identity, a square rect and the clip it is cut to. The host draws each sharing contact's photo and mood ring there on its own layer; taps still reach the product. - The core keeps only slots whose contact holds a current reference in the caller's ProfileReferencesReceived and hands them, with those references, to the new ProfilePlatform::place_contact_avatars. Its default draws nothing. - The answer never depends on who shared: any well-formed placement from a signed-in App is Ok, a host drawing failure is not reported, and nothing about slots is logged. Only the host's own Unsupported is passed on. More than 64 slots, a surface side outside 1..=16384, a non-square avatar or one outside 1..=1024 a side, and repeated slot ids are refused as Unknown. - The last placement is kept per product connection. A received reference or withdrawal that changes storage redraws it with the same geometry; disposing the connection, or placing after sign-out, clears what the host drew. --- .changeset/profile-disclose.md | 10 + docs/rfcs/profile-disclosure.md | 76 +++- js/packages/truapi-host/README.md | 11 +- js/packages/truapi-host/src/test-support.ts | 1 + rust/crates/truapi-client/src/generated.rs | 32 +- .../tests/golden/host-callbacks-adapter.ts | 7 + .../tests/golden/host-callbacks.ts | 93 +++++ .../tests/golden/wasm_bridge.rs | 22 +- .../tests/golden/worker-callbacks.ts | 8 +- rust/crates/truapi-platform/README.md | 8 +- rust/crates/truapi-platform/src/lib.rs | 78 +++- rust/crates/truapi-server/src/host_core.rs | 1 + rust/crates/truapi-server/src/runtime.rs | 62 ++- .../src/runtime/native_chat/actor/history.rs | 16 +- .../src/runtime/native_chat/actor/tests.rs | 94 +++++ .../truapi-server/src/runtime/profile.rs | 16 +- .../src/runtime/profile/avatars.rs | 274 +++++++++++++ .../truapi-server/src/runtime/services.rs | 4 + .../crates/truapi-server/src/runtime/tests.rs | 379 ++++++++++++++++++ rust/crates/truapi-server/src/test_support.rs | 48 +++ rust/crates/truapi/src/api/profile.rs | 56 ++- rust/crates/truapi/src/lib.rs | 41 +- rust/crates/truapi/src/v01/profile.rs | 65 +++ rust/crates/truapi/src/versioned/profile.rs | 3 + 24 files changed, 1350 insertions(+), 55 deletions(-) create mode 100644 rust/crates/truapi-server/src/runtime/profile/avatars.rs diff --git a/.changeset/profile-disclose.md b/.changeset/profile-disclose.md index 2cf5eab05..7b93511a0 100644 --- a/.changeset/profile-disclose.md +++ b/.changeset/profile-disclose.md @@ -14,3 +14,13 @@ message and keeps, per contact, the newest frame their host sent back, withdrawa product opens them in. Both live in wallet- and network-scoped core storage (`ProfileDisclosure`, `ProfileReferencesReceived`). Delivery is best effort: relayed references never take outbox room from other Chat traffic and are dropped, not re-signed, after one statement lifetime. + +Add `profile.placeContactAvatars`. A chat App tells the host where it draws contacts' avatars (surface size and, per +avatar, a slot id, peer identity, square rect and clip), and the host draws the photo and mood ring of each contact who +shared a profile with it on its own layer. The core filters the placement to contacts with a current reference, hands +them with their references to the new `ProfilePlatform.placeContactAvatars(product, placed)` callback, and +redraws the remembered placement when a reference arrives or is withdrawn; it clears it when the connection goes away. +The product is answered `Ok` whoever shared; only a malformed placement (more than 64 slots, a surface side outside 1 to +16384, a non-square avatar or one outside 1 to 1024 a side, a repeated slot) is refused, and a host that cannot draw +answers `Unsupported`. A JS host that supplies a `profile` group must implement the callback; the Rust trait's default +draws nothing. diff --git a/docs/rfcs/profile-disclosure.md b/docs/rfcs/profile-disclosure.md index 7d76d425f..7af52a032 100644 --- a/docs/rfcs/profile-disclosure.md +++ b/docs/rfcs/profile-disclosure.md @@ -11,7 +11,8 @@ status: draft A product hands the host one opaque profile reference for the user's chat contacts. The host relays it to each contact over Chat v2 and keeps the references contacts relay back. A chat product then asks the host to show a contact's profile by naming the contact, and the host presents the reference that contact disclosed through the existing `profile.present` -path. No product holds another user's reference. +path. A chat product may also tell the host where it draws contacts' avatars, and the host draws each sharing contact's +photo and mood ring there on its own layer. No product holds another user's reference, and none learns who shared one. ## Motivation @@ -27,16 +28,18 @@ the hosts, and Chat v2 leaves ordinary delivery to products. - **Bound:** a presented profile is the one that contact's host sent, not one a product chose. - **Stable:** a change to the referenced profile does not require relaying again. - **Withdrawable:** the discloser can retract, and contacts drop what they hold. +- **Unobservable:** a product that shows contacts' avatars cannot tell which contacts shared a profile. ## Approach -The design has five parts: +The design has six parts: -- The `Profile` trait gains `disclose`, `retract` and `present_contact`. +- The `Profile` trait gains `disclose`, `retract`, `present_contact` and `place_contact_avatars`. - `disclose` asks the user once per product before anything is stored. - Core storage holds the user's disclosure and the references received per chat product. - The Chat v2 actor relays disclosures through its host-private outbox. - `present_contact` substitutes the stored reference into `present`. +- `place_contact_avatars` substitutes stored references into a host-drawn avatar layer. ### Trait @@ -83,6 +86,19 @@ pub trait Profile: Send + Sync { ) -> Result> { Err(CallError::unavailable()) } + + /// Say where this product draws contacts' avatars. App executions only. + #[wire(id = 4)] + async fn place_contact_avatars( + &self, + _cx: &CallContext, + _request: HostProfilePlaceContactAvatarsRequest, + ) -> Result< + HostProfilePlaceContactAvatarsResponse, + CallError, + > { + Err(CallError::unavailable()) + } } pub struct HostProfileDiscloseRequest { @@ -121,6 +137,32 @@ pub enum HostProfilePresentContactError { /// Catch-all. Unknown { reason: String }, } +pub struct HostProfilePlaceContactAvatarsRequest { + /// Surface size, in the units of every rect: framebuffer pixels for a PolkaVM product, + /// CSS pixels of the viewport for a web product. 1 to 16384 a side. + pub surface_width: u32, + pub surface_height: u32, + /// Replaces the product's previous placement; empty clears it. At most 64. + pub slots: Vec, +} +pub struct ContactAvatarSlot { + /// Product-chosen id, unique in the placement and stable for one on-screen avatar. + pub slot: u32, + pub peer_identity: [u8; 32], + /// The avatar circle's bounding box: square, 1 to 1024 a side. + pub rect: AvatarRect, + /// Visible region the avatar is cut to. + pub clip: AvatarRect, +} +pub struct AvatarRect { pub x: i32, pub y: i32, pub width: u32, pub height: u32 } +pub enum HostProfilePlaceContactAvatarsError { + /// The host cannot draw over the product. + Unsupported, + /// No user is signed in. + NotConnected, + /// Catch-all, including a malformed placement. + Unknown { reason: String }, +} ``` ### Consent @@ -167,12 +209,40 @@ registry slot, keeps working when the record changes, so a relay happens only wh `present_contact` looks up the caller's received reference for the named peer, screens it again, and hands it to `ProfilePlatform::present_profile`. Host adapters are unchanged: they see a `present` whichever method produced it. +### Placed avatars + +A chat product draws its own conversation list and header, so only it knows where each contact's avatar sits. It sends +`place_contact_avatars` with its surface size and, per avatar, a slot id, the contact's peer identity, the circle's +square bounding box and the region it is cut to, in surface units. Each call replaces the product's placement. + +The core keeps only the slots whose contact holds a current reference in the caller's `ProfileReferencesReceived`, +withdrawals excluded, and hands them with those references to `ProfilePlatform::place_contact_avatars(product, +PlacedAvatars { surface_width, surface_height, avatars })`. The host draws each contact's photo and mood ring, when +they have one, on a layer over the product that lets pointer input through; a tap still reaches the product, which +opens the profile with `present_contact`. The default callback draws nothing, so a host draws avatars only once it +implements it. + +The core remembers the last placement per product connection, in memory. When a reference for that product arrives or +is withdrawn it filters the same geometry again and calls the host again, so avatars appear and disappear without the +product sending anything. Disposing the connection, or a placement made after the user signed out, clears what the host +drew. + +No leak: the product must not learn who shared a profile. The core answers `Ok` to any well-formed placement from a +signed-in user however many avatars, if any, are drawn; it returns nothing per slot, logs nothing about slots, and +treats a host drawing failure as success, since it could depend on which avatars were drawn. Only what the product +itself controls is refused: more than 64 slots, a surface side outside 1 to 16384, an avatar that is not square or is +outside 1 to 1024 a side, or a repeated slot id. The one host answer passed on is `Unsupported`, a property of the host +rather than of any contact. Nothing drawn is posted back to the product; the host renders it where the product cannot +read it. + ## Trade-offs - One reference for all contacts, so withdrawing it from one contact means rotating it for all of them. - A retraction cannot make a contact's host forget a reference it already resolved. - The watermark advances when the message is queued, so a message that never arrives, or that a peer's host does not acknowledge within one statement lifetime, is not resent until the disclosure changes. +- The host layer covers the product's own drawing, so a product that animates or scrolls between placements shows the + avatar a frame late; the product re-sends its placement when the list moves. - Dropped: carrying the reference in ordinary chat content, which puts a bearer capability in product hands. ## Open questions diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 7a36b5a0f..925ef2751 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -171,7 +171,7 @@ const callbacks: HostCallbacks = { chat, // optional: leave it out and chat products get `Unsupported` permissionStatus, // optional: reports live OS permission state pocket, // optional: serves the host's Pocket card collection - profile, // optional: shows product-referenced profiles in host UI + profile, // optional: shows profiles and draws contact avatars in host UI }; ``` @@ -188,6 +188,15 @@ when the user dismisses it. The reference is a bearer capability: the host fetch profile's bytes never return to the product. The core forwards only references that are non-empty, at most 2048 bytes and printable ASCII without whitespace; parsing the format is the host's. +`profile.placeContactAvatars(product, placed)` draws contacts' avatars over a chat product. `placed` carries the +product's surface size and, per avatar, the product's `slot` id, a square `rect`, the `clip` region it is cut to, all in +surface units (framebuffer pixels for a PolkaVM product, CSS pixels of the viewport for a web product), and the +`reference` that contact disclosed, so the host can draw their photo and mood ring. Each call replaces what was drawn +for the product; an empty `avatars` clears it. The core calls it again with the same geometry when a contact shares or +withdraws a profile, and with no avatars when the product's connection goes away. Draw on a layer the product cannot +read that lets pointer input through, and never tell the product what was drawn. The host runtimes take +`RequiredHostCallbacks`, so a `profile` group implements it alongside `presentProfile`. + `profile.disclose` needs no `profile` group, but the first call from a product asks the user through `userConfirmation.confirmPermission` with a `ProfileDisclosure` review naming that product: every Chat contact receives the reference. The answer is kept like any other permission, as `ProfileDisclosure`. A host that cannot render the diff --git a/js/packages/truapi-host/src/test-support.ts b/js/packages/truapi-host/src/test-support.ts index 37270a2a8..e12257f17 100644 --- a/js/packages/truapi-host/src/test-support.ts +++ b/js/packages/truapi-host/src/test-support.ts @@ -155,6 +155,7 @@ export function makeHostCallbacks( ? { profile: { presentProfile: async () => {}, + placeContactAvatars: async () => {}, ...overrides.profile, }, } diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index c52f883f7..f91243735 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "034025152ab6b451"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "bb70fece291da443"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -1735,6 +1735,33 @@ impl RequestMethod for ProfilePresentContact { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } +/// `profile_place_contact_avatars` method marker. +pub struct ProfilePlaceContactAvatars; +impl ProfilePlaceContactAvatars { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Profile", + method: "place_contact_avatars", + wire_name: "profile_place_contact_avatars", + request_type: "truapi::versioned::profile::HostProfilePlaceContactAvatarsRequest", + response_type: "truapi::versioned::profile::HostProfilePlaceContactAvatarsResponse", + error_type: Some("truapi::versioned::profile::HostProfilePlaceContactAvatarsError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 22, + method_id: 4, + }), + }; +} +impl RequestMethod for ProfilePlaceContactAvatars { + type Request = truapi::versioned::profile::HostProfilePlaceContactAvatarsRequest; + type Response = truapi::versioned::profile::HostProfilePlaceContactAvatarsResponse; + type Error = truapi::versioned::profile::HostProfilePlaceContactAvatarsError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + /// `renderer_render` method marker. pub struct RendererRender; impl RendererRender { @@ -2423,6 +2450,7 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ ProfileDisclose::DESCRIPTOR, ProfileRetract::DESCRIPTOR, ProfilePresentContact::DESCRIPTOR, + ProfilePlaceContactAvatars::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, SigningCreateTransaction::DESCRIPTOR, SigningCreateTransactionWithLegacyAccount::DESCRIPTOR, @@ -2503,6 +2531,7 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ ProfileDisclose::DESCRIPTOR, ProfileRetract::DESCRIPTOR, ProfilePresentContact::DESCRIPTOR, + ProfilePlaceContactAvatars::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, SigningCreateTransaction::DESCRIPTOR, SigningCreateTransactionWithLegacyAccount::DESCRIPTOR, @@ -2590,6 +2619,7 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ ProfileDisclose::DESCRIPTOR, ProfileRetract::DESCRIPTOR, ProfilePresentContact::DESCRIPTOR, + ProfilePlaceContactAvatars::DESCRIPTOR, RendererRender::DESCRIPTOR, RendererActionSubscribe::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts index 89261f7f3..3c5003677 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -40,6 +40,7 @@ import { NativeCoinageRequest, NativeCoinageResponse, PermissionDecision, + PlacedAvatars, ProductContext, UserConfirmationReview, } from "./host-callbacks.js"; @@ -149,6 +150,7 @@ export interface RawCallbacks { sendError: (error: GenericError) => void, ): (() => void) | void; presentProfile?(product: Uint8Array, request: Uint8Array): Promise; + placeContactAvatars?(product: Uint8Array, placed: Uint8Array): Promise; subscribeTheme( sendItem: (item?: Uint8Array) => void, sendError: (error: GenericError) => void, @@ -360,6 +362,11 @@ export function createWasmRawCallbacks( ProductContext.dec(product), HostProfilePresentRequest.dec(request), ), + placeContactAvatars: async (product, placed) => + await profile.placeContactAvatars( + ProductContext.dec(product), + PlacedAvatars.dec(placed), + ), } : {}), subscribeTheme: (sendItem, sendError) => diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index a01e77069..c6462d942 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -8,6 +8,7 @@ import * as S from "@parity/truapi/scale"; import { AllocatableResource, + AvatarRect, Bytes32, ChainIdentifier, DerivationIndex, @@ -778,6 +779,54 @@ export type PermissionAuthorizationStatus = */ export type PermissionDecision = "AllowOnce" | "AllowAlways" | "Deny"; +/** + * One avatar to draw over a product. + */ +export interface PlacedAvatar { + /** + * The product's id for this on-screen avatar, stable across updates. + */ + slot: number; + + /** + * Bounding box of the avatar circle, in surface units. + */ + rect: AvatarRect; + + /** + * Visible region the avatar is cut to, in surface units. + */ + clip: AvatarRect; + + /** + * The profile reference the contact disclosed. A bearer capability, as + * in `ProfilePlatform::present_profile`. + */ + reference: string; +} + +/** + * The avatars the core found drawable in one product's placement: the slots + * whose contact shared a profile with the user, each with the reference that + * contact disclosed. + */ +export interface PlacedAvatars { + /** + * Width of the product's surface, in the units of every rect. + */ + surfaceWidth: number; + + /** + * Height of the product's surface, in the same units. + */ + surfaceHeight: number; + + /** + * Avatars to draw, in the product's slot order. + */ + avatars: Array; +} + /** * Review shown before a preimage is submitted. */ @@ -1550,6 +1599,33 @@ export const PermissionDecision: S.Codec = S.lazy( S.Status("AllowOnce", "AllowAlways", "Deny"), ); +/** + * One avatar to draw over a product. + */ +export const PlacedAvatar: S.Codec = S.lazy( + (): S.Codec => + S.Struct({ + slot: S.u32, + rect: AvatarRect, + clip: AvatarRect, + reference: S.str, + }) as S.Codec, +); + +/** + * The avatars the core found drawable in one product's placement: the slots + * whose contact shared a profile with the user, each with the reference that + * contact disclosed. + */ +export const PlacedAvatars: S.Codec = S.lazy( + (): S.Codec => + S.Struct({ + surfaceWidth: S.u32, + surfaceHeight: S.u32, + avatars: S.Vector(PlacedAvatar), + }) as S.Codec, +); + /** * Review shown before a preimage is submitted. */ @@ -2362,6 +2438,23 @@ export interface ProfilePlatform { product: ProductContext, request: HostProfilePresentRequest, ): Promise; + + /** + * Draw the contact avatars a product placed, on the host's own layer over + * the product's surface, replacing what was drawn for it before; an empty + * `avatars` clears it. The layer must let pointer input through to the + * product and must never tell the product what it drew. + * + * The core calls this again, with the product's last geometry, whenever + * a contact on it shares or withdraws a profile, and with no avatars once + * the product's connection goes away. Answer `Unsupported` if this host + * cannot draw over the product; the product is told so. The default draws + * nothing. + */ + placeContactAvatars?( + product: ProductContext, + placed: PlacedAvatars, + ): Promise; } /** diff --git a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs index c6be6ed6b..1b08e2098 100644 --- a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs @@ -66,6 +66,7 @@ pub(super) struct JsBridge { pub(super) clear: Function, pub(super) subscribe_storage: Function, pub(super) present_profile: Function, + pub(super) place_contact_avatars: Function, pub(super) subscribe_theme: Function, pub(super) confirm_permission: Function, pub(super) confirm_user_action: Function, @@ -132,6 +133,8 @@ impl JsBridge { subscribe_storage: get_function(callbacks, "subscribeStorage")?, present_profile: get_optional_function(callbacks, "presentProfile")? .unwrap_or_else(|| missing_callback("presentProfile")), + place_contact_avatars: get_optional_function(callbacks, "placeContactAvatars")? + .unwrap_or_else(|| missing_callback("placeContactAvatars")), subscribe_theme: get_function(callbacks, "subscribeTheme")?, confirm_permission: get_function(callbacks, "confirmPermission")?, confirm_user_action: get_function(callbacks, "confirmUserAction")?, @@ -149,7 +152,8 @@ impl JsBridge { .is_some(), pocket_present: get_optional_function(callbacks, "subscribePocketCards")?.is_some() && get_optional_function(callbacks, "removePocketCard")?.is_some(), - profile_present: get_optional_function(callbacks, "presentProfile")?.is_some(), + profile_present: get_optional_function(callbacks, "presentProfile")?.is_some() + && get_optional_function(callbacks, "placeContactAvatars")?.is_some(), }) } @@ -744,6 +748,22 @@ impl truapi_platform::ProfilePlatform for WasmPlatform { .await .map_err(|reason| v01::HostProfilePresentError::Unknown { reason }) } + + async fn place_contact_avatars( + &self, + product: &truapi_platform::ProductContext, + placed: truapi_platform::PlacedAvatars, + ) -> Result<(), v01::HostProfilePlaceContactAvatarsError> { + invoke_unit( + &self.bridge.place_contact_avatars, + vec![ + Uint8Array::from(product.encode().as_slice()).into(), + Uint8Array::from(placed.encode().as_slice()).into(), + ], + ) + .await + .map_err(|reason| v01::HostProfilePlaceContactAvatarsError::Unknown { reason }) + } } impl truapi_platform::ThemeHost for WasmPlatform { diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index a701655fe..37e9812ac 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -43,6 +43,7 @@ export const CALLBACK_NAMES = [ "write", "clear", "presentProfile", + "placeContactAvatars", "confirmPermission", "confirmUserAction", ] as const; @@ -330,13 +331,18 @@ function pocketRawCallbacks( function profileRawCallbacks( bridge: WorkerCallbackBridge, -): Required> { +): Required> { return { presentProfile: (product, request) => bridge.callbackRequest("presentProfile", [ product, request, ]) as ReturnType["presentProfile"]>, + placeContactAvatars: (product, placed) => + bridge.callbackRequest("placeContactAvatars", [ + product, + placed, + ]) as ReturnType["placeContactAvatars"]>, }; } diff --git a/rust/crates/truapi-platform/README.md b/rust/crates/truapi-platform/README.md index 7099363e9..59de98454 100644 --- a/rust/crates/truapi-platform/README.md +++ b/rust/crates/truapi-platform/README.md @@ -59,9 +59,11 @@ revokes the grant. - `PocketPlatform`: stream the product's Pocket card collection and remove a card from it. The host owns the collection and decides which cards are privileged. -- `ProfilePlatform`: show a product-referenced profile in host-owned UI. The - host resolves, decrypts and renders the reference; nothing returns to the - product but acceptance. +- `ProfilePlatform`: show a product-referenced profile in host-owned UI, and + draw the avatars of contacts who shared one over a chat product. The host + resolves, decrypts and renders each reference; nothing returns to the + product but acceptance. Drawing avatars is optional and draws nothing by + default. `Platform` is a blanket-implemented supertrait that combines the capability traits above except `ChatPlatform`, `PermissionStatusHost`, `PocketPlatform` diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 3484d3624..3c4f66a43 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -35,21 +35,22 @@ use truapi::Bytes32; pub mod mock; use truapi::latest::{ - AllocatableResource, ChainIdentifier, ChatAction, ChatActions, ChatCustomMessage, ChatFile, - ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, DerivationIndex, GenericError, - HostChatCreateRoomError, HostChatCreateRoomRequest, HostChatCreateRoomResponse, + AllocatableResource, AvatarRect, ChainIdentifier, ChatAction, ChatActions, ChatCustomMessage, + ChatFile, ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, DerivationIndex, + GenericError, HostChatCreateRoomError, HostChatCreateRoomRequest, HostChatCreateRoomResponse, HostChatListSubscribeItem, HostChatPostMessageError, HostChatPostMessageRequest, HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, HostChatRegisterBotResponse, HostDevicePermissionRequest, HostFeatureSupportedRequest, HostFeatureSupportedResponse, HostLocalStorageChangeItem, HostLocaleSubscribeItem, HostNativeChatAttachmentMetadata, HostNavigateToError, HostPlatform, HostPocketListSubscribeItem, HostPocketRemoveCardError, HostPocketRemoveCardRequest, - HostProfilePresentError, HostProfilePresentRequest, HostPushNotificationRequest, - HostPushNotificationResponse, HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, - HostSignRawRequest, HostSignRawWithLegacyAccountRequest, HostThemeSubscribeItem, - HostWorkerBeginOperationResponse, HostWorkerOperationError, LegacyAccountTxPayload, - NotificationId, ProductAccountId, ProductAccountTxPayload, ProductProofContext, - RemotePermission, RemotePermissionRequest, RingLocation, + HostProfilePlaceContactAvatarsError, HostProfilePresentError, HostProfilePresentRequest, + HostPushNotificationRequest, HostPushNotificationResponse, HostSignPayloadRequest, + HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, + HostSignRawWithLegacyAccountRequest, HostThemeSubscribeItem, HostWorkerBeginOperationResponse, + HostWorkerOperationError, LegacyAccountTxPayload, NotificationId, ProductAccountId, + ProductAccountTxPayload, ProductProofContext, RemotePermission, RemotePermissionRequest, + RingLocation, }; use truapi::v01::HostAccountSignVrfRequest; use url::{Host, Url}; @@ -3935,6 +3936,65 @@ pub trait ProfilePlatform: Send + Sync { product: &ProductContext, request: HostProfilePresentRequest, ) -> Result<(), HostProfilePresentError>; + + /// Draw the contact avatars a product placed, on the host's own layer over + /// the product's surface, replacing what was drawn for it before; an empty + /// `avatars` clears it. The layer must let pointer input through to the + /// product and must never tell the product what it drew. + /// + /// The core calls this again, with the product's last geometry, whenever + /// a contact on it shares or withdraws a profile, and with no avatars once + /// the product's connection goes away. Answer `Unsupported` if this host + /// cannot draw over the product; the product is told so. The default draws + /// nothing. + async fn place_contact_avatars( + &self, + product: &ProductContext, + placed: PlacedAvatars, + ) -> Result<(), HostProfilePlaceContactAvatarsError> { + let _ = (product, placed); + Ok(()) + } +} + +/// The avatars the core found drawable in one product's placement: the slots +/// whose contact shared a profile with the user, each with the reference that +/// contact disclosed. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct PlacedAvatars { + /// Width of the product's surface, in the units of every rect. + pub surface_width: u32, + /// Height of the product's surface, in the same units. + pub surface_height: u32, + /// Avatars to draw, in the product's slot order. + pub avatars: Vec, +} + +/// One avatar to draw over a product. +#[derive(Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct PlacedAvatar { + /// The product's id for this on-screen avatar, stable across updates. + pub slot: u32, + /// Bounding box of the avatar circle, in surface units. + pub rect: AvatarRect, + /// Visible region the avatar is cut to, in surface units. + pub clip: AvatarRect, + /// The profile reference the contact disclosed. A bearer capability, as + /// in [`ProfilePlatform::present_profile`]. + pub reference: String, +} + +impl core::fmt::Debug for PlacedAvatar { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("PlacedAvatar") + .field("slot", &self.slot) + .field("rect", &self.rect) + .field("clip", &self.clip) + .field("reference", &"[REDACTED]") + .finish() + } } /// What the operating system currently says about a device capability. diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index ce143eab9..5455ce755 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -1750,6 +1750,7 @@ impl ProductRuntime { self.admin.product_runtime.detach_chat(); self.admin.product_runtime.detach_renderer(); self.admin.product_runtime.release_open_operations(); + self.admin.product_runtime.release_contact_avatars(); self.host_subscriptions.close(); self.core.cancel_subscriptions(); } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index c808e9df8..c17610c6d 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -100,10 +100,11 @@ use truapi::versioned::pocket::{ use truapi::versioned::preimage::RemotePreimageSubmitError; use truapi::versioned::profile::{ HostProfileDiscloseError, HostProfileDiscloseRequest, HostProfileDiscloseResponse, - HostProfilePresentContactError, HostProfilePresentContactRequest, - HostProfilePresentContactResponse, HostProfilePresentError, HostProfilePresentRequest, - HostProfilePresentResponse, HostProfileRetractError, HostProfileRetractRequest, - HostProfileRetractResponse, + HostProfilePlaceContactAvatarsError, HostProfilePlaceContactAvatarsRequest, + HostProfilePlaceContactAvatarsResponse, HostProfilePresentContactError, + HostProfilePresentContactRequest, HostProfilePresentContactResponse, HostProfilePresentError, + HostProfilePresentRequest, HostProfilePresentResponse, HostProfileRetractError, + HostProfileRetractRequest, HostProfileRetractResponse, }; use truapi::versioned::renderer::{ HostRendererActionSubscribeError, HostRendererActionSubscribeItem, @@ -334,6 +335,7 @@ pub struct ProductRuntimeHost { impl Drop for ProductRuntimeHost { fn drop(&mut self) { self.release_open_operations(); + self.release_contact_avatars(); } } @@ -1215,6 +1217,14 @@ impl ProductRuntimeHost { })); } + /// Clear the contact avatars the host drew for this connection and stop + /// redrawing them. + pub(crate) fn release_contact_avatars(&self) { + self.services + .contact_avatars + .release(self.core_instance, &self.services.spawner); + } + /// Drop the worker reference a pending operation held. An id that is not /// open releases nothing, which is what keeps `end_operation` idempotent. pub(crate) fn release_worker_for_operation(&self, id: u32) { @@ -1599,6 +1609,50 @@ impl Profile for ProductRuntimeHost { }) }) } + + #[instrument(skip_all, fields(runtime.method = "profile.place_contact_avatars"))] + async fn place_contact_avatars( + &self, + _cx: &CallContext, + request: HostProfilePlaceContactAvatarsRequest, + ) -> Result< + HostProfilePlaceContactAvatarsResponse, + CallError, + > { + // Contacts' avatars are drawn over what the user is looking at, an + // App, not a background Worker. + if self.product.execution_kind != truapi_platform::ProductExecutionKind::App { + return Err(CallError::Denied); + } + let platform = self.profile_platform()?; + let HostProfilePlaceContactAvatarsRequest::V1(request) = request; + let domain = |error| CallError::Domain(HostProfilePlaceContactAvatarsError::V1(error)); + profile::avatars::validate(&request).map_err(|reason| { + domain(v01::HostProfilePlaceContactAvatarsError::Unknown { reason }) + })?; + let placement = self + .services + .contact_avatars + .for_runtime(self.core_instance, || { + profile::avatars::ContactAvatarPlacement::new( + platform, + self.platform.clone(), + self.product.clone(), + ) + }); + let Some(owner) = self.profile_owner() else { + // Avatars drawn for a wallet that signed out come down with it. + placement.clear().await; + return Err(domain( + v01::HostProfilePlaceContactAvatarsError::NotConnected, + )); + }; + placement + .place(owner, request) + .await + .map(|()| HostProfilePlaceContactAvatarsResponse::V1) + .map_err(domain) + } } fn is_screened_profile_reference(reference: &str) -> bool { diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs index 649f52abd..40a76324a 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs @@ -386,17 +386,20 @@ impl NativeChatActor { /// Keep the newest profile reference each frame carries for `peer`, in /// this product's received-reference slot. `None` withdraws it, and a - /// frame older than the one held changes nothing. + /// frame older than the one held changes nothing. Contact avatars placed + /// over this product are redrawn when anything changed. async fn record_profile_references( &self, context: &NativeChatContext, peer: [u8; 32], frames: Vec, ) -> Result<(), Error> { + let owner = super::profile::profile_owner(context); + let mut changed = false; for frame in frames { - crate::runtime::profile::record_received_reference( + changed |= crate::runtime::profile::record_received_reference( &*context.services.platform, - super::profile::profile_owner(context), + owner, &self.product, peer, frame.discloser_product_id, @@ -406,6 +409,13 @@ impl NativeChatActor { .await .map_err(|_| Error::StorageUnavailable)?; } + if changed { + context.services.contact_avatars.redraw( + owner, + &self.product, + &context.services.spawner, + ); + } Ok(()) } diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs index 7e827a897..93ad1ba4c 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs @@ -1813,6 +1813,100 @@ fn a_received_profile_reference_is_kept_by_the_host_and_cut_from_what_the_produc }); } +#[test] +fn contact_avatars_over_the_product_follow_what_the_contact_shares() { + block_on(async { + use crate::runtime::profile::avatars::ContactAvatarPlacement; + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + let host = Arc::new(crate::test_support::RecordingAvatarHost::default()); + let placement = fixture.context.services.contact_avatars.for_runtime(1, || { + ContactAvatarPlacement::new( + host.clone(), + fixture.platform.clone(), + truapi_platform::ProductContext::new(PRODUCT.to_string()).unwrap(), + ) + }); + let rect = truapi::v01::AvatarRect { + x: 16, + y: 80, + width: 44, + height: 44, + }; + let clip = truapi::v01::AvatarRect { + x: 0, + y: 64, + width: 360, + height: 576, + }; + placement + .place( + profile::profile_owner(&fixture.context), + truapi::v01::HostProfilePlaceContactAvatarsRequest { + surface_width: 360, + surface_height: 640, + slots: vec![truapi::v01::ContactAvatarSlot { + slot: 7, + peer_identity: identity.account, + rect, + clip, + }], + }, + ) + .await + .unwrap(); + let placed = |avatars| { + ( + PRODUCT.to_string(), + truapi_platform::PlacedAvatars { + surface_width: 360, + surface_height: 640, + avatars, + }, + ) + }; + + // The product placed the avatar once, before the contact shared; the + // host redraws it when the reference arrives and when it is withdrawn. + open_profile_frame( + &fixture, + &actor, + &identity, + &peer, + "incoming-profile", + fixture.timestamp, + Some(PROFILE_REFERENCE), + ) + .await; + assert_eq!( + host.wait_for(2), + vec![ + placed(Vec::new()), + placed(vec![truapi_platform::PlacedAvatar { + slot: 7, + rect, + clip, + reference: PROFILE_REFERENCE.to_string(), + }]), + ] + ); + open_profile_frame( + &fixture, + &actor, + &identity, + &peer, + "incoming-withdrawal", + fixture.timestamp + 1, + None, + ) + .await; + assert_eq!(host.wait_for(3)[2], placed(Vec::new())); + }); +} + /// Open one statement from `identity` carrying a single profile frame. async fn open_profile_frame( fixture: &Fixture, diff --git a/rust/crates/truapi-server/src/runtime/profile.rs b/rust/crates/truapi-server/src/runtime/profile.rs index 12a06ae9f..e15ad492c 100644 --- a/rust/crates/truapi-server/src/runtime/profile.rs +++ b/rust/crates/truapi-server/src/runtime/profile.rs @@ -2,9 +2,11 @@ //! contacts, and the references their contacts disclosed to them. //! //! Both are bearer capabilities. They live in core storage, never in product -//! storage, and never cross back to a product: `present_contact` names a -//! contact and the host substitutes the reference. Both belong to one wallet on -//! one Chat network, like the roster they travel over. +//! storage, and never cross back to a product: `present_contact` and placed +//! contact avatars name a contact and the host substitutes the reference. Both +//! belong to one wallet on one Chat network, like the roster they travel over. + +pub(crate) mod avatars; use parity_scale_codec::{Decode, Encode}; use truapi_platform::{CoreStorage, CoreStorageKey}; @@ -142,6 +144,7 @@ pub(crate) async fn received_reference( /// Record a frame a contact's host sent, if it is newer than the one held: /// a reference replaces the old one, and `None` withdraws it. A frame that is /// not strictly newer is a replay or was overtaken, and changes nothing. +/// `true` when the frame was kept. pub(crate) async fn record_received_reference( storage: &(impl CoreStorage + ?Sized), owner: ProfileOwner, @@ -150,7 +153,7 @@ pub(crate) async fn record_received_reference( discloser_product_id: String, timestamp: u64, reference: Option, -) -> Result<(), String> { +) -> Result { let mut entries = read_received(storage, owner, product_id).await?; let received = ReceivedReference { peer_identity, @@ -162,7 +165,7 @@ pub(crate) async fn record_received_reference( .iter() .position(|entry| entry.peer_identity == peer_identity) { - Some(index) if entries[index].timestamp >= timestamp => return Ok(()), + Some(index) if entries[index].timestamp >= timestamp => return Ok(false), Some(index) => entries[index] = received, None if entries.len() >= MAX_RECEIVED_REFERENCES => { return Err("too many contact profile references".to_string()); @@ -175,5 +178,6 @@ pub(crate) async fn record_received_reference( StoredReferences::V1(entries).encode(), ) .await - .map_err(storage_error) + .map_err(storage_error)?; + Ok(true) } diff --git a/rust/crates/truapi-server/src/runtime/profile/avatars.rs b/rust/crates/truapi-server/src/runtime/profile/avatars.rs new file mode 100644 index 000000000..4d309eea4 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/profile/avatars.rs @@ -0,0 +1,274 @@ +//! Contact avatars the host draws over a chat product. +//! +//! A product says where it draws each contact's avatar, by peer identity. The +//! core fills in the reference each contact shared and hands the host only the +//! avatars it can draw. The product gets the same answer whoever shared, and +//! nothing about a slot is logged, so it cannot learn who shared a profile. +//! +//! The placement is kept per product connection, so a contact who shares or +//! withdraws later appears or disappears without the product sending it again. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use tracing::debug; +use truapi::v01; +use truapi_platform::{PlacedAvatar, PlacedAvatars, Platform, ProductContext, ProfilePlatform}; + +use super::{ProfileOwner, read_received}; +use crate::runtime::is_screened_profile_reference; +use crate::subscription::Spawner; + +/// Most avatars one placement may hold: a screenful of list rows and a header. +const MAX_SLOTS: usize = 64; +/// Longest surface side, in surface units. +const MAX_SURFACE_SIDE: u32 = 16384; +/// Longest avatar side, in surface units. +const MAX_AVATAR_SIDE: u32 = 1024; + +/// Why a placement is malformed, if it is. Only input the product controls is +/// judged here, never what any contact shared. +pub(crate) fn validate(request: &v01::HostProfilePlaceContactAvatarsRequest) -> Result<(), String> { + let surface = 1..=MAX_SURFACE_SIDE; + if !surface.contains(&request.surface_width) || !surface.contains(&request.surface_height) { + return Err(format!("surface sides must be 1 to {MAX_SURFACE_SIDE}")); + } + if request.slots.len() > MAX_SLOTS { + return Err(format!("at most {MAX_SLOTS} contact avatars may be placed")); + } + let mut seen = HashSet::with_capacity(request.slots.len()); + for slot in &request.slots { + let rect = slot.rect; + if rect.width != rect.height || !(1..=MAX_AVATAR_SIDE).contains(&rect.width) { + return Err(format!( + "avatar {} must be square and 1 to {MAX_AVATAR_SIDE} a side", + slot.slot + )); + } + if !seen.insert(slot.slot) { + return Err(format!("avatar slot {} is placed twice", slot.slot)); + } + } + Ok(()) +} + +/// One product connection's placement. +pub(crate) struct ContactAvatarPlacement { + platform: Arc, + storage: Arc, + product: ProductContext, + /// Held across each draw, so the host sees the connection's placements in + /// the order they were made. + state: futures::lock::Mutex, +} + +#[derive(Default)] +struct PlacementState { + /// The last non-empty placement and the wallet it was drawn for. + placed: Option<(ProfileOwner, v01::HostProfilePlaceContactAvatarsRequest)>, + /// The connection is gone; nothing is drawn for it again. + closed: bool, +} + +impl ContactAvatarPlacement { + pub(crate) fn new( + platform: Arc, + storage: Arc, + product: ProductContext, + ) -> Self { + Self { + platform, + storage, + product, + state: futures::lock::Mutex::new(PlacementState::default()), + } + } + + /// Replace the placement and draw it for `owner`. Only the host's own + /// `Unsupported` and an unreadable store fail; whatever was drawn, the + /// answer is `Ok`. + pub(crate) async fn place( + &self, + owner: ProfileOwner, + request: v01::HostProfilePlaceContactAvatarsRequest, + ) -> Result<(), v01::HostProfilePlaceContactAvatarsError> { + let mut state = self.state.lock().await; + if state.closed { + return Ok(()); + } + state.placed = None; + self.draw(owner, &request).await?; + if !request.slots.is_empty() { + state.placed = Some((owner, request)); + } + Ok(()) + } + + /// Forget the placement and clear what the host drew for it. + pub(crate) async fn clear(&self) { + let mut state = self.state.lock().await; + self.clear_drawn(&mut state).await; + } + + /// Clear what the host drew and draw nothing for this connection again. + async fn close(&self) { + let mut state = self.state.lock().await; + state.closed = true; + self.clear_drawn(&mut state).await; + } + + async fn clear_drawn(&self, state: &mut PlacementState) { + let Some((_, request)) = state.placed.take() else { + return; + }; + let cleared = PlacedAvatars { + surface_width: request.surface_width, + surface_height: request.surface_height, + avatars: Vec::new(), + }; + if let Err(error) = self + .platform + .place_contact_avatars(&self.product, cleared) + .await + { + debug!(?error, "host could not clear contact avatars"); + } + } + + /// Draw the placement again after what `owner`'s contacts shared changed. + async fn redraw(&self, owner: ProfileOwner) { + let state = self.state.lock().await; + let Some((placed_for, request)) = state.placed.as_ref() else { + return; + }; + if *placed_for != owner { + return; + } + if let Err(error) = self.draw(owner, request).await { + debug!(?error, "contact avatars were not redrawn"); + } + } + + async fn draw( + &self, + owner: ProfileOwner, + request: &v01::HostProfilePlaceContactAvatarsRequest, + ) -> Result<(), v01::HostProfilePlaceContactAvatarsError> { + let avatars = self + .drawable(owner, &request.slots) + .await + .map_err(|reason| v01::HostProfilePlaceContactAvatarsError::Unknown { reason })?; + let placed = PlacedAvatars { + surface_width: request.surface_width, + surface_height: request.surface_height, + avatars, + }; + match self + .platform + .place_contact_avatars(&self.product, placed) + .await + { + Ok(()) => Ok(()), + Err(v01::HostProfilePlaceContactAvatarsError::Unsupported) => { + Err(v01::HostProfilePlaceContactAvatarsError::Unsupported) + } + // Any other host failure could depend on which avatars it was + // given, so the product is not told of it. + Err(error) => { + debug!(?error, "host could not draw contact avatars"); + Ok(()) + } + } + } + + /// The slots whose contact currently shares a profile with this product's + /// user, each with that contact's reference. + async fn drawable( + &self, + owner: ProfileOwner, + slots: &[v01::ContactAvatarSlot], + ) -> Result, String> { + if slots.is_empty() { + return Ok(Vec::new()); + } + let shared: HashMap<[u8; 32], String> = + read_received(self.storage.as_ref(), owner, &self.product.product_id) + .await? + .into_iter() + .filter_map(|received| { + let reference = received.reference?; + is_screened_profile_reference(&reference) + .then_some((received.peer_identity, reference)) + }) + .collect(); + Ok(slots + .iter() + .filter_map(|slot| { + shared + .get(&slot.peer_identity) + .map(|reference| PlacedAvatar { + slot: slot.slot, + rect: slot.rect, + clip: slot.clip, + reference: reference.clone(), + }) + }) + .collect()) + } +} + +/// Every live product connection's placement, by product runtime. +#[derive(Default)] +pub(crate) struct ContactAvatarPlacements { + by_runtime: Mutex>>, +} + +impl ContactAvatarPlacements { + /// The placement of product runtime `runtime`, made on first use. + pub(crate) fn for_runtime( + &self, + runtime: u64, + make: impl FnOnce() -> ContactAvatarPlacement, + ) -> Arc { + self.by_runtime + .lock() + .expect("contact avatar placements mutex poisoned") + .entry(runtime) + .or_insert_with(|| Arc::new(make())) + .clone() + } + + /// Clear what the host drew for product runtime `runtime` and forget it. + pub(crate) fn release(&self, runtime: u64, spawner: &Spawner) { + let Some(placement) = self + .by_runtime + .lock() + .expect("contact avatar placements mutex poisoned") + .remove(&runtime) + else { + return; + }; + spawner(Box::pin(async move { placement.close().await })); + } + + /// Redraw every placement `product_id` holds for `owner`, after what that + /// product's contacts shared changed. + pub(crate) fn redraw(&self, owner: ProfileOwner, product_id: &str, spawner: &Spawner) { + let placements = self + .by_runtime + .lock() + .expect("contact avatar placements mutex poisoned") + .values() + .filter(|placement| placement.product.product_id == product_id) + .cloned() + .collect::>(); + if placements.is_empty() { + return; + } + spawner(Box::pin(async move { + for placement in placements { + placement.redraw(owner).await; + } + })); + } +} diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index fe6cea4d7..ce7757ba9 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -50,6 +50,9 @@ pub(crate) struct RuntimeServices { /// Host profile presenter, installed once at startup by a host that can /// render profiles. Unset leaves every product Profile call `Unsupported`. profile_platform: OnceLock>, + /// Where each live product connection draws contact avatars, so they can + /// be redrawn when what a contact shared changes. + pub(crate) contact_avatars: crate::runtime::profile::avatars::ContactAvatarPlacements, /// Optional native authenticated username index; only supplies candidates. identity_backend: OnceLock>, /// Host observer told when a device finishes pairing with this signing @@ -141,6 +144,7 @@ impl RuntimeServices { permission_status: OnceLock::new(), pocket_platform: OnceLock::new(), profile_platform: OnceLock::new(), + contact_avatars: Default::default(), identity_backend: OnceLock::new(), device_pairing_observer: OnceLock::new(), asset_hub_chain_genesis_hash, diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index 995a0168c..9afbd15ab 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -2079,6 +2079,385 @@ fn profile_present_contact_substitutes_the_reference_the_contact_sent() { )); } +/// A connection from `product` on `platform`, with `avatars` as the host's +/// profile adapter. +fn avatar_host( + platform: &Arc, + product: ProductContext, + avatars: &Arc, +) -> ProductRuntimeHost { + let (host_config, _) = runtime_config(&product.product_id); + let services = RuntimeServices::new( + platform.clone(), + host_config.host.host_info.clone(), + host_config.people_chain_genesis_hash, + host_config.bulletin_chain_genesis_hash, + host_config.asset_hub_chain_genesis_hash, + test_spawner(), + ); + let pairing_host = PairingHost::new(services.clone(), host_config); + let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); + adapters.profile_platform = Some(avatars.clone() as Arc); + ProductRuntimeHost::from_services(services, adapters, pairing_host, product) +} + +fn egui_chat() -> ProductContext { + ProductContext::new("egui-chat.dot".to_string()).expect("valid product") +} + +fn avatar_rect(x: i32, y: i32, side: u32) -> v01::AvatarRect { + v01::AvatarRect { + x, + y, + width: side, + height: side, + } +} + +const AVATAR_CLIP: v01::AvatarRect = v01::AvatarRect { + x: 0, + y: 64, + width: 360, + height: 576, +}; + +/// A 360 by 640 placement of one 44-unit avatar per `(slot, peer)`, one row +/// apart. +fn avatar_placement(slots: &[(u32, [u8; 32])]) -> v01::HostProfilePlaceContactAvatarsRequest { + v01::HostProfilePlaceContactAvatarsRequest { + surface_width: 360, + surface_height: 640, + slots: slots + .iter() + .map(|&(slot, peer_identity)| v01::ContactAvatarSlot { + slot, + peer_identity, + rect: avatar_rect(16, 80 + 56 * slot as i32, 44), + clip: AVATAR_CLIP, + }) + .collect(), + } +} + +/// What the host is handed for `slot` of [`avatar_placement`]. +fn placed_avatar(slot: u32, reference: &str) -> truapi_platform::PlacedAvatar { + truapi_platform::PlacedAvatar { + slot, + rect: avatar_rect(16, 80 + 56 * slot as i32, 44), + clip: AVATAR_CLIP, + reference: reference.to_string(), + } +} + +fn placed_avatars(avatars: Vec) -> truapi_platform::PlacedAvatars { + truapi_platform::PlacedAvatars { + surface_width: 360, + surface_height: 640, + avatars, + } +} + +fn place_avatars( + host: &ProductRuntimeHost, + request: v01::HostProfilePlaceContactAvatarsRequest, +) -> Result> +{ + futures::executor::block_on(Profile::place_contact_avatars( + host, + &CallContext::default(), + HostProfilePlaceContactAvatarsRequest::V1(request), + )) +} + +#[test] +fn contact_avatars_are_drawn_only_for_contacts_sharing_with_this_product_and_the_answer_hides_which() + { + let platform = stub_platform(); + let avatars = Arc::new(RecordingAvatarHost::default()); + let chat = signed_in(avatar_host(&platform, egui_chat(), &avatars), WALLET); + let owner = owner_of(&chat); + let (alice, bob, carol, dave) = ([0xa1; 32], [0xb0; 32], [0xca; 32], [0xda; 32]); + let alice_reference = format!("{CONTACTS_REFERENCE}a1"); + let record = |product_id: &str, peer, timestamp, reference: Option<&str>| { + futures::executor::block_on(profile::record_received_reference( + platform.as_ref(), + owner, + product_id, + peer, + "seity.dot".to_string(), + timestamp, + reference.map(str::to_string), + )) + .expect("recorded"); + }; + record("egui-chat.dot", alice, 1, Some(&alice_reference)); + record("egui-chat.dot", bob, 1, Some(CONTACTS_REFERENCE)); + record("egui-chat.dot", bob, 2, None); + // Shared with the user through another chat product only. + record("other-chat.dot", dave, 1, Some(CONTACTS_REFERENCE)); + + // Alice appears twice, as a list row and in the conversation header. + assert_eq!( + place_avatars( + &chat, + avatar_placement(&[(0, alice), (1, bob), (2, carol), (3, dave), (4, alice)]), + ) + .expect("a well-formed placement is accepted"), + HostProfilePlaceContactAvatarsResponse::V1 + ); + // Nobody on screen shares a profile: the product is answered the same. + assert_eq!( + place_avatars(&chat, avatar_placement(&[(2, carol), (3, dave)])) + .expect("the answer does not depend on who shared"), + HostProfilePlaceContactAvatarsResponse::V1 + ); + assert_eq!( + place_avatars(&chat, avatar_placement(&[])).expect("an empty placement clears"), + HostProfilePlaceContactAvatarsResponse::V1 + ); + assert_eq!( + avatars.placements(), + vec![ + ( + "egui-chat.dot".to_string(), + placed_avatars(vec![ + placed_avatar(0, &alice_reference), + placed_avatar(4, &alice_reference), + ]), + ), + ("egui-chat.dot".to_string(), placed_avatars(Vec::new())), + ("egui-chat.dot".to_string(), placed_avatars(Vec::new())), + ], + "only a current reference shared with this product is drawn, and a withdrawn one is not" + ); +} + +#[test] +fn contact_avatars_surface_only_the_hosts_own_unsupported() { + let platform = stub_platform(); + let alice = [0xa1; 32]; + let owner = owner_of(&app_host(&platform, "egui-chat.dot")); + futures::executor::block_on(profile::record_received_reference( + platform.as_ref(), + owner, + "egui-chat.dot", + alice, + "seity.dot".to_string(), + 1, + Some(CONTACTS_REFERENCE.to_string()), + )) + .expect("recorded"); + let answered = |answer| { + let avatars = Arc::new(RecordingAvatarHost { + answer: Some(answer), + ..Default::default() + }); + place_avatars( + &signed_in(avatar_host(&platform, egui_chat(), &avatars), WALLET), + avatar_placement(&[(0, alice)]), + ) + }; + + // A drawing failure could depend on which avatars the host was handed, + // so it does not reach the product. + assert_eq!( + answered(v01::HostProfilePlaceContactAvatarsError::Unknown { + reason: "avatar image failed to load".to_string(), + }) + .expect("a host failure is not reported"), + HostProfilePlaceContactAvatarsResponse::V1 + ); + assert!(matches!( + answered(v01::HostProfilePlaceContactAvatarsError::Unsupported), + Err(CallError::Domain(HostProfilePlaceContactAvatarsError::V1( + v01::HostProfilePlaceContactAvatarsError::Unsupported + ))) + )); +} + +#[test] +fn malformed_contact_avatar_placements_are_refused_before_the_host_sees_them() { + let platform = stub_platform(); + let avatars = Arc::new(RecordingAvatarHost::default()); + let chat = signed_in(avatar_host(&platform, egui_chat(), &avatars), WALLET); + let peer = [0xa1; 32]; + let with_rect = |rect| { + let mut request = avatar_placement(&[(0, peer)]); + request.slots[0].rect = rect; + request + }; + let with_surface = |surface_width, surface_height| v01::HostProfilePlaceContactAvatarsRequest { + surface_width, + surface_height, + ..avatar_placement(&[(0, peer)]) + }; + let slots = + |count: u32| avatar_placement(&(0..count).map(|slot| (slot, peer)).collect::>()); + + let accepted = [ + slots(64), + with_surface(1, 1), + with_surface(16384, 16384), + with_rect(avatar_rect(-20, -20, 1)), + with_rect(avatar_rect(0, 0, 1024)), + ]; + let refused = [ + slots(65), + with_surface(0, 640), + with_surface(360, 0), + with_surface(16385, 640), + with_surface(360, 16385), + with_rect(v01::AvatarRect { + x: 0, + y: 0, + width: 44, + height: 45, + }), + with_rect(avatar_rect(0, 0, 0)), + with_rect(avatar_rect(0, 0, 1025)), + avatar_placement(&[(3, peer), (3, [0xb0; 32])]), + ]; + for request in accepted.clone() { + assert_eq!( + place_avatars(&chat, request).expect("the bounds are inclusive"), + HostProfilePlaceContactAvatarsResponse::V1 + ); + } + for request in refused { + assert!(matches!( + place_avatars(&chat, request), + Err(CallError::Domain(HostProfilePlaceContactAvatarsError::V1( + v01::HostProfilePlaceContactAvatarsError::Unknown { .. } + ))) + )); + } + assert_eq!( + avatars.placements().len(), + accepted.len(), + "a refused placement never reaches the host" + ); +} + +#[test] +fn contact_avatars_need_an_app_a_host_that_draws_them_and_a_signed_in_user() { + let platform = stub_platform(); + let avatars = Arc::new(RecordingAvatarHost::default()); + let worker = signed_in( + avatar_host( + &platform, + ProductContext::new_with_execution( + "egui-chat.dot".to_string(), + truapi_platform::ProductExecutionKind::Worker, + ) + .expect("valid product"), + &avatars, + ), + WALLET, + ); + assert!(matches!( + place_avatars(&worker, avatar_placement(&[])), + Err(CallError::Denied) + )); + assert!(matches!( + place_avatars(&app_host(&platform, "egui-chat.dot"), avatar_placement(&[])), + Err(CallError::Unsupported) + )); + + let chat = signed_in(avatar_host(&platform, egui_chat(), &avatars), WALLET); + let alice = [0xa1; 32]; + futures::executor::block_on(profile::record_received_reference( + platform.as_ref(), + owner_of(&chat), + "egui-chat.dot", + alice, + "seity.dot".to_string(), + 1, + Some(CONTACTS_REFERENCE.to_string()), + )) + .expect("recorded"); + place_avatars(&chat, avatar_placement(&[(0, alice)])).expect("placed"); + chat.test_session_state().clear_session(); + assert!(matches!( + place_avatars(&chat, avatar_placement(&[(0, alice)])), + Err(CallError::Domain(HostProfilePlaceContactAvatarsError::V1( + v01::HostProfilePlaceContactAvatarsError::NotConnected + ))) + )); + assert_eq!( + avatars.placements(), + vec![ + ( + "egui-chat.dot".to_string(), + placed_avatars(vec![placed_avatar(0, CONTACTS_REFERENCE)]), + ), + ("egui-chat.dot".to_string(), placed_avatars(Vec::new())), + ], + "avatars drawn for a wallet that signed out are cleared" + ); +} + +#[test] +fn contact_avatars_are_redrawn_for_their_wallet_and_cleared_when_the_connection_goes() { + let platform = stub_platform(); + let avatars = Arc::new(RecordingAvatarHost::default()); + let chat = signed_in(avatar_host(&platform, egui_chat(), &avatars), WALLET); + let services = chat.services().clone(); + let owner = owner_of(&chat); + let alice = [0xa1; 32]; + place_avatars(&chat, avatar_placement(&[(0, alice)])).expect("placed"); + + futures::executor::block_on(profile::record_received_reference( + platform.as_ref(), + owner, + "egui-chat.dot", + alice, + "seity.dot".to_string(), + 1, + Some(CONTACTS_REFERENCE.to_string()), + )) + .expect("recorded"); + // What another wallet's contacts share, or another product's, is not + // this placement's business. + let other_wallet = profile::ProfileOwner { + root_public_key: [0x99; 32], + ..owner + }; + services + .contact_avatars + .redraw(other_wallet, "egui-chat.dot", &services.spawner); + services + .contact_avatars + .redraw(owner, "other-chat.dot", &services.spawner); + services + .contact_avatars + .redraw(owner, "egui-chat.dot", &services.spawner); + assert_eq!( + avatars.wait_for(2), + vec![ + ("egui-chat.dot".to_string(), placed_avatars(Vec::new())), + ( + "egui-chat.dot".to_string(), + placed_avatars(vec![placed_avatar(0, CONTACTS_REFERENCE)]), + ), + ] + ); + + drop(chat); + assert_eq!( + avatars.wait_for(3)[2], + ("egui-chat.dot".to_string(), placed_avatars(Vec::new())), + "a connection that goes away takes its avatars with it" + ); + services + .contact_avatars + .redraw(owner, "egui-chat.dot", &services.spawner); + assert_eq!( + avatars.placements().len(), + 3, + "nothing is redrawn for a connection that is gone" + ); +} + #[test] fn chain_follow_ids_are_scoped_per_product_core() { let (host_config, product) = runtime_config("same.dot"); diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 712c89f2c..e8a306134 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -67,6 +67,54 @@ pub(crate) fn immediate_spawner() -> Spawner { Arc::new(futures::executor::block_on) } +/// A profile host that records every contact avatar placement it is handed, +/// by product, and answers each with `answer`, `Ok` when unset. +#[derive(Default)] +pub(crate) struct RecordingAvatarHost { + pub(crate) placed: Mutex>, + pub(crate) answer: Option, +} + +impl RecordingAvatarHost { + /// Every placement handed over so far, oldest first. + pub(crate) fn placements(&self) -> Vec<(String, truapi_platform::PlacedAvatars)> { + self.placed.lock().expect("placed mutex poisoned").clone() + } + + /// Block until the host has been handed `count` placements, then return + /// them. Redraws and clears run on background tasks. + pub(crate) fn wait_for(&self, count: usize) -> Vec<(String, truapi_platform::PlacedAvatars)> { + wait_until( + || self.placements().len() >= count, + "the host was not handed the expected avatar placements", + ); + self.placements() + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::ProfilePlatform for RecordingAvatarHost { + async fn present_profile( + &self, + _product: &ProductContext, + _request: v01::HostProfilePresentRequest, + ) -> Result<(), v01::HostProfilePresentError> { + Ok(()) + } + + async fn place_contact_avatars( + &self, + product: &ProductContext, + placed: truapi_platform::PlacedAvatars, + ) -> Result<(), v01::HostProfilePlaceContactAvatarsError> { + self.placed + .lock() + .expect("placed mutex poisoned") + .push((product.product_id.clone(), placed)); + self.answer.clone().map_or(Ok(()), Err) + } +} + /// Test hook invoked after each recorded auth state. pub type AuthStateHook = Arc; /// Test hook invoked after an auth-session write is recorded. diff --git a/rust/crates/truapi/src/api/profile.rs b/rust/crates/truapi/src/api/profile.rs index 98054c36e..7e860443c 100644 --- a/rust/crates/truapi/src/api/profile.rs +++ b/rust/crates/truapi/src/api/profile.rs @@ -2,10 +2,11 @@ use crate::versioned::profile::{ HostProfileDiscloseError, HostProfileDiscloseRequest, HostProfileDiscloseResponse, - HostProfilePresentContactError, HostProfilePresentContactRequest, - HostProfilePresentContactResponse, HostProfilePresentError, HostProfilePresentRequest, - HostProfilePresentResponse, HostProfileRetractError, HostProfileRetractRequest, - HostProfileRetractResponse, + HostProfilePlaceContactAvatarsError, HostProfilePlaceContactAvatarsRequest, + HostProfilePlaceContactAvatarsResponse, HostProfilePresentContactError, + HostProfilePresentContactRequest, HostProfilePresentContactResponse, HostProfilePresentError, + HostProfilePresentRequest, HostProfilePresentResponse, HostProfileRetractError, + HostProfileRetractRequest, HostProfileRetractResponse, }; use crate::{CallContext, CallError}; use crate::{wire, wire_trait}; @@ -97,4 +98,51 @@ pub trait Profile: Send + Sync { ) -> Result> { Err(CallError::unavailable()) } + + /// Tell the host where this product draws chat contacts' avatars, so it + /// can draw each contact's shared photo and mood ring over them on its own + /// layer. + /// + /// Each call replaces the product's placement; an empty `slots` clears it. + /// The host draws only for contacts who shared a profile with the user, + /// and keeps the placement current as they share or withdraw one, until + /// the product replaces it or goes away. The answer is the same whoever + /// shared: nothing about any slot, and no profile data, returns to the + /// product. Taps still reach the product, which opens a profile with + /// `presentContact`. + /// + /// App executions only. Rects are in the units of the surface size the + /// product gives: framebuffer pixels for a PolkaVM product, CSS pixels of + /// its viewport for a web product. A placement with more than 64 slots, a + /// surface side outside 1 to 16384, an avatar that is not square or is + /// outside 1 to 1024 a side, or a repeated `slot` is `Unknown`. A host that + /// cannot draw over the product is `Unsupported`; with no user signed in + /// the call is `NotConnected`. + /// + /// ```ts + /// const result = await truapi.profile.placeContactAvatars({ + /// surfaceWidth: 360, + /// surfaceHeight: 640, + /// slots: [ + /// { + /// slot: 0, + /// peerIdentity: "0x0000000000000000000000000000000000000000000000000000000000000000", + /// rect: { x: 16, y: 80, width: 44, height: 44 }, + /// clip: { x: 0, y: 64, width: 360, height: 576 }, + /// }, + /// ], + /// }); + /// console.log("contact avatars placed:", result); + /// ``` + #[wire(id = 4)] + async fn place_contact_avatars( + &self, + _cx: &CallContext, + _request: HostProfilePlaceContactAvatarsRequest, + ) -> Result< + HostProfilePlaceContactAvatarsResponse, + CallError, + > { + Err(CallError::unavailable()) + } } diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 2e3215d7d..03c1403d6 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -68,25 +68,25 @@ pub mod latest { use crate::versioned::{self, Versioned}; pub use crate::v01::{ - AccountId, AllocatableResource, AllocationOutcome, Arrangement, Background, BlendingMode, - BorderStyle, BoxProps, ButtonProps, ButtonVariant, ChainIdentifier, ChatAction, - ChatActionLayout, ChatActions, ChatBotRegistrationStatus, ChatCustomMessage, ChatFile, - ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, ChatRoom, ChatRoomParticipation, - ChatRoomRegistrationStatus, ColorToken, ColumnProps, ContentAlignment, ContextualAlias, - DerivationIndex, Dimensions, Effect, EffectProps, GenericError, HorizontalAlignment, - HostAccountCreateProofRequest, HostAccountGetAliasRequest, - HostAccountListRingVrfKeysRequest, HostAccountRegisterRingVrfKeyRequest, - HostAccountRingVrfSignRequest, HostAccountSignVrfError, HostAccountSignVrfRequest, - HostPlatform, HostSignPayloadData, HostWorkerOperationError, ImageFit, ImageProps, - ImageSource, Modifier, NotificationId, OperationId, OperationStartedResult, PocketCard, - ProductAccountId, ProductProofContext, RawPayload, RegisteredRingVrfKey, RemotePermission, - RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, - RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, - RemoteStatementStoreSubscribeRequest, RenderContext, RendererNode, RingLocation, - RingLocationJunction, RingVrfKeyDisclosure, RingVrfPublicKey, RowProps, RuntimeApi, - RuntimeSpec, RuntimeType, Shape, SignedStatement, Size, Statement, StatementProof, - StorageQueryItem, StorageQueryType, StorageResultItem, TextFieldProps, TextProps, - ThemeName, ThemeVariant, TxPayloadExtension, TypographyStyle, VerticalAlignment, + AccountId, AllocatableResource, AllocationOutcome, Arrangement, AvatarRect, Background, + BlendingMode, BorderStyle, BoxProps, ButtonProps, ButtonVariant, ChainIdentifier, + ChatAction, ChatActionLayout, ChatActions, ChatBotRegistrationStatus, ChatCustomMessage, + ChatFile, ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, ChatRoom, + ChatRoomParticipation, ChatRoomRegistrationStatus, ColorToken, ColumnProps, + ContentAlignment, ContextualAlias, DerivationIndex, Dimensions, Effect, EffectProps, + GenericError, HorizontalAlignment, HostAccountCreateProofRequest, + HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, + HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, + HostAccountSignVrfError, HostAccountSignVrfRequest, HostPlatform, HostSignPayloadData, + HostWorkerOperationError, ImageFit, ImageProps, ImageSource, Modifier, NotificationId, + OperationId, OperationStartedResult, PocketCard, ProductAccountId, ProductProofContext, + RawPayload, RegisteredRingVrfKey, RemotePermission, RemoteStatementStoreCreateProofError, + RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, + RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, RenderContext, + RendererNode, RingLocation, RingLocationJunction, RingVrfKeyDisclosure, RingVrfPublicKey, + RowProps, RuntimeApi, RuntimeSpec, RuntimeType, Shape, SignedStatement, Size, Statement, + StatementProof, StorageQueryItem, StorageQueryType, StorageResultItem, TextFieldProps, + TextProps, ThemeName, ThemeVariant, TxPayloadExtension, TypographyStyle, VerticalAlignment, VrfSignature, }; pub use crate::v02::{ @@ -190,6 +190,9 @@ pub mod latest { pub type HostProfilePresentRequest = LatestOf; /// Profile presentation failure. pub type HostProfilePresentError = LatestOf; + /// Contact avatar placement failure. + pub type HostProfilePlaceContactAvatarsError = + LatestOf; /// Push notification scheduling request. pub type HostPushNotificationRequest = LatestOf; diff --git a/rust/crates/truapi/src/v01/profile.rs b/rust/crates/truapi/src/v01/profile.rs index 5b38f0f37..2d09b31cb 100644 --- a/rust/crates/truapi/src/v01/profile.rs +++ b/rust/crates/truapi/src/v01/profile.rs @@ -1,4 +1,5 @@ use alloc::string::String; +use alloc::vec::Vec; use core::fmt; use parity_scale_codec::{Decode, Encode}; @@ -115,3 +116,67 @@ pub enum HostProfilePresentContactError { reason: String, }, } + +/// Where a chat product draws contact avatars, so the host can draw the +/// photo and mood ring each contact shared over them, on its own layer. +/// +/// The product sends geometry only. The host decides which slots it can fill +/// and never says which, so the product cannot learn who shared a profile. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct HostProfilePlaceContactAvatarsRequest { + /// Width of the product's drawing surface, in the units of every rect: + /// framebuffer pixels for a PolkaVM product, CSS pixels of its viewport + /// for a web product. 1 to 16384. + pub surface_width: u32, + /// Height of the drawing surface, in the same units. 1 to 16384. + pub surface_height: u32, + /// Replaces the product's previous placement entirely; empty clears it. + /// At most 64, each with its own `slot`. + pub slots: Vec, +} + +/// One avatar the product draws for a chat contact. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct ContactAvatarSlot { + /// Product-chosen id, stable for one on-screen avatar (a list row, a + /// header). The host uses it only to keep what it draws stable across + /// updates. + pub slot: u32, + /// The contact's authenticated root identity, as the chat API names it. + pub peer_identity: [u8; 32], + /// Bounding box of the avatar circle: square, 1 to 1024 units a side. + pub rect: AvatarRect, + /// Visible region the avatar is cut to, such as the scroll area. + pub clip: AvatarRect, +} + +/// A rectangle in surface units, relative to the surface's top-left corner. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct AvatarRect { + /// Left edge. + pub x: i32, + /// Top edge. + pub y: i32, + /// Width. + pub width: u32, + /// Height. + pub height: u32, +} + +/// Contact avatar placement failure. Says nothing about any one slot. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum HostProfilePlaceContactAvatarsError { + /// This host cannot draw over the product's surface. + Unsupported, + /// No user is signed in. + NotConnected, + /// Catch-all, including a malformed placement. + Unknown { + /// Human-readable reason. + reason: String, + }, +} diff --git a/rust/crates/truapi/src/versioned/profile.rs b/rust/crates/truapi/src/versioned/profile.rs index 2c23bbbce..60127b43c 100644 --- a/rust/crates/truapi/src/versioned/profile.rs +++ b/rust/crates/truapi/src/versioned/profile.rs @@ -15,4 +15,7 @@ truapi_macros::versioned_type! { pub enum HostProfilePresentContactRequest { V1 => v01::HostProfilePresentContactRequest } pub enum HostProfilePresentContactResponse { V1 } pub enum HostProfilePresentContactError { V1 => v01::HostProfilePresentContactError } + pub enum HostProfilePlaceContactAvatarsRequest { V1 => v01::HostProfilePlaceContactAvatarsRequest } + pub enum HostProfilePlaceContactAvatarsResponse { V1 } + pub enum HostProfilePlaceContactAvatarsError { V1 => v01::HostProfilePlaceContactAvatarsError } } From ed967c4f7edd1e2d5e52179ae0dbcd3f727adbb6 Mon Sep 17 00:00:00 2001 From: w Date: Sun, 27 Sep 2026 23:59:16 -0400 Subject: [PATCH 09/30] fix(chat): open Chat state saved with the previous profile watermark layout Snapshots written before 571f348f4 end with watermarks that carry no frame timestamp or withdrawal marker, and no longer decoded, which paused Chat setup with StorageUnavailable. The trailing list now decodes in either layout. Legacy watermarks are dropped: contacts kept those references under a slot the host no longer reads, so the next reconcile sends every contact the disclosure again. --- .../src/runtime/native_chat/actor.rs | 14 +++- .../src/runtime/native_chat/actor/profile.rs | 23 +++++ .../src/runtime/native_chat/actor/tests.rs | 84 +++++++++++++++++++ 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor.rs index 37ad21401..3e6c11daa 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor.rs @@ -326,10 +326,16 @@ impl Decode for State { BoundaryState::decode(input)? }; // Added after the boundary state: a snapshot that ends here predates it. - let profile_shared = if input.remaining_len()? == Some(0) { - Vec::new() - } else { - >::decode(input)? + // What follows is the watermark list in its current layout or the one + // written before frames were ordered; see `profile::decode_watermarks`. + let profile_shared = match input.remaining_len()? { + Some(0) => Vec::new(), + Some(len) => { + let mut rest = vec![0; len]; + input.read(&mut rest)?; + profile::decode_watermarks(&rest)? + } + None => return Err("unbounded Chat state".into()), }; Ok(Self { secret, diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs index 97677f26c..94ccf515b 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs @@ -37,6 +37,29 @@ pub(super) struct ProfileWatermark { pub(super) timestamp: u64, } +/// A watermark as written before frames were ordered: peer, disclosure +/// digest, discloser. No timestamp, and no way to record a withdrawal. +type LegacyWatermark = ([u8; 32], [u8; 32], String); + +/// Decode the trailing watermark list of a Chat state snapshot, which is +/// either the current layout or the legacy one. +/// +/// Legacy watermarks are dropped rather than carried over. They were written +/// when contacts' hosts kept received references in a slot that is no longer +/// read, so no contact holds what they record, and the next reconcile has to +/// send every contact the disclosure again. Each layout must consume the whole +/// list; anything else is corruption. +pub(super) fn decode_watermarks( + bytes: &[u8], +) -> Result, parity_scale_codec::Error> { + use parity_scale_codec::DecodeAll; + if let Ok(current) = Vec::::decode_all(&mut &bytes[..]) { + return Ok(current); + } + Vec::::decode_all(&mut &bytes[..])?; + Ok(Vec::new()) +} + /// The wallet and Chat network the user's disclosure belongs to. pub(super) fn profile_owner(context: &NativeChatContext) -> ProfileOwner { ProfileOwner { diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs index 93ad1ba4c..4a6eb80dd 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs @@ -1588,6 +1588,90 @@ fn state_decode_accepts_old_prefix_and_tagged_extension_but_rejects_corruption() assert!(State::decode(&mut &legacy[..legacy.len() - 1]).is_err()); } +/// A snapshot written before frames were ordered carried watermarks without a +/// timestamp or withdrawal marker. It still opens, keeps everything else, and +/// the next publish sends the disclosure again. +#[test] +fn a_snapshot_with_legacy_profile_watermarks_opens_and_resends() { + block_on(async { + let fixture = Fixture::new(); + set_product_grants( + &fixture.platform, + PRODUCT, + truapi_platform::PermissionAuthorizationStatus::Authorized, + ) + .await; + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + seed_peer(&actor, &identity, &[&DeviceFixture::new(1)]).await; + disclose_for(&fixture).await; + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + actor + .store + .update(|state| { + let current = state.encode(); + let reopened = State::decode(&mut current.as_slice()).unwrap(); + assert_eq!( + reopened.profile_shared.len(), + 1, + "the current layout keeps its watermarks" + ); + + // The same state with its watermarks in the legacy layout. + let trailing = state.profile_shared.encode(); + let mut legacy = current[..current.len() - trailing.len()].to_vec(); + legacy.extend( + state + .profile_shared + .iter() + .map(|watermark| { + ( + watermark.peer, + watermark.digest.expect("a disclosure was sent"), + watermark.discloser_product_id.clone(), + ) + }) + .collect::>() + .encode(), + ); + let mut input = legacy.as_slice(); + let decoded = State::decode(&mut input).unwrap(); + assert!(input.is_empty()); + assert!(decoded.profile_shared.is_empty()); + assert_eq!(decoded.peers.len(), state.peers.len()); + assert_eq!(decoded.outbox.len(), state.outbox.len()); + assert_eq!( + (decoded.secret.0, decoded.index, decoded.last_expiry), + (state.secret.0, state.index, state.last_expiry) + ); + assert!(!decoded.boundary.legacy_pending); + + let mut corrupt = legacy.clone(); + corrupt.push(0); + assert!(State::decode(&mut corrupt.as_slice()).is_err()); + + *state = decoded; + Ok(()) + }) + .await + .unwrap(); + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "the contact is sent the disclosure again" + ); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert_eq!(view.prepared.len(), 1, "one reference, not two"); + }); +} + const PROFILE_REFERENCE: &str = "seity-contacts:v1:5c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb535c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb53"; fn contains(haystack: &[u8], needle: &[u8]) -> bool { From 98b84ab322074eec6be218c282ea2c00d5fd5896 Mon Sep 17 00:00:00 2001 From: w Date: Mon, 28 Sep 2026 11:05:29 -0400 Subject: [PATCH 10/30] feat(truapi)!: move the Profile service to prototype wire trait 69 Profile claimed wire trait 22, which sits in the sequential range that landed and open services draw from (main uses 1-19, open PRs claim 19, 20, 22 and 23), so it would collide once any of them merged. Per Nidish, services still being prototyped take a high id clear of that range and move to the next free id when they land. 69 is free; method ids are unchanged. Products and hosts built against trait 22 do not interoperate with this build; the wire schema hash changes accordingly. --- docs/rfcs/profile-disclosure.md | 3 ++- rust/crates/truapi-client/src/generated.rs | 12 ++++++------ rust/crates/truapi/src/api/profile.rs | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/rfcs/profile-disclosure.md b/docs/rfcs/profile-disclosure.md index 7af52a032..281de1956 100644 --- a/docs/rfcs/profile-disclosure.md +++ b/docs/rfcs/profile-disclosure.md @@ -44,7 +44,7 @@ The design has six parts: ### Trait ```rust -#[wire_trait(id = 22)] +#[wire_trait(id = 69)] #[crate::async_trait] pub trait Profile: Send + Sync { /// Show the referenced profile in host-owned UI. @@ -247,6 +247,7 @@ read it. ## Open questions +- The wire trait id. The prototype uses 69, clear of the sequential range, and moves to the next free id when it lands. - The content-type index. The prototype uses V2 index 21, which native Chat has to agree to. - Several disclosing products. There is one `ProfileDisclosure` slot, so the last product to disclose replaces the others and the earlier one can no longer retract. The alternative is one slot per product, with the host relaying the diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index f91243735..91981b220 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "bb70fece291da443"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "4562662cc9dd55ae"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -1642,7 +1642,7 @@ impl ProfilePresent { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 22, + trait_id: 69, method_id: 0, }), }; @@ -1669,7 +1669,7 @@ impl ProfileDisclose { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 22, + trait_id: 69, method_id: 1, }), }; @@ -1696,7 +1696,7 @@ impl ProfileRetract { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 22, + trait_id: 69, method_id: 2, }), }; @@ -1723,7 +1723,7 @@ impl ProfilePresentContact { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 22, + trait_id: 69, method_id: 3, }), }; @@ -1750,7 +1750,7 @@ impl ProfilePlaceContactAvatars { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 22, + trait_id: 69, method_id: 4, }), }; diff --git a/rust/crates/truapi/src/api/profile.rs b/rust/crates/truapi/src/api/profile.rs index 7e860443c..80f8106ac 100644 --- a/rust/crates/truapi/src/api/profile.rs +++ b/rust/crates/truapi/src/api/profile.rs @@ -15,7 +15,7 @@ use crate::{wire, wire_trait}; /// /// The product hands over an opaque reference; the host resolves, decrypts and /// renders it. Profile bytes never return to the product. -#[wire_trait(id = 22)] +#[wire_trait(id = 69)] #[crate::async_trait] pub trait Profile: Send + Sync { /// Show the referenced profile in host-owned UI. From 2d94812216af7da40fc54da6bd1d4c847fe829a8 Mon Sep 17 00:00:00 2001 From: w Date: Mon, 28 Sep 2026 13:29:11 -0400 Subject: [PATCH 11/30] feat(profile): relay shares when chats become ready or the share changes The Chat actor published the user's profile reference only on Chat Initialize, and dropped a reference that lapsed unacknowledged for good. - Publish after any Chat request in which a peer became ready, so the response to the acknowledgement that makes a contact ready already carries the reference. - After disclose or retract, ask every open Chat actor of the same wallet and network, whatever its product, to publish on its own task; the call never waits on it. Publishes on one actor run one at a time. - Publish at the start of each reconcile, healing any missed trigger. - Sign a lapsed reference again for a ready peer, up to three frames per peer and disclosure; a new disclosure starts a fresh count. The watermark records the attempts and the lapse. Snapshots in the previous layout open with one attempt that did not lapse; the legacy layout still opens as before. --- .changeset/profile-disclose.md | 7 +- docs/rfcs/profile-disclosure.md | 39 +- rust/crates/truapi-server/src/runtime.rs | 25 +- .../truapi-server/src/runtime/authority.rs | 7 + .../truapi-server/src/runtime/native_chat.rs | 31 +- .../src/runtime/native_chat/actor.rs | 7 +- .../src/runtime/native_chat/actor/profile.rs | 332 +++++++++++++---- .../src/runtime/native_chat/actor/tests.rs | 336 ++++++++++++++++-- .../truapi-server/src/runtime/signing_host.rs | 6 + 9 files changed, 675 insertions(+), 115 deletions(-) diff --git a/.changeset/profile-disclose.md b/.changeset/profile-disclose.md index 7b93511a0..32d792de7 100644 --- a/.changeset/profile-disclose.md +++ b/.changeset/profile-disclose.md @@ -12,8 +12,11 @@ once through `userConfirmation.confirmPermission` with a new `ProfileDisclosure` This change includes the Chat relay. The host sends the disclosure to every ready Chat v2 contact as a host-private message and keeps, per contact, the newest frame their host sent back, withdrawals included, whatever order the chat product opens them in. Both live in wallet- and network-scoped core storage (`ProfileDisclosure`, -`ProfileReferencesReceived`). Delivery is best effort: relayed references never take outbox room from other Chat traffic -and are dropped, not re-signed, after one statement lifetime. +`ProfileReferencesReceived`). The host queues the disclosure when the chat product initializes or reconciles, in the +response to a Chat request in which a contact became ready, and, without delaying the call, as soon as `disclose` or +`retract` changes it while a Chat of the same wallet is open; the chat product still has to run to submit it. Delivery +is best effort: relayed references never take outbox room from other Chat traffic, and one that lapses unacknowledged +after a statement lifetime is signed again for a ready contact, at most three frames per contact and disclosure. Add `profile.placeContactAvatars`. A chat App tells the host where it draws contacts' avatars (surface size and, per avatar, a slot id, peer identity, square rect and clip), and the host draws the photo and mood ring of each contact who diff --git a/docs/rfcs/profile-disclosure.md b/docs/rfcs/profile-disclosure.md index 281de1956..9e1eca2f4 100644 --- a/docs/rfcs/profile-disclosure.md +++ b/docs/rfcs/profile-disclosure.md @@ -186,20 +186,41 @@ withdrawn. Clearing the product clears it. Hosts treat both as secret material. A disclosure travels as a new Chat v2 content type, `ProfileReference { discloser_product_id, reference: Option }`, where `None` withdraws. The Chat actor seals it to each ready peer's devices through the same host-private outbox that carries payments and rich files, so the chat product submits and retries opaque ciphertext it cannot read, and cannot -prepare the content type itself. A per-peer watermark records what was last sent; each reconcile sends the current -disclosure to every peer whose watermark differs, which covers the first share, a new contact, a replacement and a +prepare the content type itself. A per-peer watermark records what was last sent; each publish sends the current +disclosure to every ready peer whose watermark differs, which covers the first share, a new contact, a replacement and a withdrawal. On receipt the host screens the frame, stores it for that peer and removes it from the plaintext returned to the product. Frames from compacted history are dropped. +A Chat actor publishes: + +- when the chat product initializes; +- at the start of each reconcile, which heals any trigger that was missed; +- after any Chat request in which a peer became ready, such as the acknowledgement that completes a device handover, so + that request's response already carries the reference; +- when `disclose` or `retract` changes the disclosure while the chat is open. The core stores the change, answers the + call, and asks every open Chat actor of the same wallet and network, whatever its product, to publish on a task of its + own, so the call never waits on it. + +Publishes on one actor run one at a time, so one that read an older disclosure never queues it after a newer one. +Without a new disclosure and with nothing lapsed, a publish reads the disclosure and checks watermarks, nothing more. + The product decides the order it opens statements in, so frames are ordered by their timestamp, not by arrival. Each frame a host sends a peer is timestamped later than the one before it, even if its clock steps back. The receiving host applies a frame only if it is strictly newer than the one it holds, and keeps a withdrawal as a row rather than deleting it, so a disclosure opened after its own withdrawal cannot bring the reference back. Delivery is best effort. References have their own outbox budget, one per peer, so they never take a slot payments or -rich files need, and a reference that finds no room waits for a later reconcile rather than failing the chat product's -initialization. A queued reference is offered for one statement lifetime and then dropped without being re-signed: a -host that predates the content type rejects the whole statement and never acknowledges it. +rich files need, and a reference that finds no room waits for a later publish rather than failing the chat product's +initialization. A queued reference is offered for one statement lifetime. If it lapses unacknowledged and the peer is +still ready, it is signed again as a new, later frame and offered for another lifetime, up to three frames per peer and +disclosure; then the host stops until the disclosure changes, which starts a fresh count. A host that predates the +content type rejects the whole statement and never acknowledges it, so it costs at most three statements per disclosure. +The watermark records the attempts and whether the last frame lapsed; state written before it recorded them counts as +one attempt that did not lapse. + +The host only prepares statements: the chat product submits them. A publish outside the product's own requests, after +`disclose` or `retract`, queues the reference while the chat actor is open, and it reaches the contact once the chat +product next runs and submits what its responses offer. Stability comes from the reference format rather than the relay: a reference that names a mutable record, such as a registry slot, keeps working when the record changes, so a relay happens only when the reference itself changes. @@ -239,8 +260,11 @@ read it. - One reference for all contacts, so withdrawing it from one contact means rotating it for all of them. - A retraction cannot make a contact's host forget a reference it already resolved. -- The watermark advances when the message is queued, so a message that never arrives, or that a peer's host does not - acknowledge within one statement lifetime, is not resent until the disclosure changes. +- The watermark advances when the message is queued. A message that never arrives is sent again only when it lapses + unacknowledged, three frames at most per disclosure, so a contact whose host misses all three is not sent it again + until the disclosure changes. +- The chat product must run to submit what the host prepares. A disclosure changed while no chat product runs is relayed + when one next initializes. - The host layer covers the product's own drawing, so a product that animates or scrolls between placements shows the avatar a frame late; the product re-sends its placement when the list moves. - Dropped: carrying the reference in ordinary chat content, which puts a bearer capability in product hands. @@ -255,6 +279,5 @@ read it. - Consent covers the product, not the reference: once allowed, a product may replace its disclosure without asking. - Devices. Only the host that took `disclose` knows the disclosure, so contacts that reach the user's other devices are not sent it. -- Reconcile timing. The relay runs when the chat product initializes, not when `disclose` returns. - Resolution. Hosts parse references today; a shared resolver in the core would need the reference format specified here rather than by the publishing product. diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index c17610c6d..b3b5aa6c2 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -1285,6 +1285,15 @@ impl ProductRuntimeHost { genesis_hash: self.services.people_chain_genesis_hash, }) } + + /// Tell the authority the disclosure changed, so open Chats relay it now + /// rather than when their product next initializes. Never waits for the + /// relay. + fn profile_disclosure_changed(&self) { + if let Some(session) = self.authority.current_session() { + self.authority.profile_disclosure_changed(&session); + } + } } #[truapi_platform::async_trait] @@ -1530,8 +1539,9 @@ impl Profile for ProductRuntimeHost { }; profile::write_disclosure(self.platform.as_ref(), owner, &disclosure) .await - .map(|()| HostProfileDiscloseResponse::V1) - .map_err(|reason| domain(v01::HostProfileDiscloseError::Unknown { reason })) + .map_err(|reason| domain(v01::HostProfileDiscloseError::Unknown { reason }))?; + self.profile_disclosure_changed(); + Ok(HostProfileDiscloseResponse::V1) } #[instrument(skip_all, fields(runtime.method = "profile.retract"))] @@ -1558,10 +1568,13 @@ impl Profile for ProductRuntimeHost { Some(disclosure) if disclosure.product_id != self.product_id() => { Err(domain(v01::HostProfileRetractError::NotDiscloser)) } - Some(_) => profile::clear_disclosure(storage, owner) - .await - .map(|()| HostProfileRetractResponse::V1) - .map_err(unknown), + Some(_) => { + profile::clear_disclosure(storage, owner) + .await + .map_err(unknown)?; + self.profile_disclosure_changed(); + Ok(HostProfileRetractResponse::V1) + } } } diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 332d31a71..ebef7d476 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -616,6 +616,13 @@ pub(crate) trait ProductAuthority: Send + Sync { request: PaymentTopUpRequest, ) -> Result<(), PaymentTopUpAuthorityError>; + /// The user's profile disclosure for `session`'s wallet was stored or + /// cleared. An authority that runs native Chat relays it through the + /// wallet's open Chats without delaying the caller. The default does + /// nothing: a paired host's Chat runs on the signing host, which relays + /// only the disclosure stored there. + fn profile_disclosure_changed(&self, _session: &AuthoritySession) {} + /// Ask the account authority to allocate product-scoped resources. async fn allocate_resources( &self, diff --git a/rust/crates/truapi-server/src/runtime/native_chat.rs b/rust/crates/truapi-server/src/runtime/native_chat.rs index 75a481a23..d0247dcfd 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat.rs @@ -191,6 +191,31 @@ impl NativeChatRegistry { .await } + /// Relay a changed profile disclosure through every open Chat of the + /// wallet on this network, whichever product it belongs to. Returns at + /// once: the relay runs on its own task, after the disclosure is stored, + /// and the call that changed it never waits for it. A Chat that is not + /// open relays when its product next initializes. + pub(crate) fn relay_profile_disclosure(&self, context: NativeChatContext) { + let registry = self.clone(); + let spawner = context.services.spawner.clone(); + spawner(Box::pin(async move { + let wallet = (context.session.public_key, context.genesis_hash); + let cache = registry.state.cache.lock().clone(); + let chats: Vec<_> = cache + .chats + .lock() + .await + .iter() + .filter(|((key, _), _)| *key == wallet) + .map(|(_, chat)| chat.clone()) + .collect(); + for chat in chats { + chat.relay_profile_reference(&context).await; + } + })); + } + /// Generic incoming coin import shares the wallet's allocator and recovery /// store, but neither creates a Chat device nor requires Chat permission. pub(crate) fn top_up( @@ -383,6 +408,9 @@ impl NativeChatRegistry { return Ok(response); } let chat = self.chat(&context, &product).await?; + // A peer this request makes ready is sent the user's profile in it. + // An unreadable store is left to the operation to report or repair. + let unready = chat.unready_peers().await.unwrap_or_default(); let mut binding = None; let mut opened = Vec::new(); let mut prepared = Vec::new(); @@ -392,8 +420,6 @@ impl NativeChatRegistry { match &mut request { Request::Initialize => { chat.drive_files(&context).await?; - // Known gap (docs/rfcs/profile-disclosure.md): the relay runs here only, not when - // `disclose` returns, and only on this device. chat.publish_profile_reference(&context).await?; } Request::Bind { username } => { @@ -480,6 +506,7 @@ impl NativeChatRegistry { Ok::<(), ChatError>(()) } .await; + chat.relay_to_newly_ready(&context, &unready).await; let wallet = cache .wallets .lock() diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor.rs index 3e6c11daa..0caee398d 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor.rs @@ -402,6 +402,7 @@ pub(super) struct NativeChatActor { file_transfer_gate: futures::lock::Mutex<()>, file_export_gate: futures::lock::Mutex<()>, file_cursor: AtomicUsize, + profile_gate: futures::lock::Mutex<()>, } impl NativeChatActor { @@ -452,6 +453,7 @@ impl NativeChatActor { file_transfer_gate: futures::lock::Mutex::new(()), file_export_gate: futures::lock::Mutex::new(()), file_cursor: AtomicUsize::new(0), + profile_gate: futures::lock::Mutex::new(()), }); actor .store @@ -1181,6 +1183,9 @@ impl NativeChatActor { ) -> Result<(), Error> { context.require_current()?; self.store.reauthenticate().await?; + // Heals a relay trigger that was missed. With nothing due it reads the + // disclosure and checks watermarks, nothing more. + self.relay_profile_reference(context).await; let (accepted, required) = self .store .read(|state| { @@ -1213,8 +1218,6 @@ impl NativeChatActor { } wallet.reconcile(context).await?; } - // Profile references are not renewed: one lifetime, then dropped. - self.retire_lapsed_profile_references(context).await?; // Only expiry may be renewed on a durable opaque payment handoff. The // product submits/retries the resulting statement; Host never delivers. let pending = self diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs index 94ccf515b..52229d34e 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs @@ -5,25 +5,34 @@ //! //! A per-peer watermark records what this Host last queued for that peer, so //! the initial share, a new contact, a replacement and a withdrawal are one -//! reconcile: every peer whose watermark differs from the disclosure is sent -//! the disclosure. The watermark advances when the message is queued. Each -//! frame to a peer is timestamped later than the one before it, so the peer's -//! host keeps the newest whatever order it opens them in. +//! publish: every ready peer whose watermark differs from the disclosure is +//! sent the disclosure. The watermark advances when the message is queued. +//! Each frame to a peer is timestamped later than the one before it, so the +//! peer's host keeps the newest whatever order it opens them in. +//! +//! A publish runs when the chat product initializes or reconciles, after any +//! Chat request in which a peer became ready, and when the disclosure changes +//! while the chat is open (`NativeChatRegistry::relay_profile_disclosure`). +//! Publishes on one actor run one at a time, so one that read an older +//! disclosure never queues it after a newer one. //! //! Delivery is best effort. References have their own outbox budget, one per //! peer, so they never take a slot user traffic needs; a reference that finds -//! no room is left for a later reconcile. A queued reference is offered for one -//! statement lifetime and then dropped rather than re-signed: a host that does -//! not know the content type never acknowledges it, and would otherwise hold -//! the slot for good. -//! -//! Known gap (docs/rfcs/profile-disclosure.md): advancing at queue time means a message that never -//! arrives is not resent until the disclosure changes. +//! no room is left for a later publish. A queued reference is offered for one +//! statement lifetime. If it lapses unacknowledged, it is signed again and +//! offered to a ready peer for another lifetime, up to +//! [`MAX_PROFILE_ATTEMPTS`] frames per peer and disclosure: a host that does +//! not know the content type never acknowledges it, so it costs at most that +//! many statements each time the disclosure changes. use super::*; use crate::runtime::native_chat::background::require_authorized; use crate::runtime::profile::{Disclosure, ProfileOwner, read_disclosure}; +/// Frames signed for one disclosure to one peer, the first included, before +/// the Host stops offering it until the disclosure changes. +pub(super) const MAX_PROFILE_ATTEMPTS: u8 = 3; + /// What this Host last queued to one peer. #[derive(Clone, PartialEq, Eq, Encode, Decode)] pub(super) struct ProfileWatermark { @@ -35,18 +44,37 @@ pub(super) struct ProfileWatermark { pub(super) discloser_product_id: String, /// Timestamp of the frame sent. The next frame to this peer is later. pub(super) timestamp: u64, + /// Frames signed for this digest, the one sent included. + pub(super) attempts: u8, + /// The frame sent lapsed without an acknowledgement. + pub(super) lapsed: bool, +} + +/// A watermark as written from 571f348f4 until lapsed frames were resent: no +/// attempt count and no lapse marker. +#[derive(Decode)] +struct SingleAttemptWatermark { + peer: [u8; 32], + digest: Option<[u8; 32]>, + discloser_product_id: String, + timestamp: u64, } /// A watermark as written before frames were ordered: peer, disclosure /// digest, discloser. No timestamp, and no way to record a withdrawal. type LegacyWatermark = ([u8; 32], [u8; 32], String); -/// Decode the trailing watermark list of a Chat state snapshot, which is -/// either the current layout or the legacy one. +/// Decode the trailing watermark list of a Chat state snapshot, in the +/// current layout or either earlier one. +/// +/// A single-attempt watermark counts as one attempt that did not lapse. The +/// layout cannot tell a frame that was acknowledged from one that lapsed and +/// was dropped, so, as when it was written, the peer is sent nothing more +/// until the disclosure changes. /// /// Legacy watermarks are dropped rather than carried over. They were written /// when contacts' hosts kept received references in a slot that is no longer -/// read, so no contact holds what they record, and the next reconcile has to +/// read, so no contact holds what they record, and the next publish has to /// send every contact the disclosure again. Each layout must consume the whole /// list; anything else is corruption. pub(super) fn decode_watermarks( @@ -56,6 +84,19 @@ pub(super) fn decode_watermarks( if let Ok(current) = Vec::::decode_all(&mut &bytes[..]) { return Ok(current); } + if let Ok(single) = Vec::::decode_all(&mut &bytes[..]) { + return Ok(single + .into_iter() + .map(|watermark| ProfileWatermark { + peer: watermark.peer, + digest: watermark.digest, + discloser_product_id: watermark.discloser_product_id, + timestamp: watermark.timestamp, + attempts: 1, + lapsed: false, + }) + .collect()); + } Vec::::decode_all(&mut &bytes[..])?; Ok(Vec::new()) } @@ -79,29 +120,49 @@ fn disclosure_digest(disclosure: &Disclosure) -> [u8; 32] { ) } -/// What one peer should be sent now: the disclosure, or a withdrawal of the -/// one it holds. `None` when it already holds what it should. +/// One frame to queue for a peer. +#[derive(Debug, PartialEq, Eq)] +struct Frame { + discloser: String, + /// `None` withdraws. + reference: Option, + digest: Option<[u8; 32]>, + /// Frames signed for this digest, this one included. + attempts: u8, +} + +/// What one peer should be sent now, given the user's disclosure and its +/// digest: the disclosure, a withdrawal of the one it holds, or the frame it +/// was last sent again, once that lapsed with attempts to spare. `None` when +/// it holds what it should, is still offered it, or has had every attempt. fn wanted( - disclosure: Option<&Disclosure>, + disclosure: Option<&(Disclosure, [u8; 32])>, current: Option<&ProfileWatermark>, -) -> Option<(String, Option, Option<[u8; 32]>)> { - match (disclosure, current) { - (Some(disclosure), current) => { - let digest = disclosure_digest(disclosure); - if current.is_some_and(|watermark| watermark.digest == Some(digest)) { +) -> Option { + let (discloser, reference, digest) = match (disclosure, current) { + (Some((disclosure, digest)), _) => ( + &disclosure.product_id, + Some(&disclosure.reference), + Some(*digest), + ), + (None, Some(watermark)) => (&watermark.discloser_product_id, None, None), + (None, None) => return None, + }; + let attempts = match current { + Some(watermark) if watermark.digest == digest => { + if !watermark.lapsed || watermark.attempts >= MAX_PROFILE_ATTEMPTS { return None; } - Some(( - disclosure.product_id.clone(), - Some(disclosure.reference.clone()), - Some(digest), - )) - } - (None, Some(watermark)) if watermark.digest.is_some() => { - Some((watermark.discloser_product_id.clone(), None, None)) + watermark.attempts + 1 } - (None, _) => None, - } + _ => 1, + }; + Some(Frame { + discloser: discloser.clone(), + reference: reference.cloned(), + digest, + attempts, + }) } /// A queued reference whose statement lifetime is over. @@ -115,13 +176,17 @@ fn lapsed(entry: &Outgoing, now: u64) -> bool { impl NativeChatActor { /// Queue a profile reference (or withdrawal) for every ready peer whose - /// watermark differs from the user's current disclosure, as far as the - /// outbox has room. `true` when anything was queued. + /// watermark differs from the user's current disclosure, or whose last + /// frame lapsed with attempts to spare, as far as the outbox has room. + /// `true` when anything was queued. pub(in crate::runtime::native_chat) async fn publish_profile_reference( self: &Arc, context: &NativeChatContext, ) -> Result { context.require_current()?; + // Each publish reads the disclosure and then queues it; two at once + // could queue the older one last. + let _publishing = self.profile_gate.lock().await; if self .store .read(|state| state.boundary.legacy_pending) @@ -132,26 +197,27 @@ impl NativeChatActor { self.retire_lapsed_profile_references(context).await?; let disclosure = read_disclosure(&*context.services.platform, profile_owner(context)) .await - .map_err(|_| Error::StorageUnavailable)?; + .map_err(|_| Error::StorageUnavailable)? + .map(|disclosure| { + let digest = disclosure_digest(&disclosure); + (disclosure, digest) + }); let stale = self .store - .read({ - let disclosure = disclosure.clone(); - move |state| { - state - .peers - .iter() - .filter(|peer| peer.ready()) - .filter(|peer| { - let current = state - .profile_shared - .iter() - .find(|watermark| watermark.peer == peer.identity); - wanted(disclosure.as_ref(), current).is_some() - }) - .map(|peer| peer.identity) - .collect::>() - } + .read(|state| { + state + .peers + .iter() + .filter(|peer| peer.ready()) + .filter(|peer| { + let current = state + .profile_shared + .iter() + .find(|watermark| watermark.peer == peer.identity); + wanted(disclosure.as_ref(), current).is_some() + }) + .map(|peer| peer.identity) + .collect::>() }) .await?; if stale.is_empty() { @@ -176,22 +242,23 @@ impl NativeChatActor { .profile_shared .iter() .find(|watermark| watermark.peer == identity); - let Some((discloser, reference, digest)) = wanted(disclosure.as_ref(), current) - else { + let Some(frame) = wanted(disclosure.as_ref(), current) else { continue; }; // Later than anything sent to this peer before, even // after the clock steps back, so its host can order them. + // A resend is a new frame too, with its own request id. let timestamp = current.map_or(now, |watermark| { now.max(watermark.timestamp.saturating_add(1)) }); - let tag = hash(&(identity, &discloser, &reference, timestamp).encode()); + let tag = + hash(&(identity, &frame.discloser, &frame.reference, timestamp).encode()); let request_id = format!("profile-{}", hex::encode(&tag[..8])); let bytes = wire::encode_profile_reference_message( &request_id, timestamp, - &discloser, - reference.as_deref(), + &frame.discloser, + frame.reference.as_deref(), ) .map_err(|_| Error::InvalidRequest)?; let messages = Zeroizing::new(vec![bytes]); @@ -218,7 +285,7 @@ impl NativeChatActor { }) { Ok(()) => queued = true, // No room: this peer and the rest keep their - // watermarks, so a later reconcile retries them. + // watermarks, so a later publish retries them. Err(Error::StorageUnavailable) => break, Err(error) => return Err(error), } @@ -227,9 +294,11 @@ impl NativeChatActor { .retain(|watermark| watermark.peer != identity); state.profile_shared.push(ProfileWatermark { peer: identity, - digest, - discloser_product_id: discloser, + digest: frame.digest, + discloser_product_id: frame.discloser, timestamp, + attempts: frame.attempts, + lapsed: false, }); } Ok(queued) @@ -237,11 +306,61 @@ impl NativeChatActor { .await } - /// Drop queued references whose statement lifetime is over. Their - /// watermarks stay, so the same disclosure is not queued again: a peer - /// that did not acknowledge it in a lifetime is not helped by another - /// signature, only a changed disclosure is sent again. - pub(super) async fn retire_lapsed_profile_references( + /// Publish for a trigger that has no caller to answer: a peer became + /// ready, the disclosure changed, or a reconcile. A failure waits for the + /// next publish. + pub(in crate::runtime::native_chat) async fn relay_profile_reference( + self: &Arc, + context: &NativeChatContext, + ) { + if let Err(error) = self.publish_profile_reference(context).await { + tracing::debug!(?error, "native Chat profile relay deferred"); + } + } + + /// Peers the relay does not reach yet, which a Chat request may make + /// ready. A peer is never ready in the request that adds it. + pub(in crate::runtime::native_chat) async fn unready_peers( + &self, + ) -> Result, Error> { + self.store + .read(|state| { + state + .peers + .iter() + .filter(|peer| !peer.ready()) + .map(|peer| peer.identity) + .collect() + }) + .await + } + + /// Relay to the peers of `unready` that are ready now. + pub(in crate::runtime::native_chat) async fn relay_to_newly_ready( + self: &Arc, + context: &NativeChatContext, + unready: &[[u8; 32]], + ) { + if unready.is_empty() { + return; + } + let became_ready = self + .store + .read(|state| { + state + .peers + .iter() + .any(|peer| peer.ready() && unready.contains(&peer.identity)) + }) + .await; + if became_ready.unwrap_or(false) { + self.relay_profile_reference(context).await; + } + } + + /// Drop queued references whose statement lifetime is over and mark their + /// watermarks lapsed, so the publish may sign the frame again. + async fn retire_lapsed_profile_references( &self, context: &NativeChatContext, ) -> Result<(), Error> { @@ -259,6 +378,14 @@ impl NativeChatActor { if !valid() { return Err(Error::NotConnected); } + // A peer has one reference queued at most, the one its + // watermark records. + for watermark in &mut state.profile_shared { + watermark.lapsed |= state + .outbox + .iter() + .any(|entry| entry.peer == watermark.peer && lapsed(entry, now)); + } state.outbox.retain(|entry| !lapsed(entry, now)); Ok(()) }) @@ -270,11 +397,13 @@ impl NativeChatActor { mod tests { use super::*; - fn disclosure(reference: &str) -> Disclosure { - Disclosure { + fn disclosure(reference: &str) -> (Disclosure, [u8; 32]) { + let disclosure = Disclosure { product_id: "seity.dot".into(), reference: reference.into(), - } + }; + let digest = disclosure_digest(&disclosure); + (disclosure, digest) } #[test] @@ -282,19 +411,27 @@ mod tests { let current = disclosure("seity-contacts:v1:aa"); let held = ProfileWatermark { peer: [1; 32], - digest: Some(disclosure_digest(¤t)), + digest: Some(current.1), discloser_product_id: "seity.dot".into(), timestamp: 1, + attempts: 1, + lapsed: false, }; assert!(wanted(Some(¤t), Some(&held)).is_none()); - let (_, reference, _) = wanted(Some(&disclosure("seity-contacts:v1:bb")), Some(&held)) + let replacement = wanted(Some(&disclosure("seity-contacts:v1:bb")), Some(&held)) .expect("a replacement is sent"); - assert_eq!(reference.as_deref(), Some("seity-contacts:v1:bb")); - let (discloser, reference, digest) = - wanted(None, Some(&held)).expect("a withdrawal is sent to a holder"); assert_eq!( - (discloser.as_str(), reference, digest), - ("seity.dot", None, None) + (replacement.reference.as_deref(), replacement.attempts), + (Some("seity-contacts:v1:bb"), 1) + ); + assert_eq!( + wanted(None, Some(&held)).expect("a withdrawal is sent to a holder"), + Frame { + discloser: "seity.dot".into(), + reference: None, + digest: None, + attempts: 1, + } ); let withdrawn = ProfileWatermark { digest: None, @@ -317,4 +454,49 @@ mod tests { "a new peer is sent the disclosure" ); } + + #[test] + fn a_lapsed_frame_is_sent_again_until_its_attempts_run_out() { + let current = disclosure("seity-contacts:v1:aa"); + let lapsed_watermark = |digest, attempts| ProfileWatermark { + peer: [1; 32], + digest, + discloser_product_id: "seity.dot".into(), + timestamp: 1, + attempts, + lapsed: true, + }; + let resent = wanted(Some(¤t), Some(&lapsed_watermark(Some(current.1), 1))) + .expect("a lapsed disclosure is sent again"); + assert_eq!( + (resent.digest, resent.attempts), + (Some(current.1), 2), + "as another attempt at the same disclosure" + ); + assert!( + wanted( + Some(¤t), + Some(&lapsed_watermark(Some(current.1), MAX_PROFILE_ATTEMPTS)) + ) + .is_none(), + "not once its attempts are spent" + ); + assert_eq!( + wanted( + Some(&disclosure("seity-contacts:v1:bb")), + Some(&lapsed_watermark(Some(current.1), MAX_PROFILE_ATTEMPTS)) + ) + .expect("a new disclosure is sent") + .attempts, + 1, + "with attempts of its own" + ); + assert_eq!( + wanted(None, Some(&lapsed_watermark(None, 1))) + .expect("a lapsed withdrawal is sent again") + .attempts, + 2 + ); + assert!(wanted(None, Some(&lapsed_watermark(None, MAX_PROFILE_ATTEMPTS))).is_none()); + } } diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs index 4a6eb80dd..756042385 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs @@ -10,7 +10,7 @@ use crate::{ host_logic::statement_store::decode_verified_statement_data, runtime::{authority::AuthoritySession, services::RuntimeServices}, subscription::Spawner, - test_support::{StubPlatform, core_storage_test_key}, + test_support::{StubPlatform, core_storage_test_key, wait_until}, }; use futures::{ executor::block_on, @@ -1672,6 +1672,74 @@ fn a_snapshot_with_legacy_profile_watermarks_opens_and_resends() { }); } +/// A snapshot written before lapsed frames were resent carried watermarks with +/// no attempt count. They open as one attempt that did not lapse, so the +/// contact is not sent the same disclosure again. +#[test] +fn a_snapshot_with_single_attempt_profile_watermarks_keeps_them() { + block_on(async { + let fixture = Fixture::new(); + set_product_grants( + &fixture.platform, + PRODUCT, + truapi_platform::PermissionAuthorizationStatus::Authorized, + ) + .await; + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + seed_peer(&actor, &identity, &[&DeviceFixture::new(1)]).await; + disclose_for(&fixture).await; + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + actor + .store + .update(|state| { + let current = state.encode(); + let trailing = state.profile_shared.encode(); + let mut single = current[..current.len() - trailing.len()].to_vec(); + single.extend( + state + .profile_shared + .iter() + .map(|watermark| { + ( + watermark.peer, + watermark.digest, + watermark.discloser_product_id.clone(), + watermark.timestamp, + ) + }) + .collect::>() + .encode(), + ); + let mut input = single.as_slice(); + let decoded = State::decode(&mut input).unwrap(); + assert!(input.is_empty()); + assert_eq!(decoded.profile_shared.len(), 1); + assert!( + decoded.profile_shared == state.profile_shared, + "kept as one attempt that has not lapsed" + ); + assert_eq!(decoded.outbox.len(), state.outbox.len()); + *state = decoded; + Ok(()) + }) + .await + .unwrap(); + assert!( + !actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "the contact is not sent it again" + ); + }); +} + const PROFILE_REFERENCE: &str = "seity-contacts:v1:5c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb535c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb53"; fn contains(haystack: &[u8], needle: &[u8]) -> bool { @@ -2254,8 +2322,23 @@ fn a_reference_with_no_outbox_room_waits_for_a_later_reconcile() { }); } +/// A peer host that predates the content type never acknowledges a +/// reference; let its statement lifetime pass. +async fn lapse_profile_references(actor: &Arc) { + actor + .store + .update(|state| { + for entry in &mut state.outbox { + entry.statement.expiry = Some(1 << 32); + } + Ok(()) + }) + .await + .unwrap(); +} + #[test] -fn an_unacknowledged_reference_lapses_after_one_lifetime_without_resending() { +fn an_unacknowledged_reference_is_resent_a_bounded_number_of_times_per_disclosure() { block_on(async { let fixture = Fixture::new(); set_product_grants( @@ -2268,40 +2351,253 @@ fn an_unacknowledged_reference_lapses_after_one_lifetime_without_resending() { let identity = IdentityFixture::new(); seed_peer(&actor, &identity, &[&DeviceFixture::new(1)]).await; disclose_for(&fixture).await; + let registry = NativeChatRegistry::default(); assert!( actor .publish_profile_reference(&fixture.context) .await .unwrap() ); - // A peer host that predates the content type never acknowledges it; - // the statement lifetime passes. - actor - .store - .update(|state| { - for entry in &mut state.outbox { - entry.statement.expiry = Some(1 << 32); - } - Ok(()) - }) - .await - .unwrap(); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + let mut request_ids = vec![view.prepared[0].request_id.clone()]; + + for attempt in 2..=profile::MAX_PROFILE_ATTEMPTS { + lapse_profile_references(&actor).await; + actor.reconcile(&fixture.context, ®istry).await.unwrap(); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert_eq!(view.prepared.len(), 1, "attempt {attempt} is offered"); + let resent = &view.prepared[0]; + assert!( + !request_ids.contains(&resent.request_id), + "attempt {attempt} is a new frame" + ); + assert!( + resent + .statement + .expiry + .is_some_and(|expiry| (expiry >> 32) > current_unix_secs()), + "attempt {attempt} is signed for a new lifetime" + ); + request_ids.push(resent.request_id.clone()); + } - actor - .reconcile(&fixture.context, &NativeChatRegistry::default()) - .await - .unwrap(); + lapse_profile_references(&actor).await; + actor.reconcile(&fixture.context, ®istry).await.unwrap(); let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); assert!( view.prepared.is_empty(), - "the lapsed reference is dropped, not re-signed" + "the last attempt is dropped, not re-signed" ); assert!( !actor .publish_profile_reference(&fixture.context) .await .unwrap(), - "and not queued again while the disclosure is unchanged" + "and nothing is queued again while the disclosure is unchanged" + ); + + crate::runtime::profile::write_disclosure( + fixture.platform.as_ref(), + profile::profile_owner(&fixture.context), + &crate::runtime::profile::Disclosure { + product_id: "seity.dot".into(), + reference: format!("{PROFILE_REFERENCE}ff"), + }, + ) + .await + .unwrap(); + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "a new disclosure is sent" + ); + lapse_profile_references(&actor).await; + actor.reconcile(&fixture.context, ®istry).await.unwrap(); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert_eq!( + view.prepared.len(), + 1, + "and has attempts of its own when it lapses" + ); + }); +} + +#[test] +fn a_reconcile_sends_a_reference_no_trigger_queued() { + block_on(async { + let fixture = Fixture::new(); + set_product_grants( + &fixture.platform, + PRODUCT, + truapi_platform::PermissionAuthorizationStatus::Authorized, + ) + .await; + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + seed_peer(&actor, &identity, &[&DeviceFixture::new(1)]).await; + // Stored with no Chat told, as by a host that stopped before relaying. + disclose_for(&fixture).await; + actor + .reconcile(&fixture.context, &NativeChatRegistry::default()) + .await + .unwrap(); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert_eq!(view.prepared.len(), 1); + assert_eq!(view.prepared[0].peer_identity, identity.account); + assert!(view.prepared[0].request_id.starts_with("profile-")); + }); +} + +#[test] +fn a_peer_that_becomes_ready_is_sent_the_disclosure_in_that_request() { + block_on(async { + let fixture = Fixture::new(); + set_product_grants( + &fixture.platform, + PRODUCT, + truapi_platform::PermissionAuthorizationStatus::Authorized, + ) + .await; + let registry = NativeChatRegistry::default(); + let actor = registry.chat(&fixture.context, PRODUCT).await.unwrap(); + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + // Retiring the legacy device leaves the peer unready until it + // acknowledges. + let added = wire::encode_device_added_message( + "own-device", + fixture.timestamp, + &actor.public.account_id, + &actor.public.chat_public_key, + ) + .unwrap(); + let removed = wire::encode_device_removed_message( + "legacy-device", + fixture.timestamp, + &actor.legacy_account, + ) + .unwrap(); + actor + .prepare( + &fixture.context, + identity.account, + HostNativeChatRoute::Device, + wire::encode_transport_request_plaintext("retire-legacy", &[added, removed]) + .unwrap(), + ) + .await + .unwrap(); + disclose_for(&fixture).await; + let initialized = registry + .execute( + fixture.context.clone(), + PRODUCT.into(), + HostProductDeviceChatRequest::Initialize, + ) + .await + .unwrap(); + assert!( + initialized.prepared.is_empty(), + "a peer that is not ready is sent nothing" ); + + let opened = registry + .execute( + fixture.context.clone(), + PRODUCT.into(), + HostProductDeviceChatRequest::Open { + statement: acknowledgment(&actor, &identity, &peer, "retire-legacy", false), + }, + ) + .await + .unwrap(); + assert_eq!( + opened.prepared.len(), + 1, + "the acknowledgement that makes it ready brings the reference" + ); + assert_eq!(opened.prepared[0].peer_identity, identity.account); + assert!(opened.prepared[0].request_id.starts_with("profile-")); }); } + +/// Each open Chat of the wallet whose disclosure changed relays it, whichever +/// product it belongs to; another wallet's Chat relays nothing. +#[test] +fn a_changed_disclosure_is_relayed_by_the_open_chats_of_its_wallet() { + use crate::runtime::profile::{Disclosure, clear_disclosure, write_disclosure}; + let fixture = Fixture::new(); + let mut other_wallet = fixture.context.clone(); + other_wallet.session.public_key = [9; 32]; + let registry = NativeChatRegistry::default(); + let identity = IdentityFixture::new(); + let open = |context: &NativeChatContext, product: &str| { + block_on(async { + set_product_grants( + &fixture.platform, + product, + truapi_platform::PermissionAuthorizationStatus::Authorized, + ) + .await; + let chat = registry.chat(context, product).await.unwrap(); + seed_peer(&chat, &identity, &[&DeviceFixture::new(1)]).await; + chat + }) + }; + let chats = [ + open(&fixture.context, PRODUCT), + open(&fixture.context, "other.dot"), + ]; + let bystander = open(&other_wallet, PRODUCT); + let relayed = |chat: &Arc, withdrawn: bool| { + block_on(chat.store.read(|state| { + state.profile_shared.iter().any(|watermark| { + watermark.peer == identity.account && watermark.digest.is_none() == withdrawn + }) && state + .outbox + .iter() + .any(|entry| matches!(entry.kind, OutgoingKind::ProfileReference(_))) + })) + .unwrap() + }; + // Both wallets hold a disclosure; only the first changed it. + for context in [&fixture.context, &other_wallet] { + block_on(write_disclosure( + fixture.platform.as_ref(), + profile::profile_owner(context), + &Disclosure { + product_id: "seity.dot".into(), + reference: PROFILE_REFERENCE.into(), + }, + )) + .unwrap(); + } + + registry.relay_profile_disclosure(fixture.context.clone()); + wait_until( + || chats.iter().all(|chat| relayed(chat, false)), + "every open Chat of the wallet relays the disclosure", + ); + block_on(clear_disclosure( + fixture.platform.as_ref(), + profile::profile_owner(&fixture.context), + )) + .unwrap(); + registry.relay_profile_disclosure(fixture.context.clone()); + wait_until( + || chats.iter().all(|chat| relayed(chat, true)), + "and then its withdrawal", + ); + assert!( + block_on( + bystander + .store + .read(|state| state.profile_shared.is_empty()) + ) + .unwrap(), + "another wallet's Chat is not told" + ); +} diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 217425043..e7dd551b0 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -1389,6 +1389,12 @@ impl ProductAuthority for SigningHost { .map_err(PaymentTopUpAuthorityError::Domain) } + fn profile_disclosure_changed(&self, session: &AuthoritySession) { + if let Ok(context) = self.native_chat_context(session) { + self.native_chat.relay_profile_disclosure(context); + } + } + async fn allocate_resources( &self, cx: &CallContext, From 3e1dc6977808b37ad6a15729126601ceaad5ebfd Mon Sep 17 00:00:00 2001 From: w Date: Mon, 28 Sep 2026 19:11:59 -0400 Subject: [PATCH 12/30] feat(profile): re-disclosing resends to contacts; placed avatars carry shared_at Every profile.disclose call stores a new disclosure revision, so the same reference disclosed again (its record changed) starts a fresh round to every ready peer. Automatic publishes keep skipping rounds already sent; disclosures stored before revisions decode as revision 0 with their old digest. PlacedAvatar gains shared_at, the sender timestamp of the frame the reference came from, so a host can drop a profile it cached when a contact re-shares the same reference. --- .changeset/profile-disclose.md | 10 ++- docs/rfcs/profile-disclosure.md | 17 ++-- js/packages/truapi-host/README.md | 9 +- .../tests/golden/host-callbacks.ts | 12 ++- rust/crates/truapi-platform/src/lib.rs | 9 +- rust/crates/truapi-server/src/runtime.rs | 13 ++- .../src/runtime/native_chat/actor/profile.rs | 25 +++++- .../src/runtime/native_chat/actor/tests.rs | 90 +++++++++++++++++-- .../truapi-server/src/runtime/profile.rs | 30 ++++++- .../src/runtime/profile/avatars.rs | 9 +- .../crates/truapi-server/src/runtime/tests.rs | 38 +++++++- 11 files changed, 228 insertions(+), 34 deletions(-) diff --git a/.changeset/profile-disclose.md b/.changeset/profile-disclose.md index 32d792de7..e8a46b5ef 100644 --- a/.changeset/profile-disclose.md +++ b/.changeset/profile-disclose.md @@ -16,13 +16,17 @@ product opens them in. Both live in wallet- and network-scoped core storage (`Pr response to a Chat request in which a contact became ready, and, without delaying the call, as soon as `disclose` or `retract` changes it while a Chat of the same wallet is open; the chat product still has to run to submit it. Delivery is best effort: relayed references never take outbox room from other Chat traffic, and one that lapses unacknowledged -after a statement lifetime is signed again for a ready contact, at most three frames per contact and disclosure. +after a statement lifetime is signed again for a ready contact, at most three frames per contact and disclosure. Every +`disclose` call is a new disclosure, even with the reference already held: a profile whose record changed behind the +same reference is sent to every ready contact again, with a fresh attempt count. Add `profile.placeContactAvatars`. A chat App tells the host where it draws contacts' avatars (surface size and, per avatar, a slot id, peer identity, square rect and clip), and the host draws the photo and mood ring of each contact who shared a profile with it on its own layer. The core filters the placement to contacts with a current reference, hands -them with their references to the new `ProfilePlatform.placeContactAvatars(product, placed)` callback, and -redraws the remembered placement when a reference arrives or is withdrawn; it clears it when the connection goes away. +them with their references and `sharedAt` (Unix ms of the contact's share) to the new +`ProfilePlatform.placeContactAvatars(product, placed)` callback, and redraws the remembered placement when a reference +arrives, is re-shared or is withdrawn; a larger `sharedAt` for the same reference tells the host its cached profile is +stale; it clears it when the connection goes away. The product is answered `Ok` whoever shared; only a malformed placement (more than 64 slots, a surface side outside 1 to 16384, a non-square avatar or one outside 1 to 1024 a side, a repeated slot) is refused, and a host that cannot draw answers `Unsupported`. A JS host that supplies a `profile` group must implement the callback; the Rust trait's default diff --git a/docs/rfcs/profile-disclosure.md b/docs/rfcs/profile-disclosure.md index 9e1eca2f4..ab5a6be8a 100644 --- a/docs/rfcs/profile-disclosure.md +++ b/docs/rfcs/profile-disclosure.md @@ -222,8 +222,13 @@ The host only prepares statements: the chat product submits them. A publish outs `disclose` or `retract`, queues the reference while the chat actor is open, and it reaches the contact once the chat product next runs and submits what its responses offer. -Stability comes from the reference format rather than the relay: a reference that names a mutable record, such as a -registry slot, keeps working when the record changes, so a relay happens only when the reference itself changes. +A reference may name a mutable record, such as a registry slot, and a discloser such as Seity re-shares the same +reference when only the record behind it changes. Each `disclose` call therefore stores the disclosure with a new +revision, later than the stored one, and the watermark records the disclosure by a digest that includes it. Disclosing +the reference already held starts a new round to every ready peer: a new, later frame with its own request id and +signature, and a fresh attempt count. Automatic publishes (initialize, reconcile, a peer becoming ready) compare the +same revision and never resend a round already sent. A disclosure stored before revisions reads as revision 0 and keeps +the digest it was sent under, so an upgrade sends nothing. ### Presentation @@ -238,13 +243,15 @@ square bounding box and the region it is cut to, in surface units. Each call rep The core keeps only the slots whose contact holds a current reference in the caller's `ProfileReferencesReceived`, withdrawals excluded, and hands them with those references to `ProfilePlatform::place_contact_avatars(product, -PlacedAvatars { surface_width, surface_height, avatars })`. The host draws each contact's photo and mood ring, when +PlacedAvatars { surface_width, surface_height, avatars })`. Each avatar carries `shared_at`, the sender timestamp (Unix ms) of +the frame its reference came from: a newer frame with the same reference means the contact updated the record behind +it, and the host should drop any profile it cached for that reference. The host draws each contact's photo and mood ring, when they have one, on a layer over the product that lets pointer input through; a tap still reaches the product, which opens the profile with `present_contact`. The default callback draws nothing, so a host draws avatars only once it implements it. -The core remembers the last placement per product connection, in memory. When a reference for that product arrives or -is withdrawn it filters the same geometry again and calls the host again, so avatars appear and disappear without the +The core remembers the last placement per product connection, in memory. When a reference for that product arrives, is +re-shared in a newer frame or is withdrawn it filters the same geometry again and calls the host again, so avatars appear and disappear without the product sending anything. Disposing the connection, or a placement made after the user signed out, clears what the host drew. diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 925ef2751..dad8db7ab 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -191,9 +191,12 @@ and printable ASCII without whitespace; parsing the format is the host's. `profile.placeContactAvatars(product, placed)` draws contacts' avatars over a chat product. `placed` carries the product's surface size and, per avatar, the product's `slot` id, a square `rect`, the `clip` region it is cut to, all in surface units (framebuffer pixels for a PolkaVM product, CSS pixels of the viewport for a web product), and the -`reference` that contact disclosed, so the host can draw their photo and mood ring. Each call replaces what was drawn -for the product; an empty `avatars` clears it. The core calls it again with the same geometry when a contact shares or -withdraws a profile, and with no avatars when the product's connection goes away. Draw on a layer the product cannot +`reference` that contact disclosed, so the host can draw their photo and mood ring, with `sharedAt` (Unix ms, a +`bigint`) of the share it came from. A contact re-shares the same reference when the record behind it changes, so a +larger `sharedAt` for a reference the host has cached means the cached profile is stale. Each call replaces what was +drawn for the product; an empty `avatars` clears it. The core calls it again with the same geometry when a contact +shares, re-shares or withdraws a profile, and with no avatars when the product's connection goes away. Draw on a layer +the product cannot read that lets pointer input through, and never tell the product what was drawn. The host runtimes take `RequiredHostCallbacks`, so a `profile` group implements it alongside `presentProfile`. diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index c6462d942..3f6ad4d14 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -803,6 +803,14 @@ export interface PlacedAvatar { * in `ProfilePlatform::present_profile`. */ reference: string; + + /** + * When the contact's host sent the share this reflects, in Unix + * milliseconds. A contact re-shares the same reference when the record + * behind it changes; a larger `shared_at` for the same reference means + * any cached copy of that profile is stale. + */ + sharedAt: bigint; } /** @@ -1609,6 +1617,7 @@ export const PlacedAvatar: S.Codec = S.lazy( rect: AvatarRect, clip: AvatarRect, reference: S.str, + sharedAt: S.u64, }) as S.Codec, ); @@ -2446,7 +2455,8 @@ export interface ProfilePlatform { * product and must never tell the product what it drew. * * The core calls this again, with the product's last geometry, whenever - * a contact on it shares or withdraws a profile, and with no avatars once + * a contact on it shares, re-shares or withdraws a profile, and with no + * avatars once * the product's connection goes away. Answer `Unsupported` if this host * cannot draw over the product; the product is told so. The default draws * nothing. diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 3c4f66a43..5111d17e5 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -3943,7 +3943,8 @@ pub trait ProfilePlatform: Send + Sync { /// product and must never tell the product what it drew. /// /// The core calls this again, with the product's last geometry, whenever - /// a contact on it shares or withdraws a profile, and with no avatars once + /// a contact on it shares, re-shares or withdraws a profile, and with no + /// avatars once /// the product's connection goes away. Answer `Unsupported` if this host /// cannot draw over the product; the product is told so. The default draws /// nothing. @@ -3984,6 +3985,11 @@ pub struct PlacedAvatar { /// The profile reference the contact disclosed. A bearer capability, as /// in [`ProfilePlatform::present_profile`]. pub reference: String, + /// When the contact's host sent the share this reflects, in Unix + /// milliseconds. A contact re-shares the same reference when the record + /// behind it changes; a larger `shared_at` for the same reference means + /// any cached copy of that profile is stale. + pub shared_at: u64, } impl core::fmt::Debug for PlacedAvatar { @@ -3993,6 +3999,7 @@ impl core::fmt::Debug for PlacedAvatar { .field("rect", &self.rect) .field("clip", &self.clip) .field("reference", &"[REDACTED]") + .field("shared_at", &self.shared_at) .finish() } } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index b3b5aa6c2..ff7ccce7c 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -1533,11 +1533,22 @@ impl Profile for ProductRuntimeHost { } Err(reason) => return Err(domain(v01::HostProfileDiscloseError::Unknown { reason })), } + let storage = self.platform.as_ref(); + // A later revision than the stored one even when the reference is the + // same: the record behind it changed, and contacts are sent it again. + let previous = profile::read_disclosure(storage, owner) + .await + .ok() + .flatten() + .map_or(0, |disclosure| disclosure.revision); + let now = crate::host_logic::statement_store::current_unix_secs() + .saturating_mul(1000); let disclosure = profile::Disclosure { product_id: self.product_id(), reference: request.reference, + revision: now.max(previous.saturating_add(1)), }; - profile::write_disclosure(self.platform.as_ref(), owner, &disclosure) + profile::write_disclosure(storage, owner, &disclosure) .await .map_err(|reason| domain(v01::HostProfileDiscloseError::Unknown { reason }))?; self.profile_disclosure_changed(); diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs index 52229d34e..633f4d522 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs @@ -6,7 +6,10 @@ //! A per-peer watermark records what this Host last queued for that peer, so //! the initial share, a new contact, a replacement and a withdrawal are one //! publish: every ready peer whose watermark differs from the disclosure is -//! sent the disclosure. The watermark advances when the message is queued. +//! sent the disclosure. Each `profile.disclose` call is a new revision, so +//! disclosing the same reference again (its record changed) is sent to every +//! ready peer anew; automatic publishes never resend a revision already sent. +//! The watermark advances when the message is queued. //! Each frame to a peer is timestamped later than the one before it, so the //! peer's host keeps the newest whatever order it opens them in. //! @@ -23,7 +26,7 @@ //! offered to a ready peer for another lifetime, up to //! [`MAX_PROFILE_ATTEMPTS`] frames per peer and disclosure: a host that does //! not know the content type never acknowledges it, so it costs at most that -//! many statements each time the disclosure changes. +//! many statements each time the user discloses or retracts. use super::*; use crate::runtime::native_chat::background::require_authorized; @@ -109,12 +112,27 @@ pub(super) fn profile_owner(context: &NativeChatContext) -> ProfileOwner { } } +/// What a watermark records a disclosure by. Each `profile.disclose` call has +/// its own revision and so its own digest, and starts a new round even for +/// the same reference; automatic publishes of one disclosure share it. A +/// disclosure stored before revisions keeps the digest it was sent under. fn disclosure_digest(disclosure: &Disclosure) -> [u8; 32] { + if disclosure.revision == 0 { + return hash( + &( + b"native-chat-profile-v1", + &disclosure.product_id, + &disclosure.reference, + ) + .encode(), + ); + } hash( &( - b"native-chat-profile-v1", + b"native-chat-profile-v2", &disclosure.product_id, &disclosure.reference, + disclosure.revision, ) .encode(), ) @@ -401,6 +419,7 @@ mod tests { let disclosure = Disclosure { product_id: "seity.dot".into(), reference: reference.into(), + revision: 1, }; let digest = disclosure_digest(&disclosure); (disclosure, digest) diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs index 756042385..7f6d04a25 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs @@ -1786,6 +1786,7 @@ fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retrac &Disclosure { product_id: "seity.dot".into(), reference: PROFILE_REFERENCE.into(), + revision: 1, }, ) .await @@ -1826,6 +1827,7 @@ fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retrac &Disclosure { product_id: "seity.dot".into(), reference: format!("{PROFILE_REFERENCE}ff"), + revision: 1, }, ) .await @@ -1879,6 +1881,7 @@ fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retrac &Disclosure { product_id: "seity.dot".into(), reference: PROFILE_REFERENCE.into(), + revision: 1, }, ) .await @@ -1892,6 +1895,56 @@ fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retrac let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); assert_eq!(view.prepared.len(), 1); assert_ne!(view.prepared[0].request_id, first_request); + let redisclosed_from = view.prepared[0].request_id.clone(); + assert!( + !actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "an automatic publish does not resend the revision already sent" + ); + + // The user updates what contacts see: the same reference, a new + // disclose call. Every ready peer is sent a fresh frame. + write_disclosure( + fixture.platform.as_ref(), + owner, + &Disclosure { + product_id: "seity.dot".into(), + reference: PROFILE_REFERENCE.into(), + revision: 2, + }, + ) + .await + .unwrap(); + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "a new disclose of the same reference starts a new round" + ); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert_eq!(view.prepared.len(), 1); + assert_ne!(view.prepared[0].request_id, redisclosed_from); + assert!( + actor + .store + .read(|state| state + .profile_shared + .iter() + .all(|watermark| watermark.attempts == 1)) + .await + .unwrap(), + "the new round starts its attempts afresh" + ); + assert!( + !actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "and is sent once" + ); }); } @@ -2033,29 +2086,47 @@ fn contact_avatars_over_the_product_follow_what_the_contact_shares() { Some(PROFILE_REFERENCE), ) .await; + let avatar = |shared_at| truapi_platform::PlacedAvatar { + slot: 7, + rect, + clip, + reference: PROFILE_REFERENCE.to_string(), + shared_at, + }; assert_eq!( host.wait_for(2), vec![ placed(Vec::new()), - placed(vec![truapi_platform::PlacedAvatar { - slot: 7, - rect, - clip, - reference: PROFILE_REFERENCE.to_string(), - }]), + placed(vec![avatar(fixture.timestamp)]), ] ); + // The contact re-shares the same reference (its record changed): the + // host is told, with the newer frame's time, so it drops its cache. open_profile_frame( &fixture, &actor, &identity, &peer, - "incoming-withdrawal", + "incoming-reshare", fixture.timestamp + 1, + Some(PROFILE_REFERENCE), + ) + .await; + assert_eq!( + host.wait_for(3)[2], + placed(vec![avatar(fixture.timestamp + 1)]) + ); + open_profile_frame( + &fixture, + &actor, + &identity, + &peer, + "incoming-withdrawal", + fixture.timestamp + 2, None, ) .await; - assert_eq!(host.wait_for(3)[2], placed(Vec::new())); + assert_eq!(host.wait_for(4)[3], placed(Vec::new())); }); } @@ -2184,6 +2255,7 @@ async fn disclose_for(fixture: &Fixture) { &crate::runtime::profile::Disclosure { product_id: "seity.dot".into(), reference: PROFILE_REFERENCE.into(), + revision: 1, }, ) .await @@ -2402,6 +2474,7 @@ fn an_unacknowledged_reference_is_resent_a_bounded_number_of_times_per_disclosur &crate::runtime::profile::Disclosure { product_id: "seity.dot".into(), reference: format!("{PROFILE_REFERENCE}ff"), + revision: 1, }, ) .await @@ -2571,6 +2644,7 @@ fn a_changed_disclosure_is_relayed_by_the_open_chats_of_its_wallet() { &Disclosure { product_id: "seity.dot".into(), reference: PROFILE_REFERENCE.into(), + revision: 1, }, )) .unwrap(); diff --git a/rust/crates/truapi-server/src/runtime/profile.rs b/rust/crates/truapi-server/src/runtime/profile.rs index e15ad492c..9d11b4020 100644 --- a/rust/crates/truapi-server/src/runtime/profile.rs +++ b/rust/crates/truapi-server/src/runtime/profile.rs @@ -8,7 +8,7 @@ pub(crate) mod avatars; -use parity_scale_codec::{Decode, Encode}; +use parity_scale_codec::{Decode, DecodeAll, Encode}; use truapi_platform::{CoreStorage, CoreStorageKey}; /// The wallet and Chat network a disclosure, and what contacts sent back, @@ -22,7 +22,7 @@ pub(crate) struct ProfileOwner { } impl ProfileOwner { - fn disclosure_key(&self) -> CoreStorageKey { + pub(crate) fn disclosure_key(&self) -> CoreStorageKey { CoreStorageKey::ProfileDisclosure { root_public_key: self.root_public_key, genesis_hash: self.genesis_hash, @@ -43,6 +43,18 @@ impl ProfileOwner { pub(crate) struct Disclosure { pub(crate) product_id: String, pub(crate) reference: String, + /// Which `profile.disclose` call this is. Every call takes a larger + /// revision, so disclosing the same reference again (the record behind + /// it changed) starts a new round to every contact. `0` for a disclosure + /// stored before revisions existed. + pub(crate) revision: u64, +} + +/// A disclosure as stored before revisions: product and reference only. +#[derive(Decode)] +struct UnrevisedDisclosure { + product_id: String, + reference: String, } /// What one contact's host last sent. @@ -84,8 +96,18 @@ pub(crate) async fn read_disclosure( else { return Ok(None); }; - Disclosure::decode(&mut raw.as_slice()) - .map(Some) + let bytes = raw.as_slice(); + if let Ok(current) = Disclosure::decode_all(&mut &bytes[..]) { + return Ok(Some(current)); + } + UnrevisedDisclosure::decode_all(&mut &bytes[..]) + .map(|old| { + Some(Disclosure { + product_id: old.product_id, + reference: old.reference, + revision: 0, + }) + }) .map_err(|error| format!("stored profile disclosure is unreadable: {error}")) } diff --git a/rust/crates/truapi-server/src/runtime/profile/avatars.rs b/rust/crates/truapi-server/src/runtime/profile/avatars.rs index 4d309eea4..6894443b8 100644 --- a/rust/crates/truapi-server/src/runtime/profile/avatars.rs +++ b/rust/crates/truapi-server/src/runtime/profile/avatars.rs @@ -182,7 +182,7 @@ impl ContactAvatarPlacement { } /// The slots whose contact currently shares a profile with this product's - /// user, each with that contact's reference. + /// user, each with that contact's reference and when it was shared. async fn drawable( &self, owner: ProfileOwner, @@ -191,14 +191,14 @@ impl ContactAvatarPlacement { if slots.is_empty() { return Ok(Vec::new()); } - let shared: HashMap<[u8; 32], String> = + let shared: HashMap<[u8; 32], (String, u64)> = read_received(self.storage.as_ref(), owner, &self.product.product_id) .await? .into_iter() .filter_map(|received| { let reference = received.reference?; is_screened_profile_reference(&reference) - .then_some((received.peer_identity, reference)) + .then_some((received.peer_identity, (reference, received.timestamp))) }) .collect(); Ok(slots @@ -206,11 +206,12 @@ impl ContactAvatarPlacement { .filter_map(|slot| { shared .get(&slot.peer_identity) - .map(|reference| PlacedAvatar { + .map(|(reference, shared_at)| PlacedAvatar { slot: slot.slot, rect: slot.rect, clip: slot.clip, reference: reference.clone(), + shared_at: *shared_at, }) }) .collect()) diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index 9afbd15ab..e9510d0d6 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -1778,6 +1778,15 @@ fn profile_disclose_stores_the_reference_and_only_its_discloser_may_retract_it() assert_eq!(stored.product_id, "seity.dot"); assert_eq!(stored.reference, CONTACTS_REFERENCE); + // Disclosing the same reference again (its record changed) is a new + // revision, so contacts are sent it again. + disclose(&seity, CONTACTS_REFERENCE).expect("re-disclosing is allowed"); + let again = futures::executor::block_on(profile::read_disclosure(platform.as_ref(), owner)) + .expect("readable") + .expect("stored"); + assert_eq!(again.reference, CONTACTS_REFERENCE); + assert!(again.revision > stored.revision); + assert!(matches!( retract(&other), Err(CallError::Domain(HostProfileRetractError::V1( @@ -1799,6 +1808,31 @@ fn profile_disclose_stores_the_reference_and_only_its_discloser_may_retract_it() ); } +#[test] +fn a_disclosure_stored_before_revisions_still_reads() { + use parity_scale_codec::Encode; + use truapi_platform::CoreStorage; + let platform = consenting_platform(); + let seity = app_host(&platform, "seity.dot"); + let owner = owner_of(&seity); + futures::executor::block_on(platform.write_core_storage( + owner.disclosure_key(), + ("seity.dot".to_string(), CONTACTS_REFERENCE.to_string()).encode(), + )) + .unwrap(); + let stored = futures::executor::block_on(profile::read_disclosure(platform.as_ref(), owner)) + .expect("the old layout decodes") + .expect("stored"); + assert_eq!( + stored, + profile::Disclosure { + product_id: "seity.dot".into(), + reference: CONTACTS_REFERENCE.into(), + revision: 0, + } + ); +} + #[test] fn profile_disclose_asks_once_per_product_and_a_refusal_stores_nothing() { let platform = Arc::new(StubPlatform::default()); @@ -2139,12 +2173,14 @@ fn avatar_placement(slots: &[(u32, [u8; 32])]) -> v01::HostProfilePlaceContactAv } } -/// What the host is handed for `slot` of [`avatar_placement`]. +/// What the host is handed for `slot` of [`avatar_placement`], shared by a +/// frame sent at time 1. fn placed_avatar(slot: u32, reference: &str) -> truapi_platform::PlacedAvatar { truapi_platform::PlacedAvatar { slot, rect: avatar_rect(16, 80 + 56 * slot as i32, 44), clip: AVATAR_CLIP, + shared_at: 1, reference: reference.to_string(), } } From 073ea6e96b8c0c446a4b19830bfa57f58c533cde Mon Sep 17 00:00:00 2001 From: w Date: Mon, 28 Sep 2026 19:50:25 -0400 Subject: [PATCH 13/30] feat(profile): name the contact who shared a presented profile `profile.presentContact` now hands the host the contact behind the reference. A received reference is stored only when it arrives over the authenticated Chat v2 channel from that peer, so the host can say who shared it rather than which product asked. - Add `ProfilePlatform::present_contact_profile(product, PresentedContactProfile { reference, peer_identity, shared_at })`, where `shared_at` is the stored frame's sender timestamp. Its default calls `present_profile` with the reference alone, so iOS, Android and the CLI are unchanged. The product-facing Profile wire is unchanged. - The generated JS surface gains `presentContactProfile`. The WASM adapter passes the profile group through `profileHostAdapter`, which applies that default for a host built before the callback, so the wasm bridge's profile probe still finds every callback it needs. - RFC: add a Provenance section. The host guarantees the contact's authenticated Chat device delivered the reference; it does not prove whose profile it is, since the record is not owner-signed and a contact can forward someone else's, and copies cannot be erased. --- .../profile-present-contact-attribution.md | 11 +++ docs/rfcs/profile-disclosure.md | 16 ++- js/packages/truapi-host/README.md | 9 +- .../truapi-host/src/adapter-support.ts | 20 ++++ .../src/host-callbacks-adapter.test.ts | 60 ++++++++++++ js/packages/truapi-host/src/test-support.ts | 1 + .../truapi-codegen/src/ts/host_callbacks.rs | 34 ++++--- .../tests/golden/host-callbacks-adapter.ts | 13 ++- .../tests/golden/host-callbacks.ts | 56 +++++++++++ .../tests/golden/wasm_bridge.rs | 20 ++++ .../tests/golden/worker-callbacks.ts | 13 ++- rust/crates/truapi-platform/README.md | 11 ++- rust/crates/truapi-platform/src/lib.rs | 52 ++++++++++ rust/crates/truapi-server/src/runtime.rs | 19 +++- .../crates/truapi-server/src/runtime/tests.rs | 98 ++++++++++++++++++- 15 files changed, 405 insertions(+), 28 deletions(-) create mode 100644 .changeset/profile-present-contact-attribution.md diff --git a/.changeset/profile-present-contact-attribution.md b/.changeset/profile-present-contact-attribution.md new file mode 100644 index 000000000..348391f2a --- /dev/null +++ b/.changeset/profile-present-contact-attribution.md @@ -0,0 +1,11 @@ +--- +"@parity/truapi-host": minor +--- + +Name the contact who shared a presented profile. `profile.presentContact` now reaches the new +`ProfilePlatform.presentContactProfile(product, presented)` callback, where `presented` carries the `reference`, the +`peerIdentity` of the contact whose authenticated Chat device delivered it, and `sharedAt` (Unix ms of that share), so a +host can say who shared a profile rather than which product asked. It names who sent the reference, not whose profile it +is: the record is not signed by its owner, and a contact can forward someone else's reference. A JS host that supplies a +`profile` group must implement the callback; one built before it still has contacts' profiles presented through +`presentProfile`, as the Rust trait's default does. The product-facing Profile wire is unchanged. diff --git a/docs/rfcs/profile-disclosure.md b/docs/rfcs/profile-disclosure.md index ab5a6be8a..47c928a72 100644 --- a/docs/rfcs/profile-disclosure.md +++ b/docs/rfcs/profile-disclosure.md @@ -38,7 +38,7 @@ The design has six parts: - `disclose` asks the user once per product before anything is stored. - Core storage holds the user's disclosure and the references received per chat product. - The Chat v2 actor relays disclosures through its host-private outbox. -- `present_contact` substitutes the stored reference into `present`. +- `present_contact` hands the host the stored reference and the contact who sent it. - `place_contact_avatars` substitutes stored references into a host-drawn avatar layer. ### Trait @@ -233,7 +233,19 @@ the digest it was sent under, so an upgrade sends nothing. ### Presentation `present_contact` looks up the caller's received reference for the named peer, screens it again, and hands it to -`ProfilePlatform::present_profile`. Host adapters are unchanged: they see a `present` whichever method produced it. +`ProfilePlatform::present_contact_profile(product, PresentedContactProfile { reference, peer_identity, shared_at })`, +where `shared_at` is the sender timestamp (Unix ms) of the frame the reference came from. The default calls +`present_profile` with the reference alone, so a host that does not implement it shows the profile as before. + +### Provenance + +The host stores a received reference only when it arrives over the authenticated Chat v2 channel from that peer's own +device, so when `present_contact` opens the drawer the host knows who sent it: it can say "shared with you by +over Chat" and name that contact, not the product that asked. That is all it guarantees. The contacts record behind the +reference is not signed by its owner, so the reference proves who delivered it, not whose profile it is: a contact can +forward another person's reference as their own. Nor can copies be erased: a reference is a bearer capability, so +whoever received it, directly or forwarded, keeps it and what it resolved; a retraction only stops a receiving host from +presenting it. ### Placed avatars diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index dad8db7ab..70dab2664 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -188,6 +188,13 @@ when the user dismisses it. The reference is a bearer capability: the host fetch profile's bytes never return to the product. The core forwards only references that are non-empty, at most 2048 bytes and printable ASCII without whitespace; parsing the format is the host's. +`profile.presentContactProfile(product, presented)` shows the profile a Chat contact shared when a chat product calls +`profile.presentContact`. `presented` carries the `reference`, the `peerIdentity` of the contact whose authenticated +Chat device delivered it and the `sharedAt` (Unix ms, a `bigint`) of that share, so the drawer can say who shared it +rather than which product asked. It names who sent the reference, not whose profile it is: the record is not signed by +its owner, and a contact can forward someone else's. Same contract as `presentProfile` otherwise. A `profile` group +without it, from a host built before it, has contacts' profiles presented through `presentProfile`. + `profile.placeContactAvatars(product, placed)` draws contacts' avatars over a chat product. `placed` carries the product's surface size and, per avatar, the product's `slot` id, a square `rect`, the `clip` region it is cut to, all in surface units (framebuffer pixels for a PolkaVM product, CSS pixels of the viewport for a web product), and the @@ -198,7 +205,7 @@ drawn for the product; an empty `avatars` clears it. The core calls it again wit shares, re-shares or withdraws a profile, and with no avatars when the product's connection goes away. Draw on a layer the product cannot read that lets pointer input through, and never tell the product what was drawn. The host runtimes take -`RequiredHostCallbacks`, so a `profile` group implements it alongside `presentProfile`. +`RequiredHostCallbacks`, so a `profile` group implements it and `presentContactProfile` alongside `presentProfile`. `profile.disclose` needs no `profile` group, but the first call from a product asks the user through `userConfirmation.confirmPermission` with a `ProfileDisclosure` review naming that product: every Chat contact receives diff --git a/js/packages/truapi-host/src/adapter-support.ts b/js/packages/truapi-host/src/adapter-support.ts index bea241656..a538d3115 100644 --- a/js/packages/truapi-host/src/adapter-support.ts +++ b/js/packages/truapi-host/src/adapter-support.ts @@ -15,6 +15,7 @@ import type { HopProvider, JsonRpcConnection, NativeChatFilesHost, + ProfilePlatform, } from "./generated/host-callbacks.js"; type WireResult = @@ -171,6 +172,25 @@ export function coinageWalletHostAdapter( }; } +/** + * A profile host built before `presentContactProfile` still shows a contact's + * profile: without it, the contact's reference is presented as + * `presentProfile` would, the core's own default, rather than failing. + */ +export function profileHostAdapter( + host: Required | undefined, +): Required | undefined { + if (host === undefined || typeof host.presentContactProfile === "function") + return host; + return { + presentProfile: (product, request) => host.presentProfile(product, request), + presentContactProfile: (product, presented) => + host.presentProfile(product, { reference: presented.reference }), + placeContactAvatars: (product, placed) => + host.placeContactAvatars(product, placed), + }; +} + /** Optional SDK embeddings must fail closed, never invent successful file handles. */ export const unavailableNativeChatFilesHost: Required = { async pickChatFiles() { diff --git a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts index fe3acce8c..eabf57bc9 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -30,6 +30,7 @@ import { NativeCoinageRequest, NativeCoinageResponse, PermissionDecision, + PresentedContactProfile, ProductContext, ProductExecutionKind, UserConfirmationReview, @@ -906,6 +907,65 @@ describe("createWasmRawCallbacks", () => { expect(closes).toBe(1); expect(returns).toBe(1); }); + + describe("contact profile presentation", () => { + const product = ProductContext.enc({ + productId: "egui-chat.dot", + executionKind: "App", + }); + const presented = { + reference: "seity-contacts:v1:ab", + peerIdentity: new Uint8Array(32).fill(0xa1), + sharedAt: 1_700_000_000_500n, + }; + + it("hands the host the contact who shared the profile", async () => { + const contacts: (typeof presented)[] = []; + const references: string[] = []; + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + profile: { + presentProfile: async (_product, request) => { + references.push(request.reference); + }, + presentContactProfile: async (_product, contact) => { + contacts.push(contact); + }, + }, + }), + ); + + await raw.presentContactProfile!( + product, + PresentedContactProfile.enc(presented), + ); + expect(contacts).toEqual([presented]); + expect(references).toEqual([]); + }); + + it("presents the reference alone for a host built before it", async () => { + const references: string[] = []; + const legacy = { + async presentProfile( + _product: unknown, + request: { reference: string }, + ) { + references.push(request.reference); + }, + async placeContactAvatars() {}, + }; + const raw = createWasmRawCallbacks({ + ...makeHostCallbacks(), + profile: legacy as never, + }); + + await raw.presentContactProfile!( + product, + PresentedContactProfile.enc(presented), + ); + expect(references).toEqual([presented.reference]); + }); + }); }); describe("ProductContext codec", () => { diff --git a/js/packages/truapi-host/src/test-support.ts b/js/packages/truapi-host/src/test-support.ts index e12257f17..e7aedd29d 100644 --- a/js/packages/truapi-host/src/test-support.ts +++ b/js/packages/truapi-host/src/test-support.ts @@ -155,6 +155,7 @@ export function makeHostCallbacks( ? { profile: { presentProfile: async () => {}, + presentContactProfile: async () => {}, placeContactAvatars: async () => {}, ...overrides.profile, }, diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index 8491f25cd..d258e062b 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -215,11 +215,10 @@ fn emit_wasm_adapter( { support_imports.insert("unavailableNativeChatFilesHost".to_string()); } - if traits - .iter() - .any(|trait_def| trait_def.name == "CoinageWalletHost") - { - support_imports.insert("coinageWalletHostAdapter".to_string()); + for trait_def in &traits { + if let Some(adapter) = optional_host_adapter(&trait_def.name) { + support_imports.insert(adapter.to_string()); + } } for trait_def in &traits { for method in &trait_def.methods { @@ -339,14 +338,13 @@ fn emit_wasm_adapter( // narrowed reference rather than re-reading a possibly-absent member. for name in &optional_traits { let namespace = callback_namespace(name); - if name == "CoinageWalletHost" { - writeln!( + match optional_host_adapter(name) { + Some(adapter) => writeln!( out, - " const {namespace} = coinageWalletHostAdapter(callbacks.{namespace});" + " const {namespace} = {adapter}(callbacks.{namespace});" ) - .unwrap(); - } else { - writeln!(out, " const {namespace} = callbacks.{namespace};").unwrap(); + .unwrap(), + None => writeln!(out, " const {namespace} = callbacks.{namespace};").unwrap(), } } // HOP remains a required Rust capability. Older JS embeddings get its @@ -391,6 +389,20 @@ fn emit_wasm_adapter( Ok(out) } +/// The hand-written `adapter-support` wrapper an optional capability group +/// passes through before the adapter binds it, if it has one. +/// +/// `ProfilePlatform`'s wrapper applies `present_contact_profile`'s Rust default +/// for a host built before that callback, so the core never reaches a missing +/// function for it. +fn optional_host_adapter(trait_name: &str) -> Option<&'static str> { + match trait_name { + "CoinageWalletHost" => Some("coinageWalletHostAdapter"), + "ProfilePlatform" => Some("profileHostAdapter"), + _ => None, + } +} + /// Emit the generated callback metadata/proxy used by the Web Worker bridge. /// /// The lifecycle/transport pieces stay hand-written in `worker-runtime.ts` and diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts index 3c5003677..893fcabe9 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -41,6 +41,7 @@ import { NativeCoinageResponse, PermissionDecision, PlacedAvatars, + PresentedContactProfile, ProductContext, UserConfirmationReview, } from "./host-callbacks.js"; @@ -52,6 +53,7 @@ import { coinageWalletHostAdapter, driveResultStream, hopConnectAdapter, + profileHostAdapter, unavailableHopProvider, unavailableNativeChatFilesHost, } from "../adapter-support.js"; @@ -150,6 +152,10 @@ export interface RawCallbacks { sendError: (error: GenericError) => void, ): (() => void) | void; presentProfile?(product: Uint8Array, request: Uint8Array): Promise; + presentContactProfile?( + product: Uint8Array, + presented: Uint8Array, + ): Promise; placeContactAvatars?(product: Uint8Array, placed: Uint8Array): Promise; subscribeTheme( sendItem: (item?: Uint8Array) => void, @@ -168,7 +174,7 @@ export function createWasmRawCallbacks( const identityBackend = callbacks.identityBackend; const permissionStatus = callbacks.permissionStatus; const pocket = callbacks.pocket; - const profile = callbacks.profile; + const profile = profileHostAdapter(callbacks.profile); const hop = callbacks.hop ?? unavailableHopProvider; const nativeChatFiles = callbacks.nativeChatFiles ?? unavailableNativeChatFilesHost; @@ -362,6 +368,11 @@ export function createWasmRawCallbacks( ProductContext.dec(product), HostProfilePresentRequest.dec(request), ), + presentContactProfile: async (product, presented) => + await profile.presentContactProfile( + ProductContext.dec(product), + PresentedContactProfile.dec(presented), + ), placeContactAvatars: async (product, placed) => await profile.placeContactAvatars( ProductContext.dec(product), diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 3f6ad4d14..e4d6d9e0a 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -845,6 +845,30 @@ export interface PreimageSubmitReview { size: bigint; } +/** + * A profile a Chat contact shared with the user, with the contact who sent + * it. + */ +export interface PresentedContactProfile { + /** + * The profile reference the contact disclosed. A bearer capability, as + * in `ProfilePlatform::present_profile`. + */ + reference: string; + + /** + * The contact whose authenticated Chat device delivered the reference: + * who shared it, not necessarily whose profile it is. + */ + peerIdentity: Uint8Array; + + /** + * When the contact's host sent the share, in Unix milliseconds, as in + * `PlacedAvatar::shared_at`. + */ + sharedAt: bigint; +} + /** * Product identity attached to one product-facing TrUAPI connection. * @@ -1643,6 +1667,19 @@ export const PreimageSubmitReview: S.Codec = S.lazy( S.Struct({ size: S.u64 }) as S.Codec, ); +/** + * A profile a Chat contact shared with the user, with the contact who sent + * it. + */ +export const PresentedContactProfile: S.Codec = S.lazy( + (): S.Codec => + S.Struct({ + reference: S.str, + peerIdentity: S.Bytes(32), + sharedAt: S.u64, + }) as S.Codec, +); + /** * Product identity attached to one product-facing TrUAPI connection. * @@ -2448,6 +2485,25 @@ export interface ProfilePlatform { request: HostProfilePresentRequest, ): Promise; + /** + * Show a profile a Chat contact shared with the user, for the product + * that asked with `profile.presentContact`. Same contract as + * `ProfilePlatform::present_profile`: return once it is shown, and + * report an unparseable reference as `InvalidReference`. + * + * The core holds this reference because it arrived over the + * authenticated Chat channel from `peer_identity`'s own device, so the + * host can name that contact as who shared it, rather than the product + * that asked. It cannot vouch for more: the record behind the reference + * is not signed by its owner, so a contact can forward someone else's + * reference. The default presents it as + * `ProfilePlatform::present_profile` would, without the contact. + */ + presentContactProfile?( + product: ProductContext, + presented: PresentedContactProfile, + ): Promise; + /** * Draw the contact avatars a product placed, on the host's own layer over * the product's surface, replacing what was drawn for it before; an empty diff --git a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs index 1b08e2098..ca50ebf4e 100644 --- a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs @@ -66,6 +66,7 @@ pub(super) struct JsBridge { pub(super) clear: Function, pub(super) subscribe_storage: Function, pub(super) present_profile: Function, + pub(super) present_contact_profile: Function, pub(super) place_contact_avatars: Function, pub(super) subscribe_theme: Function, pub(super) confirm_permission: Function, @@ -133,6 +134,8 @@ impl JsBridge { subscribe_storage: get_function(callbacks, "subscribeStorage")?, present_profile: get_optional_function(callbacks, "presentProfile")? .unwrap_or_else(|| missing_callback("presentProfile")), + present_contact_profile: get_optional_function(callbacks, "presentContactProfile")? + .unwrap_or_else(|| missing_callback("presentContactProfile")), place_contact_avatars: get_optional_function(callbacks, "placeContactAvatars")? .unwrap_or_else(|| missing_callback("placeContactAvatars")), subscribe_theme: get_function(callbacks, "subscribeTheme")?, @@ -153,6 +156,7 @@ impl JsBridge { pocket_present: get_optional_function(callbacks, "subscribePocketCards")?.is_some() && get_optional_function(callbacks, "removePocketCard")?.is_some(), profile_present: get_optional_function(callbacks, "presentProfile")?.is_some() + && get_optional_function(callbacks, "presentContactProfile")?.is_some() && get_optional_function(callbacks, "placeContactAvatars")?.is_some(), }) } @@ -749,6 +753,22 @@ impl truapi_platform::ProfilePlatform for WasmPlatform { .map_err(|reason| v01::HostProfilePresentError::Unknown { reason }) } + async fn present_contact_profile( + &self, + product: &truapi_platform::ProductContext, + presented: truapi_platform::PresentedContactProfile, + ) -> Result<(), v01::HostProfilePresentError> { + invoke_unit( + &self.bridge.present_contact_profile, + vec![ + Uint8Array::from(product.encode().as_slice()).into(), + Uint8Array::from(presented.encode().as_slice()).into(), + ], + ) + .await + .map_err(|reason| v01::HostProfilePresentError::Unknown { reason }) + } + async fn place_contact_avatars( &self, product: &truapi_platform::ProductContext, diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index 37e9812ac..0c8ff635a 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -43,6 +43,7 @@ export const CALLBACK_NAMES = [ "write", "clear", "presentProfile", + "presentContactProfile", "placeContactAvatars", "confirmPermission", "confirmUserAction", @@ -331,13 +332,23 @@ function pocketRawCallbacks( function profileRawCallbacks( bridge: WorkerCallbackBridge, -): Required> { +): Required< + Pick< + RawCallbacks, + "presentProfile" | "presentContactProfile" | "placeContactAvatars" + > +> { return { presentProfile: (product, request) => bridge.callbackRequest("presentProfile", [ product, request, ]) as ReturnType["presentProfile"]>, + presentContactProfile: (product, presented) => + bridge.callbackRequest("presentContactProfile", [ + product, + presented, + ]) as ReturnType["presentContactProfile"]>, placeContactAvatars: (product, placed) => bridge.callbackRequest("placeContactAvatars", [ product, diff --git a/rust/crates/truapi-platform/README.md b/rust/crates/truapi-platform/README.md index 59de98454..197cb8a5a 100644 --- a/rust/crates/truapi-platform/README.md +++ b/rust/crates/truapi-platform/README.md @@ -59,11 +59,12 @@ revokes the grant. - `PocketPlatform`: stream the product's Pocket card collection and remove a card from it. The host owns the collection and decides which cards are privileged. -- `ProfilePlatform`: show a product-referenced profile in host-owned UI, and - draw the avatars of contacts who shared one over a chat product. The host - resolves, decrypts and renders each reference; nothing returns to the - product but acceptance. Drawing avatars is optional and draws nothing by - default. +- `ProfilePlatform`: show a product-referenced profile in host-owned UI, show + a contact's shared profile naming the contact who sent it, and draw the + avatars of contacts who shared one over a chat product. The host resolves, + decrypts and renders each reference; nothing returns to the product but + acceptance. Naming the contact is optional and presents the reference alone + by default; drawing avatars is optional and draws nothing by default. `Platform` is a blanket-implemented supertrait that combines the capability traits above except `ChatPlatform`, `PermissionStatusHost`, `PocketPlatform` diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 5111d17e5..0df721133 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -3937,6 +3937,32 @@ pub trait ProfilePlatform: Send + Sync { request: HostProfilePresentRequest, ) -> Result<(), HostProfilePresentError>; + /// Show a profile a Chat contact shared with the user, for the product + /// that asked with `profile.presentContact`. Same contract as + /// [`ProfilePlatform::present_profile`]: return once it is shown, and + /// report an unparseable reference as `InvalidReference`. + /// + /// The core holds this reference because it arrived over the + /// authenticated Chat channel from `peer_identity`'s own device, so the + /// host can name that contact as who shared it, rather than the product + /// that asked. It cannot vouch for more: the record behind the reference + /// is not signed by its owner, so a contact can forward someone else's + /// reference. The default presents it as + /// [`ProfilePlatform::present_profile`] would, without the contact. + async fn present_contact_profile( + &self, + product: &ProductContext, + presented: PresentedContactProfile, + ) -> Result<(), HostProfilePresentError> { + self.present_profile( + product, + HostProfilePresentRequest { + reference: presented.reference, + }, + ) + .await + } + /// Draw the contact avatars a product placed, on the host's own layer over /// the product's surface, replacing what was drawn for it before; an empty /// `avatars` clears it. The layer must let pointer input through to the @@ -3958,6 +3984,32 @@ pub trait ProfilePlatform: Send + Sync { } } +/// A profile a Chat contact shared with the user, with the contact who sent +/// it. +#[derive(Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct PresentedContactProfile { + /// The profile reference the contact disclosed. A bearer capability, as + /// in [`ProfilePlatform::present_profile`]. + pub reference: String, + /// The contact whose authenticated Chat device delivered the reference: + /// who shared it, not necessarily whose profile it is. + pub peer_identity: [u8; 32], + /// When the contact's host sent the share, in Unix milliseconds, as in + /// [`PlacedAvatar::shared_at`]. + pub shared_at: u64, +} + +impl core::fmt::Debug for PresentedContactProfile { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("PresentedContactProfile") + .field("reference", &"[REDACTED]") + .field("peer_identity", &self.peer_identity) + .field("shared_at", &self.shared_at) + .finish() + } +} + /// The avatars the core found drawable in one product's placement: the slots /// whose contact shared a profile with the user, each with the reference that /// contact disclosed. diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index ff7ccce7c..fdd316664 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -1601,7 +1601,9 @@ impl Profile for ProductRuntimeHost { let owner = self .profile_owner() .ok_or_else(|| domain(v01::HostProfilePresentContactError::NotConnected))?; - let reference = profile::received_reference( + // Stored only when it arrived over the authenticated Chat channel from + // this peer, so the peer is who shared it. + let (reference, shared_at) = profile::received_reference( self.platform.as_ref(), owner, &self.product_id(), @@ -1609,7 +1611,11 @@ impl Profile for ProductRuntimeHost { ) .await .map_err(|reason| domain(v01::HostProfilePresentContactError::Unknown { reason }))? - .and_then(|received| received.reference) + .and_then(|received| { + received + .reference + .map(|reference| (reference, received.timestamp)) + }) .ok_or_else(|| domain(v01::HostProfilePresentContactError::NotShared))?; // A stored reference passed the same screen when it arrived; check // again rather than trust storage. @@ -1619,7 +1625,14 @@ impl Profile for ProductRuntimeHost { )); } platform - .present_profile(&self.product, v01::HostProfilePresentRequest { reference }) + .present_contact_profile( + &self.product, + truapi_platform::PresentedContactProfile { + reference, + peer_identity: request.peer_identity, + shared_at, + }, + ) .await .map(|()| HostProfilePresentContactResponse::V1) .map_err(|error| { diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index e9510d0d6..1483a1e37 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -1586,6 +1586,41 @@ impl truapi_platform::ProfilePlatform for RecordingProfilePlatform { } } +/// Records contact presentations separately from product-referenced ones, as a +/// host that names who shared a profile does. +#[derive(Default)] +struct RecordingContactProfilePlatform { + presented: Mutex>, + contacts: Mutex>, +} + +#[truapi::async_trait] +impl truapi_platform::ProfilePlatform for RecordingContactProfilePlatform { + async fn present_profile( + &self, + _product: &ProductContext, + request: truapi::latest::HostProfilePresentRequest, + ) -> Result<(), truapi::latest::HostProfilePresentError> { + self.presented + .lock() + .expect("presented mutex poisoned") + .push(request.reference); + Ok(()) + } + + async fn present_contact_profile( + &self, + product: &ProductContext, + presented: truapi_platform::PresentedContactProfile, + ) -> Result<(), truapi::latest::HostProfilePresentError> { + self.contacts + .lock() + .expect("contacts mutex poisoned") + .push((product.product_id.clone(), presented)); + Ok(()) + } +} + fn profile_host(profile: Option>) -> ProductRuntimeHost { let (host_config, product) = runtime_config("egui-chat.dot"); let services = RuntimeServices::new( @@ -1671,7 +1706,7 @@ fn profile_present_forwards_screened_references_and_is_unsupported_without_an_ad fn profile_host_on( platform: Arc, product: ProductContext, - profile: Option>, + profile: Option>, ) -> ProductRuntimeHost { let (host_config, _) = runtime_config(&product.product_id); let services = RuntimeServices::new( @@ -1684,8 +1719,7 @@ fn profile_host_on( ); let pairing_host = PairingHost::new(services.clone(), host_config); let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); - adapters.profile_platform = - profile.map(|profile| profile as Arc); + adapters.profile_platform = profile; ProductRuntimeHost::from_services(services, adapters, pairing_host, product) } @@ -2050,7 +2084,7 @@ fn profile_present_contact_substitutes_the_reference_the_contact_sent() { .expect("presented mutex poisoned") .as_slice(), [("egui-chat.dot".to_string(), CONTACTS_REFERENCE.to_string())], - "the host presents the stored reference, attributed to the caller" + "a host without contact attribution presents the stored reference by default" ); assert!(matches!( present_contact(&chat, bob), @@ -2113,6 +2147,62 @@ fn profile_present_contact_substitutes_the_reference_the_contact_sent() { )); } +#[test] +fn profile_present_contact_names_the_contact_who_shared_it() { + let platform = stub_platform(); + let presenter = Arc::new(RecordingContactProfilePlatform::default()); + let chat = signed_in( + profile_host_on( + platform.clone(), + ProductContext::new("egui-chat.dot".to_string()).expect("valid product"), + Some(presenter.clone()), + ), + WALLET, + ); + let alice = [0xa1; 32]; + let record = |timestamp| { + futures::executor::block_on(profile::record_received_reference( + platform.as_ref(), + owner_of(&chat), + "egui-chat.dot", + alice, + "seity.dot".to_string(), + timestamp, + Some(CONTACTS_REFERENCE.to_string()), + )) + .expect("recorded"); + }; + record(1_700_000_000_000); + // Alice re-shares after changing the record behind the same reference. + record(1_700_000_000_500); + + present_contact(&chat, alice).expect("presented"); + assert_eq!( + presenter + .contacts + .lock() + .expect("contacts mutex poisoned") + .as_slice(), + [( + "egui-chat.dot".to_string(), + truapi_platform::PresentedContactProfile { + reference: CONTACTS_REFERENCE.to_string(), + peer_identity: alice, + shared_at: 1_700_000_000_500, + } + )], + "the host learns who sent the reference and when their newest share was" + ); + assert!( + presenter + .presented + .lock() + .expect("presented mutex poisoned") + .is_empty(), + "a contact presentation is not reported as a product-referenced one" + ); +} + /// A connection from `product` on `platform`, with `avatars` as the host's /// profile adapter. fn avatar_host( From 9409b3e4958a7e16e266e50320a709e08af0c87d Mon Sep 17 00:00:00 2001 From: w Date: Mon, 28 Sep 2026 20:38:59 -0400 Subject: [PATCH 14/30] feat(profile): pass the contact's username to the presented profile `PresentedContactProfile` gains `username: Option`, so a host can name the contact who shared a profile without showing an address. The name is the host's own, never the product's: the one the calling product's Chat roster holds for that peer (resolved and verified by the host when the contact was bound or first authenticated), else the peer's verified dotNS name. The core waits at most 2 seconds for it and passes `None` otherwise, so a slow directory never holds the drawer back. A paired host knows no roster and passes `None`. --- .../profile-present-contact-attribution.md | 6 ++- docs/rfcs/profile-disclosure.md | 10 +++-- js/packages/truapi-host/README.md | 11 +++-- .../src/host-callbacks-adapter.test.ts | 1 + .../tests/golden/host-callbacks.ts | 10 +++++ rust/crates/truapi-platform/src/lib.rs | 7 +++ rust/crates/truapi-server/src/runtime.rs | 26 +++++++++++ .../truapi-server/src/runtime/authority.rs | 13 ++++++ .../truapi-server/src/runtime/native_chat.rs | 26 +++++++++++ .../src/runtime/native_chat/actor/profile.rs | 14 ++++++ .../src/runtime/native_chat/actor/tests.rs | 43 +++++++++++++++++++ .../src/runtime/native_chat/identity.rs | 23 +++++++--- .../truapi-server/src/runtime/signing_host.rs | 12 ++++++ .../crates/truapi-server/src/runtime/tests.rs | 5 ++- 14 files changed, 190 insertions(+), 17 deletions(-) diff --git a/.changeset/profile-present-contact-attribution.md b/.changeset/profile-present-contact-attribution.md index 348391f2a..579a7d104 100644 --- a/.changeset/profile-present-contact-attribution.md +++ b/.changeset/profile-present-contact-attribution.md @@ -4,8 +4,10 @@ Name the contact who shared a presented profile. `profile.presentContact` now reaches the new `ProfilePlatform.presentContactProfile(product, presented)` callback, where `presented` carries the `reference`, the -`peerIdentity` of the contact whose authenticated Chat device delivered it, and `sharedAt` (Unix ms of that share), so a -host can say who shared a profile rather than which product asked. It names who sent the reference, not whose profile it +`peerIdentity` of the contact whose authenticated Chat device delivered it, `sharedAt` (Unix ms of that share) and, when +the core knows one, the contact's `username`, so a host can say who shared a profile rather than which product asked. +The username is the one the product's Chat roster verified for that contact, else the contact's verified dotNS name, +looked up for at most 2 seconds; it never comes from the product. It names who sent the reference, not whose profile it is: the record is not signed by its owner, and a contact can forward someone else's reference. A JS host that supplies a `profile` group must implement the callback; one built before it still has contacts' profiles presented through `presentProfile`, as the Rust trait's default does. The product-facing Profile wire is unchanged. diff --git a/docs/rfcs/profile-disclosure.md b/docs/rfcs/profile-disclosure.md index 47c928a72..8b5e6fc78 100644 --- a/docs/rfcs/profile-disclosure.md +++ b/docs/rfcs/profile-disclosure.md @@ -233,9 +233,13 @@ the digest it was sent under, so an upgrade sends nothing. ### Presentation `present_contact` looks up the caller's received reference for the named peer, screens it again, and hands it to -`ProfilePlatform::present_contact_profile(product, PresentedContactProfile { reference, peer_identity, shared_at })`, -where `shared_at` is the sender timestamp (Unix ms) of the frame the reference came from. The default calls -`present_profile` with the reference alone, so a host that does not implement it shows the profile as before. +`ProfilePlatform::present_contact_profile(product, PresentedContactProfile { reference, peer_identity, shared_at, +username })`, where `shared_at` is the sender timestamp (Unix ms) of the frame the reference came from. `username` is +the host's own name for the contact, never one from the product: the name the calling product's Chat roster holds for +that peer, verified when the contact was bound or first authenticated, else the peer's verified dotNS name. The core +waits at most 2 seconds for it and passes `None` when it knows none, so a slow directory never holds the drawer back; the +host then names the contact generically, never by address. The default calls `present_profile` with the reference +alone, so a host that does not implement it shows the profile as before. ### Provenance diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 70dab2664..4f038d723 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -190,10 +190,13 @@ and printable ASCII without whitespace; parsing the format is the host's. `profile.presentContactProfile(product, presented)` shows the profile a Chat contact shared when a chat product calls `profile.presentContact`. `presented` carries the `reference`, the `peerIdentity` of the contact whose authenticated -Chat device delivered it and the `sharedAt` (Unix ms, a `bigint`) of that share, so the drawer can say who shared it -rather than which product asked. It names who sent the reference, not whose profile it is: the record is not signed by -its owner, and a contact can forward someone else's. Same contract as `presentProfile` otherwise. A `profile` group -without it, from a host built before it, has contacts' profiles presented through `presentProfile`. +Chat device delivered it, the `sharedAt` (Unix ms, a `bigint`) of that share and, when the core knows it, the contact's +`username`, so the drawer can say who shared it rather than which product asked. The username is the one the core's +Chat roster verified for that contact, else the contact's verified dotNS name, looked up for at most 2 seconds; never a +name from the product. Without one, name the contact generically, never by address. It names who sent the reference, +not whose profile it is: the record is not signed by its owner, and a contact can forward someone else's. Same contract +as `presentProfile` otherwise. A `profile` group without it, from a host built before it, has contacts' profiles +presented through `presentProfile`. `profile.placeContactAvatars(product, placed)` draws contacts' avatars over a chat product. `placed` carries the product's surface size and, per avatar, the product's `slot` id, a square `rect`, the `clip` region it is cut to, all in diff --git a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts index eabf57bc9..55ede8bd2 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -917,6 +917,7 @@ describe("createWasmRawCallbacks", () => { reference: "seity-contacts:v1:ab", peerIdentity: new Uint8Array(32).fill(0xa1), sharedAt: 1_700_000_000_500n, + username: "alice.01", }; it("hands the host the contact who shared the profile", async () => { diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index e4d6d9e0a..6447b273f 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -867,6 +867,15 @@ export interface PresentedContactProfile { * `PlacedAvatar::shared_at`. */ sharedAt: bigint; + + /** + * The contact's username, when the core knows one: the name its Chat + * roster holds for `peer_identity`, verified when the contact was bound + * or first authenticated, else the peer's verified dotNS name. Never a + * name from the product. ``undefined`` when neither is known in time; show the + * contact without a name then, never by address. + */ + username?: string; } /** @@ -1677,6 +1686,7 @@ export const PresentedContactProfile: S.Codec = S.lazy( reference: S.str, peerIdentity: S.Bytes(32), sharedAt: S.u64, + username: S.Option(S.str), }) as S.Codec, ); diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 0df721133..cac5055be 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -3998,6 +3998,12 @@ pub struct PresentedContactProfile { /// When the contact's host sent the share, in Unix milliseconds, as in /// [`PlacedAvatar::shared_at`]. pub shared_at: u64, + /// The contact's username, when the core knows one: the name its Chat + /// roster holds for `peer_identity`, verified when the contact was bound + /// or first authenticated, else the peer's verified dotNS name. Never a + /// name from the product. `None` when neither is known in time; show the + /// contact without a name then, never by address. + pub username: Option, } impl core::fmt::Debug for PresentedContactProfile { @@ -4006,6 +4012,7 @@ impl core::fmt::Debug for PresentedContactProfile { .field("reference", &"[REDACTED]") .field("peer_identity", &self.peer_identity) .field("shared_at", &self.shared_at) + .field("username", &self.username) .finish() } } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index fdd316664..b7ed550c4 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -161,6 +161,9 @@ const PREIMAGE_SUBMIT_TIMEOUT: Duration = Duration::from_secs(360); /// end-to-end submit deadline may reduce it further. const PREIMAGE_REMOTE_AUTHORITY_RESPONSE_TIMEOUT: Duration = RESOURCE_ALLOCATION_REMOTE_AUTHORITY_RESPONSE_TIMEOUT; +/// How long `profile.presentContact` waits for the contact's name before it +/// shows the profile without one. +const CONTACT_USERNAME_BUDGET: Duration = Duration::from_secs(2); const LEGACY_PRODUCT_ACCOUNT_MISMATCH_REASON: &str = "Account can't be derived from product account id"; @@ -1286,6 +1289,25 @@ impl ProductRuntimeHost { }) } + /// The host-verified name of `peer_identity`, this product's Chat + /// contact, or `None` when the host knows none within + /// [`CONTACT_USERNAME_BUDGET`]: a presented profile is not held back + /// waiting for a slow directory. + async fn contact_username(&self, peer_identity: &[u8; 32]) -> Option { + let session = self.authority.current_session()?; + let product_id = self.product_id(); + let lookup = self + .authority + .contact_username(&session, &product_id, *peer_identity) + .fuse(); + let deadline = futures_timer::Delay::new(CONTACT_USERNAME_BUDGET).fuse(); + pin_mut!(lookup, deadline); + futures::select! { + username = lookup => username, + () = deadline => None, + } + } + /// Tell the authority the disclosure changed, so open Chats relay it now /// rather than when their product next initializes. Never waits for the /// relay. @@ -1624,6 +1646,9 @@ impl Profile for ProductRuntimeHost { v01::HostProfilePresentContactError::InvalidReference, )); } + // The name comes from the host, never from the request: the product + // names only the peer identity. + let username = self.contact_username(&request.peer_identity).await; platform .present_contact_profile( &self.product, @@ -1631,6 +1656,7 @@ impl Profile for ProductRuntimeHost { reference, peer_identity: request.peer_identity, shared_at, + username, }, ) .await diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index ebef7d476..b691dc9e4 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -623,6 +623,19 @@ pub(crate) trait ProductAuthority: Send + Sync { /// only the disclosure stored there. fn profile_disclosure_changed(&self, _session: &AuthoritySession) {} + /// The name to show for `peer_identity`, a Chat contact of `product_id` + /// in `session`'s wallet: one the host verified itself, never one a + /// product supplied. `None` when the host knows none. The default knows + /// none: a paired host's Chat roster lives on the signing host. + async fn contact_username( + &self, + _session: &AuthoritySession, + _product_id: &str, + _peer_identity: [u8; 32], + ) -> Option { + None + } + /// Ask the account authority to allocate product-scoped resources. async fn allocate_resources( &self, diff --git a/rust/crates/truapi-server/src/runtime/native_chat.rs b/rust/crates/truapi-server/src/runtime/native_chat.rs index d0247dcfd..cbf54e792 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat.rs @@ -216,6 +216,32 @@ impl NativeChatRegistry { })); } + /// The name to show for `peer`, a contact of `product`'s Chat: the one + /// its roster holds, verified when the contact was bound or first + /// authenticated, else the peer's verified dotNS name. Never a name a + /// product supplied; `None` when neither is known. A Chat not yet open in + /// this session is not opened for this, since opening it is the product's + /// authorized Chat work; the dotNS lookup covers it. + pub(crate) async fn contact_username( + &self, + context: &NativeChatContext, + product: &str, + peer: [u8; 32], + ) -> Option { + let key = ( + (context.session.public_key, context.genesis_hash), + product.to_owned(), + ); + let cache = self.state.cache.lock().clone(); + let chat = cache.chats.lock().await.get(&key).cloned(); + if let Some(chat) = chat + && let Some(username) = chat.contact_username(&peer).await + { + return Some(username); + } + identity::verified_username(context, peer).await + } + /// Generic incoming coin import shares the wallet's allocator and recovery /// store, but neither creates a Chat device nor requires Chat permission. pub(crate) fn top_up( diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs index 633f4d522..28348368d 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs @@ -353,6 +353,20 @@ impl NativeChatActor { .await } + /// The name this Chat's roster holds for `peer`: resolved and verified + /// by the host when the contact was bound or first authenticated. `None` + /// when `peer` is not a contact, has no name, or the store is unreadable. + pub(in crate::runtime::native_chat) async fn contact_username( + &self, + peer: &[u8; 32], + ) -> Option { + self.store + .read(|state| state.peer(peer).ok().and_then(|peer| peer.username.clone())) + .await + .ok() + .flatten() + } + /// Relay to the peers of `unready` that are ready now. pub(in crate::runtime::native_chat) async fn relay_to_newly_ready( self: &Arc, diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs index 7f6d04a25..d93ca0268 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs @@ -2597,6 +2597,49 @@ fn a_peer_that_becomes_ready_is_sent_the_disclosure_in_that_request() { }); } +/// A contact is named from the verified roster of the Chat it is a contact +/// of; a peer the roster does not hold, or another product's contact, gets +/// no name when the directory cannot supply one. +#[test] +fn a_contact_is_named_by_the_roster_of_its_own_chat() { + block_on(async { + let fixture = Fixture::new(); + let registry = NativeChatRegistry::default(); + set_product_grants( + &fixture.platform, + PRODUCT, + truapi_platform::PermissionAuthorizationStatus::Authorized, + ) + .await; + let chat = registry.chat(&fixture.context, PRODUCT).await.unwrap(); + let identity = IdentityFixture::new(); + seed_peer(&chat, &identity, &[&DeviceFixture::new(1)]).await; + + assert_eq!( + registry + .contact_username(&fixture.context, PRODUCT, identity.account) + .await + .as_deref(), + Some("peer.dot"), + "the name the host verified when the contact was added" + ); + assert_eq!( + registry + .contact_username(&fixture.context, PRODUCT, [0xee; 32]) + .await, + None, + "a peer not on the roster does not borrow a contact's name" + ); + assert_eq!( + registry + .contact_username(&fixture.context, "other.dot", identity.account) + .await, + None, + "another product's Chat does not lend its roster" + ); + }); +} + /// Each open Chat of the wallet whose disclosure changed relays it, whichever /// product it belongs to; another wallet's Chat relays nothing. #[test] diff --git a/rust/crates/truapi-server/src/runtime/native_chat/identity.rs b/rust/crates/truapi-server/src/runtime/native_chat/identity.rs index 981e493ad..d8c38d526 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/identity.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/identity.rs @@ -63,13 +63,7 @@ pub(crate) async fn resolve_account( let chat_public_key = people_key(context, account).await?; // A directory outage must not turn a chain-authenticated incoming identity // into an arbitrary-key fallback or make its independent People key unusable. - let username = match dotns::verified_label(context, &account).await { - Ok(username) => username, - Err(reason) => { - tracing::debug!(%reason, "native Chat peer name unavailable"); - None - } - }; + let username = verified_username(context, account).await; ensure_session(context)?; Ok(ResolvedPeer { identity_account_id: account, @@ -78,6 +72,21 @@ pub(crate) async fn resolve_account( }) } +/// `account`'s verified dotNS name, full preferred over lite, or `None` when +/// it has none or the directory cannot be read. +pub(crate) async fn verified_username( + context: &NativeChatContext, + account: [u8; 32], +) -> Option { + match dotns::verified_label(context, &account).await { + Ok(username) => username, + Err(reason) => { + tracing::debug!(%reason, "native Chat peer name unavailable"); + None + } + } +} + async fn username_candidate( context: &NativeChatContext, username: &str, diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index e7dd551b0..1cfba2c45 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -1395,6 +1395,18 @@ impl ProductAuthority for SigningHost { } } + async fn contact_username( + &self, + session: &AuthoritySession, + product_id: &str, + peer_identity: [u8; 32], + ) -> Option { + let context = self.native_chat_context(session).ok()?; + self.native_chat + .contact_username(&context, product_id, peer_identity) + .await + } + async fn allocate_resources( &self, cx: &CallContext, diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index 1483a1e37..8f0588353 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -2189,9 +2189,12 @@ fn profile_present_contact_names_the_contact_who_shared_it() { reference: CONTACTS_REFERENCE.to_string(), peer_identity: alice, shared_at: 1_700_000_000_500, + // A paired host's Chat roster lives on the signing host. + username: None, } )], - "the host learns who sent the reference and when their newest share was" + "the host learns who sent the reference and when their newest share was, and no name \ + it does not know" ); assert!( presenter From 131f5a37e4ca5a6d276806cab912102b08b9f062 Mon Sep 17 00:00:00 2001 From: w Date: Tue, 29 Sep 2026 12:14:29 -0400 Subject: [PATCH 15/30] Preserve renewal target visibility in browser tests --- rust/crates/truapi/src/runtime.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 3d788cb03..5d3edc40a 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -88,8 +88,10 @@ pub use signing_host::{ respond_to_pairing, resume_pairing, }; pub use signing_host::{LocalIdentity, LocalIdentityContext, WalletAllowanceSnapshot}; +#[cfg(any(test, not(target_arch = "wasm32")))] +pub use signing_host::StatementRenewalTarget; #[cfg(not(target_arch = "wasm32"))] -pub use signing_host::{StatementRenewalTarget, TrackedStatementRenewalTarget}; +pub use signing_host::TrackedStatementRenewalTarget; use tracing::{instrument, warn}; use truapi::api::{Chat, Contacts, Pocket, Profile, Renderer}; use truapi::versioned::account::{ From c213487f50aeaa42b66576a6323e29f2339574e5 Mon Sep 17 00:00:00 2001 From: w Date: Tue, 29 Sep 2026 12:54:22 -0400 Subject: [PATCH 16/30] Migrate Seity native bindings and regenerate unified API catalogs --- .changeset/profile-present.md | 3 + rust/crates/truapi-client/src/generated.rs | 2 +- rust/crates/truapi/src/platform.rs | 85 +++++++++++++++++----- rust/crates/truapi/src/runtime.rs | 3 +- rust/crates/truapi/src/v01/profile.rs | 55 +++++++++++--- 5 files changed, 117 insertions(+), 31 deletions(-) diff --git a/.changeset/profile-present.md b/.changeset/profile-present.md index ea884695b..44abd291a 100644 --- a/.changeset/profile-present.md +++ b/.changeset/profile-present.md @@ -6,3 +6,6 @@ Add the `profile` service. `profile.present({ reference })` asks the host to show a referenced profile in host-owned UI; the host resolves, decrypts and renders it, and nothing but acceptance returns to the product. Hosts opt in with the optional `profile` callbacks (`ProfilePlatform`); a host that supplies none answers `Unsupported`. + +Keep the optional profile bindings available under the consolidated native +`runtime` feature, and use the shared host clock for disclosure revisions. diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 27054e02c..ac47ff7b5 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "4562662cc9dd55ae"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "474f1270d1b5ac35"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index 3420c8f6e..f45b1de02 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -1383,7 +1383,10 @@ pub trait Features: Send + Sync { /// Wallet and asset binding checked by the native service before every operation. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct NativeCoinageScope { /// Authenticated root key of the wallet owning the main purse. pub root_public_key: [u8; 32], @@ -1395,7 +1398,10 @@ pub struct NativeCoinageScope { /// Immutable, Host-authenticated outgoing intent. No field is a product display hint. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct NativeCoinagePaymentIntent { /// Stable wallet-, network- and product-scoped operation identity. pub operation_id: [u8; 32], @@ -1414,7 +1420,10 @@ pub struct NativeCoinagePaymentIntent { /// Host-private bearer material. Never return this through the product API or log it. /// Raw amounts are canonical unsigned decimal u128 strings, avoiding FFI truncation. #[derive(Clone, PartialEq, Eq, Encode, Decode, zeroize::Zeroize)] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct NativeCoinageMemo { /// Validated 64-byte native sr25519 secret keys, confined to the trusted Host. pub secret_keys: Vec>, @@ -1424,7 +1433,10 @@ pub struct NativeCoinageMemo { /// Durable native-wallet operations, not an alternative inventory ledger. #[derive(Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Enum))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum NativeCoinageOperation { /// Read trusted denomination metadata without selecting or allocating inventory. Denomination, @@ -1497,7 +1509,10 @@ impl zeroize::Zeroize for NativeCoinageOperation { /// One native operation with the immutable wallet/network scope to authenticate. #[derive(Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct NativeCoinageRequest { /// Expected owner and asset, verified against the active native wallet. pub scope: NativeCoinageScope, @@ -1513,7 +1528,10 @@ impl zeroize::Zeroize for NativeCoinageRequest { /// Sanitized failures. Never forward secret-bearing native exception descriptions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Enum))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum NativeCoinageFailure { /// The selected native owner, its durable store or its session is unavailable. Unavailable, @@ -1533,7 +1551,10 @@ pub enum NativeCoinageFailure { /// Incoming settlement result; acceptance and best-head observations are not finality. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Enum))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum NativeCoinageTopUpOutcome { /// The original requested minimum has been credited at finality. /// For a zero minimum, the source claim is terminal with positive finalized credit. @@ -1551,7 +1572,10 @@ pub enum NativeCoinageTopUpOutcome { /// Typed native results. Only the trusted Host may consume a Prepared memo. #[derive(Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Enum))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum NativeCoinageResponse { /// Trusted denomination metadata for the selected wallet/asset. Denomination { @@ -1611,7 +1635,10 @@ pub trait CoinageWalletHost: Send + Sync { /// Trusted native Chat selection context; never passed to a product. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct NativeChatFilePickRequest { /// Authenticated product requesting selection. pub product_id: String, @@ -1625,7 +1652,10 @@ pub struct NativeChatFilePickRequest { /// Immutable Host-owned source and metadata derived from its actual bytes. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct NativeChatPickedFile { /// Opaque private handle surviving restart until explicitly released. pub source_id: String, @@ -1635,7 +1665,10 @@ pub struct NativeChatPickedFile { /// Trusted context for exporting a verified native Chat attachment. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct NativeChatFileExportRequest { /// Authenticated product requesting presentation. pub product_id: String, @@ -3632,7 +3665,10 @@ pub struct IdentityDisclosureReview { /// Review shown before a product binds or uses wallet-held Chat identity authority. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct ChatAuthorityReview { /// Product requesting the Chat identity operation. pub product_id: String, @@ -3642,7 +3678,10 @@ pub struct ChatAuthorityReview { /// user's Chat contacts. The host relays it to every contact, so the prompt /// names the product, never the contacts or the reference. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct ProfileDisclosureReview { /// Product asking to disclose the profile. pub product_id: String, @@ -3653,7 +3692,10 @@ pub struct ProfileDisclosureReview { /// This review never grants a reusable spending permission. Chat authority and /// automatic product signing do not authorize it. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct MainPurseChatPaymentReview { /// Authenticated product requesting this payment. pub calling_product_id: String, @@ -3943,7 +3985,10 @@ pub trait ProfilePlatform: Send + Sync { /// A profile a Chat contact shared with the user, with the contact who sent /// it. #[derive(Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct PresentedContactProfile { /// The profile reference the contact disclosed. A bearer capability, as /// in [`ProfilePlatform::present_profile`]. @@ -3977,7 +4022,10 @@ impl core::fmt::Debug for PresentedContactProfile { /// whose contact shared a profile with the user, each with the reference that /// contact disclosed. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct PlacedAvatars { /// Width of the product's surface, in the units of every rect. pub surface_width: u32, @@ -3989,7 +4037,10 @@ pub struct PlacedAvatars { /// One avatar to draw over a product. #[derive(Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct PlacedAvatar { /// The product's id for this on-screen avatar, stable across updates. pub slot: u32, diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 848a41ccc..7545b801a 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -1753,8 +1753,7 @@ impl Profile for ProductRuntimeHost { .ok() .flatten() .map_or(0, |disclosure| disclosure.revision); - let now = crate::host_logic::statement_store::current_unix_secs() - .saturating_mul(1000); + let now = crate::unix_time::current_unix_secs().saturating_mul(1000); let disclosure = profile::Disclosure { product_id: self.product_id(), reference: request.reference, diff --git a/rust/crates/truapi/src/v01/profile.rs b/rust/crates/truapi/src/v01/profile.rs index 2d09b31cb..c11dd2afe 100644 --- a/rust/crates/truapi/src/v01/profile.rs +++ b/rust/crates/truapi/src/v01/profile.rs @@ -9,7 +9,10 @@ use parity_scale_codec::{Decode, Encode}; /// it names. The host resolves and renders it itself, so profile bytes, the /// avatar image included, never reach the product. #[derive(Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct HostProfilePresentRequest { /// Opaque profile reference, e.g. a Seity `#` blob reference. pub reference: String, @@ -25,7 +28,10 @@ impl fmt::Debug for HostProfilePresentRequest { /// Profile presentation failure. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum HostProfilePresentError { /// The reference is malformed or names a format this host cannot open. InvalidReference, @@ -41,7 +47,10 @@ pub enum HostProfilePresentError { /// The reference is a bearer capability for everyone the host relays it to. /// The host stores it as the user's own and never parses it. #[derive(Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct HostProfileDiscloseRequest { /// Opaque profile reference, e.g. a Seity contacts reference. pub reference: String, @@ -57,7 +66,10 @@ impl fmt::Debug for HostProfileDiscloseRequest { /// Profile disclosure failure. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum HostProfileDiscloseError { /// The reference is empty, too long, or not printable ASCII. InvalidReference, @@ -75,7 +87,10 @@ pub enum HostProfileDiscloseError { /// Profile retraction failure. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum HostProfileRetractError { /// Another product disclosed the reference the host holds. NotDiscloser, @@ -94,7 +109,10 @@ pub enum HostProfileRetractError { /// reference that contact's host sent, so the product cannot read, keep or /// substitute it. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct HostProfilePresentContactRequest { /// The contact's authenticated root identity, as the chat API names it. pub peer_identity: [u8; 32], @@ -102,7 +120,10 @@ pub struct HostProfilePresentContactRequest { /// Contact profile presentation failure. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum HostProfilePresentContactError { /// This contact has not shared a profile with the user. NotShared, @@ -123,7 +144,10 @@ pub enum HostProfilePresentContactError { /// The product sends geometry only. The host decides which slots it can fill /// and never says which, so the product cannot learn who shared a profile. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct HostProfilePlaceContactAvatarsRequest { /// Width of the product's drawing surface, in the units of every rect: /// framebuffer pixels for a PolkaVM product, CSS pixels of its viewport @@ -138,7 +162,10 @@ pub struct HostProfilePlaceContactAvatarsRequest { /// One avatar the product draws for a chat contact. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct ContactAvatarSlot { /// Product-chosen id, stable for one on-screen avatar (a list row, a /// header). The host uses it only to keep what it draws stable across @@ -154,7 +181,10 @@ pub struct ContactAvatarSlot { /// A rectangle in surface units, relative to the surface's top-left corner. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct AvatarRect { /// Left edge. pub x: i32, @@ -168,7 +198,10 @@ pub struct AvatarRect { /// Contact avatar placement failure. Says nothing about any one slot. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum HostProfilePlaceContactAvatarsError { /// This host cannot draw over the product's surface. Unsupported, From 1b0b38b7d3ce448a44bfb3415d945e33274bd4bd Mon Sep 17 00:00:00 2001 From: w Date: Tue, 29 Sep 2026 13:51:13 -0400 Subject: [PATCH 17/30] test(profile): retain behavior coverage instead of capability echoes --- .../src/web/worker-provider.test.ts | 72 ------------------- rust/crates/truapi/src/lib.rs | 8 +-- 2 files changed, 4 insertions(+), 76 deletions(-) diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index 09c0260c0..1402a5815 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -348,78 +348,6 @@ describe("createWebWorkerPairingHostRuntime", () => { }); } - it("reports the chat capability to the worker when the host serves it", async () => { - const worker = new FakeWorker(); - void createWebWorkerPairingHostRuntime( - asWorker(worker), - makeHostCallbacks({ - chat: { createChatRoom: async () => ({ status: "New" }) }, - }), - { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, - ); - - worker.emit({ kind: "loaded" }); - - expect(lastMessageOfKind(worker, "init").capabilities).toEqual({ - chat: true, - permissionStatus: false, - pocket: false, - profile: false, - identityBackend: false, - coinageWallet: false, - contacts: false, - }); - }); - - it("reports the pocket capability to the worker when the host serves it", async () => { - const worker = new FakeWorker(); - void createWebWorkerPairingHostRuntime( - asWorker(worker), - makeHostCallbacks({ - pocket: { removePocketCard: async () => {} }, - }), - { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, - ); - - worker.emit({ kind: "loaded" }); - - // Without this the worker never builds the pocket callbacks, so a host - // that serves Pocket is answered `Unsupported` anyway. - expect(lastMessageOfKind(worker, "init").capabilities).toEqual({ - chat: false, - permissionStatus: false, - pocket: true, - profile: false, - identityBackend: false, - coinageWallet: false, - }); - }); - - it("reports the profile capability to the worker when the host serves it", async () => { - const worker = new FakeWorker(); - void createWebWorkerPairingHostRuntime( - asWorker(worker), - makeHostCallbacks({ - profile: { presentProfile: async () => {} }, - }), - { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, - ); - - worker.emit({ kind: "loaded" }); - - // Without this the worker never builds the profile callbacks, so a host - // that renders profiles is answered `Unsupported` anyway. - expect(lastMessageOfKind(worker, "init").capabilities).toEqual({ - chat: false, - permissionStatus: false, - pocket: false, - profile: true, - identityBackend: false, - coinageWallet: false, - contacts: false, - }); - }); - it("preserves optional authenticated identity search through the worker boundary", async () => { const worker = new FakeWorker(); const account = new Uint8Array(32).fill(0x42); diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index f31426682..8622f76b3 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -88,10 +88,10 @@ pub mod latest { use crate::versioned::{self, Versioned}; pub use crate::v01::{ - AllocatableResource, AllocationOutcome, Arrangement, AvatarRect, Background, BlendingMode, BorderStyle, - BoxProps, ButtonProps, ButtonVariant, ChainIdentifier, ChatAction, ChatActionLayout, - ChatActions, ChatBotRegistrationStatus, ChatCustomMessage, ChatFile, ChatMedia, - ChatMessageContent, ChatReaction, ChatRichText, ChatRoom, ChatRoomParticipation, + AllocatableResource, AllocationOutcome, Arrangement, AvatarRect, Background, BlendingMode, + BorderStyle, BoxProps, ButtonProps, ButtonVariant, ChainIdentifier, ChatAction, + ChatActionLayout, ChatActions, ChatBotRegistrationStatus, ChatCustomMessage, ChatFile, + ChatMedia, ChatMessageContent, ChatRichText, ChatRoom, ChatRoomParticipation, ChatRoomRegistrationStatus, ColorToken, ColumnProps, ContactHandle, ContactPickOutcome, ContentAlignment, ContextualAlias, DerivationIndex, Dimensions, Effect, EffectProps, GenericError, HorizontalAlignment, HostAccountCreateProofRequest, From c57f71d8e31f259f8c05b22fa2c9e4f7077994a4 Mon Sep 17 00:00:00 2001 From: w Date: Tue, 29 Sep 2026 14:10:59 -0400 Subject: [PATCH 18/30] fix(truapi): restore ChatReaction payload export --- rust/crates/truapi/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 8622f76b3..dcb65370c 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -91,7 +91,7 @@ pub mod latest { AllocatableResource, AllocationOutcome, Arrangement, AvatarRect, Background, BlendingMode, BorderStyle, BoxProps, ButtonProps, ButtonVariant, ChainIdentifier, ChatAction, ChatActionLayout, ChatActions, ChatBotRegistrationStatus, ChatCustomMessage, ChatFile, - ChatMedia, ChatMessageContent, ChatRichText, ChatRoom, ChatRoomParticipation, + ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, ChatRoom, ChatRoomParticipation, ChatRoomRegistrationStatus, ColorToken, ColumnProps, ContactHandle, ContactPickOutcome, ContentAlignment, ContextualAlias, DerivationIndex, Dimensions, Effect, EffectProps, GenericError, HorizontalAlignment, HostAccountCreateProofRequest, From 6778214613f0b9d1db1ccc1029cd078d7ff0139d Mon Sep 17 00:00:00 2001 From: w Date: Tue, 29 Sep 2026 11:37:44 -0400 Subject: [PATCH 19/30] feat(profile): own profile status, presentation and avatar slot Add `profile.ownStatus` (5) and `profile.presentOwn` (6), and version 2 of `profile.placeContactAvatars` (4) with an optional `own` slot for the signed-in user's avatar. The core fills the own slot from the wallet's disclosure, with `shared_at` set to its revision, and hands it to the existing `placeContactAvatars` callback in the same replacement set as contact avatars; disclosing or retracting redraws every remembered placement. A v0.1 placement upgrades to one without an own slot, so existing chat products keep working unchanged. Brings in 2de6d984f from feat/chat-own-profile-storage, reworked: the combined placement is method 4's V2 request rather than a separate method 7, since the Profile trait is still a prototype. The WASM bridge generator now checks error envelopes for recursion along the path only, so two versions of an envelope may carry the same payload. --- .changeset/profile-disclose.md | 6 + docs/rfcs/profile-disclosure.md | 9 ++ rust/crates/truapi-client/src/generated.rs | 62 ++++++- .../truapi-codegen/src/rust/wasm_bridge.rs | 13 ++ rust/crates/truapi/src/api/profile.rs | 56 ++++++- rust/crates/truapi/src/runtime.rs | 78 ++++++++- .../src/runtime/native_chat/actor/tests.rs | 3 +- .../truapi/src/runtime/profile/avatars.rs | 97 ++++++++--- rust/crates/truapi/src/runtime/tests.rs | 153 ++++++++++++++++++ rust/crates/truapi/src/v01/profile.rs | 48 ++++++ rust/crates/truapi/src/v02.rs | 2 + rust/crates/truapi/src/v02/profile.rs | 38 +++++ rust/crates/truapi/src/versioned/profile.rs | 76 ++++++++- 13 files changed, 599 insertions(+), 42 deletions(-) create mode 100644 rust/crates/truapi/src/v02/profile.rs diff --git a/.changeset/profile-disclose.md b/.changeset/profile-disclose.md index e8a46b5ef..c3eade0aa 100644 --- a/.changeset/profile-disclose.md +++ b/.changeset/profile-disclose.md @@ -31,3 +31,9 @@ The product is answered `Ok` whoever shared; only a malformed placement (more th 16384, a non-square avatar or one outside 1 to 1024 a side, a repeated slot) is refused, and a host that cannot draw answers `Unsupported`. A JS host that supplies a `profile` group must implement the callback; the Rust trait's default draws nothing. + +Add `profile.ownStatus` and `profile.presentOwn`, and an optional `own` slot in version 2 of +`profile.placeContactAvatars`. A chat product can report whether its signed-in user has configured a profile and ask +the host to present it without receiving the bearer reference. The core fills the own slot from the user's disclosure +and hands it to the existing callback in the same replacement set as the contact avatars, redraws it when the user +discloses or retracts, and still reveals nothing per slot. Version 1 placements keep working unchanged. diff --git a/docs/rfcs/profile-disclosure.md b/docs/rfcs/profile-disclosure.md index 8b5e6fc78..5467de2f1 100644 --- a/docs/rfcs/profile-disclosure.md +++ b/docs/rfcs/profile-disclosure.md @@ -251,6 +251,9 @@ forward another person's reference as their own. Nor can copies be erased: a ref whoever received it, directly or forwarded, keeps it and what it resolved; a retraction only stops a receiving host from presenting it. +`own_status` reports only whether the signed-in wallet has a current disclosure. `present_own` resolves that disclosure +and hands it to the same host presenter. Neither method returns the reference or profile contents to the product. + ### Placed avatars A chat product draws its own conversation list and header, so only it knows where each contact's avatar sits. It sends @@ -271,6 +274,12 @@ re-shared in a newer frame or is withdrawn it filters the same geometry again an product sending anything. Disposing the connection, or a placement made after the user signed out, clears what the host drew. +Version 2 of `place_contact_avatars` adds an optional `own` slot for where the product draws the signed-in user's own +avatar. The core fills it from the wallet's current disclosure, with `shared_at` set to the disclosure's revision, and +hands it to the host in the same `PlacedAvatars` set as the contact avatars, so one placement never replaces another's +overlay. Slot ids are unique across `own` and the contact slots. Disclosing or retracting redraws every remembered +placement for that wallet, as a contact's reference change does. A version 1 placement is one with no own slot. + No leak: the product must not learn who shared a profile. The core answers `Ok` to any well-formed placement from a signed-in user however many avatars, if any, are drawn; it returns nothing per slot, logs nothing about slots, and treats a host drawing failure as success, since it could depend on which avatars were drawn. Only what the product diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index ac47ff7b5..17478eacd 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "474f1270d1b5ac35"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "df3802dbfb5dcf65"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -1789,6 +1789,60 @@ impl RequestMethod for ProfilePlaceContactAvatars { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } +/// `profile_own_status` method marker. +pub struct ProfileOwnStatus; +impl ProfileOwnStatus { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Profile", + method: "own_status", + wire_name: "profile_own_status", + request_type: "truapi::versioned::profile::HostProfileOwnStatusRequest", + response_type: "truapi::versioned::profile::HostProfileOwnStatusResponse", + error_type: Some("truapi::versioned::profile::HostProfileOwnStatusError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 69, + method_id: 5, + }), + }; +} +impl RequestMethod for ProfileOwnStatus { + type Request = truapi::versioned::profile::HostProfileOwnStatusRequest; + type Response = truapi::versioned::profile::HostProfileOwnStatusResponse; + type Error = truapi::versioned::profile::HostProfileOwnStatusError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `profile_present_own` method marker. +pub struct ProfilePresentOwn; +impl ProfilePresentOwn { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Profile", + method: "present_own", + wire_name: "profile_present_own", + request_type: "truapi::versioned::profile::HostProfilePresentOwnRequest", + response_type: "truapi::versioned::profile::HostProfilePresentOwnResponse", + error_type: Some("truapi::versioned::profile::HostProfilePresentOwnError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 69, + method_id: 6, + }), + }; +} +impl RequestMethod for ProfilePresentOwn { + type Request = truapi::versioned::profile::HostProfilePresentOwnRequest; + type Response = truapi::versioned::profile::HostProfilePresentOwnResponse; + type Error = truapi::versioned::profile::HostProfilePresentOwnError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + /// `renderer_render` method marker. pub struct RendererRender; impl RendererRender { @@ -2479,6 +2533,8 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ ProfileRetract::DESCRIPTOR, ProfilePresentContact::DESCRIPTOR, ProfilePlaceContactAvatars::DESCRIPTOR, + ProfileOwnStatus::DESCRIPTOR, + ProfilePresentOwn::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, SigningCreateTransaction::DESCRIPTOR, SigningCreateTransactionWithLegacyAccount::DESCRIPTOR, @@ -2561,6 +2617,8 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ ProfileRetract::DESCRIPTOR, ProfilePresentContact::DESCRIPTOR, ProfilePlaceContactAvatars::DESCRIPTOR, + ProfileOwnStatus::DESCRIPTOR, + ProfilePresentOwn::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, SigningCreateTransaction::DESCRIPTOR, SigningCreateTransactionWithLegacyAccount::DESCRIPTOR, @@ -2650,6 +2708,8 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ ProfileRetract::DESCRIPTOR, ProfilePresentContact::DESCRIPTOR, ProfilePlaceContactAvatars::DESCRIPTOR, + ProfileOwnStatus::DESCRIPTOR, + ProfilePresentOwn::DESCRIPTOR, RendererRender::DESCRIPTOR, RendererActionSubscribe::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index b335fce5e..4cb9ecb1d 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -707,6 +707,9 @@ fn validate_error_type(err: &TypeRef, ctx: &BridgeCtx<'_>) -> Result<()> { validate_error_name(name, ctx, &mut seen) } +/// `seen` holds the envelopes on the path from the root, not every name +/// visited: two versions of one envelope may carry the same payload, which is +/// sharing, not recursion. fn validate_error_name<'a>( name: &'a str, ctx: &BridgeCtx<'a>, @@ -718,6 +721,16 @@ fn validate_error_name<'a>( if !seen.insert(name) { bail!("platform error type `{name}` contains a recursive alias/envelope"); } + let result = validate_error_def(name, ctx, seen); + seen.remove(name); + result +} + +fn validate_error_def<'a>( + name: &'a str, + ctx: &BridgeCtx<'a>, + seen: &mut BTreeSet<&'a str>, +) -> Result<()> { let Some(type_def) = resolve_alias_type(name, ctx) else { bail!("platform error type `{name}` is not present in the API definition"); }; diff --git a/rust/crates/truapi/src/api/profile.rs b/rust/crates/truapi/src/api/profile.rs index 80f8106ac..ab32cd0f0 100644 --- a/rust/crates/truapi/src/api/profile.rs +++ b/rust/crates/truapi/src/api/profile.rs @@ -2,9 +2,11 @@ use crate::versioned::profile::{ HostProfileDiscloseError, HostProfileDiscloseRequest, HostProfileDiscloseResponse, + HostProfileOwnStatusError, HostProfileOwnStatusRequest, HostProfileOwnStatusResponse, HostProfilePlaceContactAvatarsError, HostProfilePlaceContactAvatarsRequest, HostProfilePlaceContactAvatarsResponse, HostProfilePresentContactError, HostProfilePresentContactRequest, HostProfilePresentContactResponse, HostProfilePresentError, + HostProfilePresentOwnError, HostProfilePresentOwnRequest, HostProfilePresentOwnResponse, HostProfilePresentRequest, HostProfilePresentResponse, HostProfileRetractError, HostProfileRetractRequest, HostProfileRetractResponse, }; @@ -99,11 +101,13 @@ pub trait Profile: Send + Sync { Err(CallError::unavailable()) } - /// Tell the host where this product draws chat contacts' avatars, so it - /// can draw each contact's shared photo and mood ring over them on its own - /// layer. + /// Tell the host where this product draws chat contacts' avatars, and + /// optionally the signed-in user's own, so it can draw each shared photo + /// and mood ring over them on its own layer. /// - /// Each call replaces the product's placement; an empty `slots` clears it. + /// Each call replaces the product's placement; an empty `slots` and no + /// `own` clears it. The own slot is filled only while the user has + /// disclosed a profile, and redrawn when they disclose or retract one. /// The host draws only for contacts who shared a profile with the user, /// and keeps the placement current as they share or withdraw one, until /// the product replaces it or goes away. The answer is the same whoever @@ -115,14 +119,19 @@ pub trait Profile: Send + Sync { /// product gives: framebuffer pixels for a PolkaVM product, CSS pixels of /// its viewport for a web product. A placement with more than 64 slots, a /// surface side outside 1 to 16384, an avatar that is not square or is - /// outside 1 to 1024 a side, or a repeated `slot` is `Unknown`. A host that - /// cannot draw over the product is `Unsupported`; with no user signed in - /// the call is `NotConnected`. + /// outside 1 to 1024 a side, or a `slot` repeated across `own` and `slots` + /// is `Unknown`. A host that cannot draw over the product is + /// `Unsupported`; with no user signed in the call is `NotConnected`. /// /// ```ts /// const result = await truapi.profile.placeContactAvatars({ /// surfaceWidth: 360, /// surfaceHeight: 640, + /// own: { + /// slot: 1, + /// rect: { x: 300, y: 16, width: 44, height: 44 }, + /// clip: { x: 0, y: 0, width: 360, height: 640 }, + /// }, /// slots: [ /// { /// slot: 0, @@ -145,4 +154,37 @@ pub trait Profile: Send + Sync { > { Err(CallError::unavailable()) } + + /// Report whether the signed-in user has configured a profile. + /// + /// Only the boolean status returns. The profile reference and contents + /// remain host-owned. + /// + /// ```ts + /// const result = await truapi.profile.ownStatus(); + /// console.log("own profile configured:", result); + /// ``` + #[wire(id = 5)] + async fn own_status( + &self, + _cx: &CallContext, + _request: HostProfileOwnStatusRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Show the signed-in user's profile in host-owned UI. + /// + /// ```ts + /// const result = await truapi.profile.presentOwn(); + /// console.log("own profile presentation:", result); + /// ``` + #[wire(id = 6)] + async fn present_own( + &self, + _cx: &CallContext, + _request: HostProfilePresentOwnRequest, + ) -> Result> { + Err(CallError::unavailable()) + } } diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 7545b801a..e14b70366 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -114,9 +114,11 @@ use truapi::versioned::pocket::{ use truapi::versioned::preimage::RemotePreimageSubmitError; use truapi::versioned::profile::{ HostProfileDiscloseError, HostProfileDiscloseRequest, HostProfileDiscloseResponse, + HostProfileOwnStatusError, HostProfileOwnStatusRequest, HostProfileOwnStatusResponse, HostProfilePlaceContactAvatarsError, HostProfilePlaceContactAvatarsRequest, HostProfilePlaceContactAvatarsResponse, HostProfilePresentContactError, HostProfilePresentContactRequest, HostProfilePresentContactResponse, HostProfilePresentError, + HostProfilePresentOwnError, HostProfilePresentOwnRequest, HostProfilePresentOwnResponse, HostProfilePresentRequest, HostProfilePresentResponse, HostProfileRetractError, HostProfileRetractRequest, HostProfileRetractResponse, }; @@ -1763,6 +1765,9 @@ impl Profile for ProductRuntimeHost { .await .map_err(|reason| domain(v01::HostProfileDiscloseError::Unknown { reason }))?; self.profile_disclosure_changed(); + self.services + .contact_avatars + .redraw_owner(owner, &self.services.spawner); Ok(HostProfileDiscloseResponse::V1) } @@ -1795,6 +1800,9 @@ impl Profile for ProductRuntimeHost { .await .map_err(unknown)?; self.profile_disclosure_changed(); + self.services + .contact_avatars + .redraw_owner(owner, &self.services.spawner); Ok(HostProfileRetractResponse::V1) } } @@ -1877,8 +1885,9 @@ impl Profile for ProductRuntimeHost { return Err(CallError::Denied); } let platform = self.profile_platform()?; - let HostProfilePlaceContactAvatarsRequest::V1(request) = request; - let domain = |error| CallError::Domain(HostProfilePlaceContactAvatarsError::V1(error)); + // A v0.1 placement is a v0.2 one with no own slot. + let request = truapi::versioned::IntoLatest::into_latest(request); + let domain = |error| CallError::Domain(HostProfilePlaceContactAvatarsError::V2(error)); profile::avatars::validate(&request).map_err(|reason| { domain(v01::HostProfilePlaceContactAvatarsError::Unknown { reason }) })?; @@ -1902,9 +1911,72 @@ impl Profile for ProductRuntimeHost { placement .place(owner, request) .await - .map(|()| HostProfilePlaceContactAvatarsResponse::V1) + .map(|()| HostProfilePlaceContactAvatarsResponse::V2) .map_err(domain) } + + #[instrument(skip_all, fields(runtime.method = "profile.own_status"))] + async fn own_status( + &self, + _cx: &CallContext, + _request: HostProfileOwnStatusRequest, + ) -> Result> { + let domain = |error| CallError::Domain(HostProfileOwnStatusError::V1(error)); + let owner = self + .profile_owner() + .ok_or_else(|| domain(v01::HostProfileOwnStatusError::NotConnected))?; + let disclosure = profile::read_disclosure(self.platform.as_ref(), owner) + .await + .map_err(|reason| domain(v01::HostProfileOwnStatusError::Unknown { reason }))?; + if disclosure + .as_ref() + .is_some_and(|disclosure| !is_screened_profile_reference(&disclosure.reference)) + { + return Err(domain(v01::HostProfileOwnStatusError::Unknown { + reason: "stored profile disclosure is invalid".into(), + })); + } + Ok(HostProfileOwnStatusResponse::V1( + v01::HostProfileOwnStatusResponse { + configured: disclosure.is_some(), + }, + )) + } + + #[instrument(skip_all, fields(runtime.method = "profile.present_own"))] + async fn present_own( + &self, + _cx: &CallContext, + _request: HostProfilePresentOwnRequest, + ) -> Result> { + let platform = self.profile_platform()?; + let domain = |error| CallError::Domain(HostProfilePresentOwnError::V1(error)); + let owner = self + .profile_owner() + .ok_or_else(|| domain(v01::HostProfilePresentOwnError::NotConnected))?; + let reference = profile::read_disclosure(self.platform.as_ref(), owner) + .await + .map_err(|reason| domain(v01::HostProfilePresentOwnError::Unknown { reason }))? + .map(|disclosure| disclosure.reference) + .ok_or_else(|| domain(v01::HostProfilePresentOwnError::NotConfigured))?; + if !is_screened_profile_reference(&reference) { + return Err(domain(v01::HostProfilePresentOwnError::InvalidReference)); + } + platform + .present_profile(&self.product, v01::HostProfilePresentRequest { reference }) + .await + .map(|()| HostProfilePresentOwnResponse::V1) + .map_err(|error| { + domain(match error { + v01::HostProfilePresentError::InvalidReference => { + v01::HostProfilePresentOwnError::InvalidReference + } + v01::HostProfilePresentError::Unknown { reason } => { + v01::HostProfilePresentOwnError::Unknown { reason } + } + }) + }) + } } fn is_screened_profile_reference(reference: &str) -> bool { diff --git a/rust/crates/truapi/src/runtime/native_chat/actor/tests.rs b/rust/crates/truapi/src/runtime/native_chat/actor/tests.rs index 971e8f14c..745b8e552 100644 --- a/rust/crates/truapi/src/runtime/native_chat/actor/tests.rs +++ b/rust/crates/truapi/src/runtime/native_chat/actor/tests.rs @@ -2050,9 +2050,10 @@ fn contact_avatars_over_the_product_follow_what_the_contact_shares() { placement .place( profile::profile_owner(&fixture.context), - truapi::v01::HostProfilePlaceContactAvatarsRequest { + truapi::v02::HostProfilePlaceContactAvatarsRequest { surface_width: 360, surface_height: 640, + own: None, slots: vec![truapi::v01::ContactAvatarSlot { slot: 7, peer_identity: identity.account, diff --git a/rust/crates/truapi/src/runtime/profile/avatars.rs b/rust/crates/truapi/src/runtime/profile/avatars.rs index 96cfb4eef..35d4ae70e 100644 --- a/rust/crates/truapi/src/runtime/profile/avatars.rs +++ b/rust/crates/truapi/src/runtime/profile/avatars.rs @@ -1,21 +1,24 @@ -//! Contact avatars the host draws over a chat product. +//! Avatars the host draws over a chat product: its contacts' and the signed-in +//! user's own. //! -//! A product says where it draws each contact's avatar, by peer identity. The -//! core fills in the reference each contact shared and hands the host only the -//! avatars it can draw. The product gets the same answer whoever shared, and +//! A product says where it draws each contact's avatar, by peer identity, and +//! optionally where it draws the user's own. The core fills in the reference +//! each contact shared, and the user's own disclosure, and hands the host only +//! the avatars it can draw. The product gets the same answer whoever shared, and //! nothing about a slot is logged, so it cannot learn who shared a profile. //! //! The placement is kept per product connection, so a contact who shares or -//! withdraws later appears or disappears without the product sending it again. +//! withdraws later, or the user disclosing or retracting their own, appears or +//! disappears without the product sending it again. use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use tracing::debug; -use truapi::v01; +use truapi::{v01, v02}; use crate::platform::{PlacedAvatar, PlacedAvatars, Platform, ProductContext, ProfilePlatform}; -use super::{ProfileOwner, read_received}; +use super::{ProfileOwner, read_disclosure, read_received}; use crate::runtime::is_screened_profile_reference; use crate::subscription::Spawner; @@ -28,7 +31,7 @@ const MAX_AVATAR_SIDE: u32 = 1024; /// Why a placement is malformed, if it is. Only input the product controls is /// judged here, never what any contact shared. -pub(crate) fn validate(request: &v01::HostProfilePlaceContactAvatarsRequest) -> Result<(), String> { +pub(crate) fn validate(request: &v02::HostProfilePlaceContactAvatarsRequest) -> Result<(), String> { let surface = 1..=MAX_SURFACE_SIDE; if !surface.contains(&request.surface_width) || !surface.contains(&request.surface_height) { return Err(format!("surface sides must be 1 to {MAX_SURFACE_SIDE}")); @@ -36,17 +39,16 @@ pub(crate) fn validate(request: &v01::HostProfilePlaceContactAvatarsRequest) -> if request.slots.len() > MAX_SLOTS { return Err(format!("at most {MAX_SLOTS} contact avatars may be placed")); } - let mut seen = HashSet::with_capacity(request.slots.len()); - for slot in &request.slots { - let rect = slot.rect; + let mut seen = HashSet::with_capacity(request.slots.len() + usize::from(request.own.is_some())); + let own = request.own.iter().map(|own| (own.slot, own.rect)); + for (slot, rect) in own.chain(request.slots.iter().map(|slot| (slot.slot, slot.rect))) { if rect.width != rect.height || !(1..=MAX_AVATAR_SIDE).contains(&rect.width) { return Err(format!( - "avatar {} must be square and 1 to {MAX_AVATAR_SIDE} a side", - slot.slot + "avatar {slot} must be square and 1 to {MAX_AVATAR_SIDE} a side" )); } - if !seen.insert(slot.slot) { - return Err(format!("avatar slot {} is placed twice", slot.slot)); + if !seen.insert(slot) { + return Err(format!("avatar slot {slot} is placed twice")); } } Ok(()) @@ -65,7 +67,7 @@ pub(crate) struct ContactAvatarPlacement { #[derive(Default)] struct PlacementState { /// The last non-empty placement and the wallet it was drawn for. - placed: Option<(ProfileOwner, v01::HostProfilePlaceContactAvatarsRequest)>, + placed: Option<(ProfileOwner, v02::HostProfilePlaceContactAvatarsRequest)>, /// The connection is gone; nothing is drawn for it again. closed: bool, } @@ -90,7 +92,7 @@ impl ContactAvatarPlacement { pub(crate) async fn place( &self, owner: ProfileOwner, - request: v01::HostProfilePlaceContactAvatarsRequest, + request: v02::HostProfilePlaceContactAvatarsRequest, ) -> Result<(), v01::HostProfilePlaceContactAvatarsError> { let mut state = self.state.lock().await; if state.closed { @@ -98,7 +100,7 @@ impl ContactAvatarPlacement { } state.placed = None; self.draw(owner, &request).await?; - if !request.slots.is_empty() { + if request.own.is_some() || !request.slots.is_empty() { state.placed = Some((owner, request)); } Ok(()) @@ -121,9 +123,10 @@ impl ContactAvatarPlacement { let Some((_, request)) = state.placed.take() else { return; }; + let (surface_width, surface_height) = (request.surface_width, request.surface_height); let cleared = PlacedAvatars { - surface_width: request.surface_width, - surface_height: request.surface_height, + surface_width, + surface_height, avatars: Vec::new(), }; if let Err(error) = self @@ -135,7 +138,8 @@ impl ContactAvatarPlacement { } } - /// Draw the placement again after what `owner`'s contacts shared changed. + /// Draw the placement again after what `owner`'s contacts shared, or what + /// `owner` disclosed, changed. async fn redraw(&self, owner: ProfileOwner) { let state = self.state.lock().await; let Some((placed_for, request)) = state.placed.as_ref() else { @@ -152,15 +156,35 @@ impl ContactAvatarPlacement { async fn draw( &self, owner: ProfileOwner, - request: &v01::HostProfilePlaceContactAvatarsRequest, + request: &v02::HostProfilePlaceContactAvatarsRequest, ) -> Result<(), v01::HostProfilePlaceContactAvatarsError> { - let avatars = self + let unknown = |reason| v01::HostProfilePlaceContactAvatarsError::Unknown { reason }; + let mut avatars = self .drawable(owner, &request.slots) .await - .map_err(|reason| v01::HostProfilePlaceContactAvatarsError::Unknown { reason })?; + .map_err(unknown)?; + if let Some(own) = request.own { + let disclosure = read_disclosure(self.storage.as_ref(), owner) + .await + .map_err(unknown)?; + if let Some(disclosure) = + disclosure.filter(|disclosure| is_screened_profile_reference(&disclosure.reference)) + { + avatars.push(PlacedAvatar { + slot: own.slot, + rect: own.rect, + clip: own.clip, + reference: disclosure.reference, + // Every disclosure takes a newer revision, so a host that + // caches by `shared_at` refetches the user's new profile. + shared_at: disclosure.revision, + }); + } + } + let (surface_width, surface_height) = (request.surface_width, request.surface_height); let placed = PlacedAvatars { - surface_width: request.surface_width, - surface_height: request.surface_height, + surface_width, + surface_height, avatars, }; match self @@ -272,4 +296,25 @@ impl ContactAvatarPlacements { } })); } + + /// Redraw every placement for `owner` after their own disclosed profile + /// changed. Any product may draw the user's own avatar, so every + /// placement is redrawn, not only the discloser's. + pub(crate) fn redraw_owner(&self, owner: ProfileOwner, spawner: &Spawner) { + let placements = self + .by_runtime + .lock() + .expect("contact avatar placements mutex poisoned") + .values() + .cloned() + .collect::>(); + if placements.is_empty() { + return; + } + spawner(Box::pin(async move { + for placement in placements { + placement.redraw(owner).await; + } + })); + } } diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 426b680d4..f70b84186 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -2433,6 +2433,74 @@ fn present_contact( )) } +fn own_profile_status( + host: &ProductRuntimeHost, +) -> Result> { + futures::executor::block_on(Profile::own_status( + host, + &CallContext::default(), + HostProfileOwnStatusRequest::V1, + )) +} + +fn present_own_profile( + host: &ProductRuntimeHost, +) -> Result> { + futures::executor::block_on(Profile::present_own( + host, + &CallContext::default(), + HostProfilePresentOwnRequest::V1, + )) +} + +#[test] +fn own_profile_status_and_presentation_resolve_the_host_owned_disclosure() { + let platform = stub_platform(); + let presented = Arc::new(RecordingProfilePlatform::default()); + let chat = signed_in( + profile_host_on(platform.clone(), egui_chat(), Some(presented.clone())), + WALLET, + ); + let owner = owner_of(&chat); + assert_eq!( + own_profile_status(&chat).expect("status is available"), + HostProfileOwnStatusResponse::V1(v01::HostProfileOwnStatusResponse { configured: false }) + ); + assert!(matches!( + present_own_profile(&chat), + Err(CallError::Domain(HostProfilePresentOwnError::V1( + v01::HostProfilePresentOwnError::NotConfigured + ))) + )); + + futures::executor::block_on(profile::write_disclosure( + platform.as_ref(), + owner, + &profile::Disclosure { + product_id: "seity.dot".to_string(), + reference: CONTACTS_REFERENCE.to_string(), + revision: 1, + }, + )) + .expect("own disclosure stored"); + assert_eq!( + own_profile_status(&chat).expect("status is available"), + HostProfileOwnStatusResponse::V1(v01::HostProfileOwnStatusResponse { configured: true }) + ); + assert_eq!( + present_own_profile(&chat).expect("own profile is presented"), + HostProfilePresentOwnResponse::V1 + ); + assert_eq!( + presented + .presented + .lock() + .expect("presented mutex poisoned") + .as_slice(), + [("egui-chat.dot".to_string(), CONTACTS_REFERENCE.to_string())] + ); +} + const CONTACTS_REFERENCE: &str = "seity-contacts:v1:5c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb535c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb53"; const WALLET: [u8; 32] = [0x57; 32]; @@ -2960,16 +3028,101 @@ fn placed_avatars(avatars: Vec) -> crate::platfor } } +/// Place as a v0.1 caller, answered as the dispatcher answers one. fn place_avatars( host: &ProductRuntimeHost, request: v01::HostProfilePlaceContactAvatarsRequest, ) -> Result> { + use truapi::versioned::{FromLatest, IntoLatest}; futures::executor::block_on(Profile::place_contact_avatars( host, &CallContext::default(), HostProfilePlaceContactAvatarsRequest::V1(request), )) + .map(|response| HostProfilePlaceContactAvatarsResponse::from_latest(response.into_latest(), 1)) + .map_err(|error| truapi::frame::downgrade_call_error(error, 1)) +} + +/// Place as a v0.2 caller, which may add the user's own avatar. +fn place_profile_avatars( + host: &ProductRuntimeHost, + request: v02::HostProfilePlaceContactAvatarsRequest, +) -> Result> +{ + futures::executor::block_on(Profile::place_contact_avatars( + host, + &CallContext::default(), + HostProfilePlaceContactAvatarsRequest::V2(request), + )) +} + +#[test] +fn own_avatar_placement_draws_the_disclosed_profile_without_returning_its_reference() { + let platform = stub_platform(); + let avatars = Arc::new(RecordingAvatarHost::default()); + let chat = signed_in(avatar_host(&platform, egui_chat(), &avatars), WALLET); + let owner = owner_of(&chat); + futures::executor::block_on(profile::write_disclosure( + platform.as_ref(), + owner, + &profile::Disclosure { + product_id: "seity.dot".to_string(), + reference: CONTACTS_REFERENCE.to_string(), + revision: 7, + }, + )) + .expect("own disclosure stored"); + + assert_eq!( + place_profile_avatars( + &chat, + v02::HostProfilePlaceContactAvatarsRequest { + surface_width: 360, + surface_height: 640, + own: Some(v02::OwnAvatarSlot { + slot: 0, + rect: avatar_rect(16, 80, 44), + clip: AVATAR_CLIP, + }), + slots: Vec::new(), + }, + ) + .expect("own placement accepted"), + HostProfilePlaceContactAvatarsResponse::V2 + ); + assert_eq!( + avatars.placements(), + [( + "egui-chat.dot".to_string(), + placed_avatars(vec![crate::platform::PlacedAvatar { + shared_at: 7, + ..placed_avatar(0, CONTACTS_REFERENCE) + }]) + )] + ); +} + +#[test] +fn own_and_contact_slots_share_one_slot_namespace() { + let platform = stub_platform(); + let avatars = Arc::new(RecordingAvatarHost::default()); + let chat = signed_in(avatar_host(&platform, egui_chat(), &avatars), WALLET); + let mut request = truapi::versioned::IntoLatest::into_latest( + HostProfilePlaceContactAvatarsRequest::V1(avatar_placement(&[(0, [0xa1; 32])])), + ); + request.own = Some(v02::OwnAvatarSlot { + slot: 0, + rect: avatar_rect(16, 16, 44), + clip: AVATAR_CLIP, + }); + assert!(matches!( + place_profile_avatars(&chat, request), + Err(CallError::Domain(HostProfilePlaceContactAvatarsError::V2( + v01::HostProfilePlaceContactAvatarsError::Unknown { .. } + ))) + )); + assert!(avatars.placements().is_empty()); } #[test] diff --git a/rust/crates/truapi/src/v01/profile.rs b/rust/crates/truapi/src/v01/profile.rs index c11dd2afe..4d4425378 100644 --- a/rust/crates/truapi/src/v01/profile.rs +++ b/rust/crates/truapi/src/v01/profile.rs @@ -196,6 +196,54 @@ pub struct AvatarRect { pub height: u32, } +/// Whether the signed-in user currently has a profile disclosed through the +/// host. The reference itself never crosses into the product. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct HostProfileOwnStatusResponse { + /// `true` when the host holds a current own-profile reference. + pub configured: bool, +} + +/// Failure while querying the signed-in user's profile status. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] +pub enum HostProfileOwnStatusError { + /// No user is signed in. + NotConnected, + /// Catch-all. + Unknown { + /// Human-readable reason. + reason: String, + }, +} + +/// Failure while presenting the signed-in user's profile. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] +pub enum HostProfilePresentOwnError { + /// The signed-in user has not configured a profile. + NotConfigured, + /// The host holds a reference it cannot parse. + InvalidReference, + /// No user is signed in. + NotConnected, + /// Catch-all. + Unknown { + /// Human-readable reason. + reason: String, + }, +} + /// Contact avatar placement failure. Says nothing about any one slot. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] #[cfg_attr( diff --git a/rust/crates/truapi/src/v02.rs b/rust/crates/truapi/src/v02.rs index ea782764b..c4a8a1826 100644 --- a/rust/crates/truapi/src/v02.rs +++ b/rust/crates/truapi/src/v02.rs @@ -6,6 +6,8 @@ mod account; mod local_storage; +mod profile; pub use account::*; pub use local_storage::*; +pub use profile::*; diff --git a/rust/crates/truapi/src/v02/profile.rs b/rust/crates/truapi/src/v02/profile.rs new file mode 100644 index 000000000..87aac2171 --- /dev/null +++ b/rust/crates/truapi/src/v02/profile.rs @@ -0,0 +1,38 @@ +use alloc::vec::Vec; +use parity_scale_codec::{Decode, Encode}; + +use crate::v01::{AvatarRect, ContactAvatarSlot}; + +/// Where a chat product draws avatars the host fills in: its contacts' and, +/// optionally, the signed-in user's own. +/// +/// v0.2 adds `own` to the v0.1 placement. A v0.1 placement is this one with no +/// own slot, which is exactly what v0.1 meant. Both kinds live in one +/// placement so a product never has two placements replacing each other's +/// overlay. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostProfilePlaceContactAvatarsRequest { + /// Width of the product's drawing surface, in the units of every rect: + /// framebuffer pixels for a PolkaVM product, CSS pixels of its viewport + /// for a web product. 1 to 16384. + pub surface_width: u32, + /// Height of the drawing surface, in the same units. 1 to 16384. + pub surface_height: u32, + /// Where the signed-in user's own avatar is drawn, if the product draws + /// one. The host fills it only when the user has disclosed a profile. + pub own: Option, + /// Replaces the product's previous placement entirely; empty clears it. + /// At most 64, each with its own `slot`, unique across `own` too. + pub slots: Vec, +} + +/// Where the product draws the signed-in user's own avatar. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct OwnAvatarSlot { + /// Product-chosen id, unique within this placement. + pub slot: u32, + /// Bounding box of the avatar circle: square, 1 to 1024 units a side. + pub rect: AvatarRect, + /// Visible region the avatar is cut to. + pub clip: AvatarRect, +} diff --git a/rust/crates/truapi/src/versioned/profile.rs b/rust/crates/truapi/src/versioned/profile.rs index 60127b43c..8583d6b04 100644 --- a/rust/crates/truapi/src/versioned/profile.rs +++ b/rust/crates/truapi/src/versioned/profile.rs @@ -1,6 +1,11 @@ //! Versioned wrappers for [`Profile`](crate::api::Profile) methods. +//! +//! v0.2 of `place_contact_avatars` adds an optional slot for the signed-in +//! user's own avatar. A v0.1 placement upgrades to one with no own slot, which +//! is exactly what v0.1 meant; the response and error keep their v0.1 shape. -use crate::v01; +use crate::versioned::{FromLatest, IntoLatest}; +use crate::{v01, v02}; truapi_macros::versioned_type! { pub enum HostProfilePresentRequest { V1 => v01::HostProfilePresentRequest } @@ -15,7 +20,70 @@ truapi_macros::versioned_type! { pub enum HostProfilePresentContactRequest { V1 => v01::HostProfilePresentContactRequest } pub enum HostProfilePresentContactResponse { V1 } pub enum HostProfilePresentContactError { V1 => v01::HostProfilePresentContactError } - pub enum HostProfilePlaceContactAvatarsRequest { V1 => v01::HostProfilePlaceContactAvatarsRequest } - pub enum HostProfilePlaceContactAvatarsResponse { V1 } - pub enum HostProfilePlaceContactAvatarsError { V1 => v01::HostProfilePlaceContactAvatarsError } + pub enum HostProfilePlaceContactAvatarsRequest { + V1 => v01::HostProfilePlaceContactAvatarsRequest, + V2 => v02::HostProfilePlaceContactAvatarsRequest, + } + pub enum HostProfilePlaceContactAvatarsResponse { V1, V2 } + pub enum HostProfilePlaceContactAvatarsError { + V1 => v01::HostProfilePlaceContactAvatarsError, + V2 => v01::HostProfilePlaceContactAvatarsError, + } + pub enum HostProfileOwnStatusRequest { V1 } + pub enum HostProfileOwnStatusResponse { V1 => v01::HostProfileOwnStatusResponse } + pub enum HostProfileOwnStatusError { V1 => v01::HostProfileOwnStatusError } + pub enum HostProfilePresentOwnRequest { V1 } + pub enum HostProfilePresentOwnResponse { V1 } + pub enum HostProfilePresentOwnError { V1 => v01::HostProfilePresentOwnError } +} + +impl IntoLatest for HostProfilePlaceContactAvatarsRequest { + fn into_latest(self) -> Self::Latest { + match self { + Self::V1(v01::HostProfilePlaceContactAvatarsRequest { + surface_width, + surface_height, + slots, + }) => v02::HostProfilePlaceContactAvatarsRequest { + surface_width, + surface_height, + own: None, + slots, + }, + Self::V2(latest) => latest, + } + } +} + +// The response and error did not change shape in v0.2. They still gain a V2 +// variant, because a method's version is uniform across its request, response +// and error — without one the generated client would keep every placement +// pinned to V1 and no product could reach the own slot. + +impl IntoLatest for HostProfilePlaceContactAvatarsResponse { + fn into_latest(self) -> Self::Latest {} +} + +impl FromLatest for HostProfilePlaceContactAvatarsResponse { + fn from_latest((): Self::Latest, target: u8) -> Self { + if target >= 2 { Self::V2 } else { Self::V1 } + } +} + +impl IntoLatest for HostProfilePlaceContactAvatarsError { + fn into_latest(self) -> Self::Latest { + match self { + Self::V1(error) | Self::V2(error) => error, + } + } +} + +impl FromLatest for HostProfilePlaceContactAvatarsError { + fn from_latest(latest: Self::Latest, target: u8) -> Self { + if target >= 2 { + Self::V2(latest) + } else { + Self::V1(latest) + } + } } From a802b5249158f928446d8ea6dfb4d0a57c41a4f6 Mon Sep 17 00:00:00 2001 From: w Date: Tue, 29 Sep 2026 16:01:06 -0400 Subject: [PATCH 20/30] test(profile): convert unit responses before version downgrading --- .changeset/profile-disclose.md | 1 + rust/crates/truapi/src/runtime/tests.rs | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.changeset/profile-disclose.md b/.changeset/profile-disclose.md index c3eade0aa..e07b2a0de 100644 --- a/.changeset/profile-disclose.md +++ b/.changeset/profile-disclose.md @@ -37,3 +37,4 @@ Add `profile.ownStatus` and `profile.presentOwn`, and an optional `own` slot in the host to present it without receiving the bearer reference. The core fills the own slot from the user's disclosure and hands it to the existing callback in the same replacement set as the contact avatars, redraws it when the user discloses or retracts, and still reveals nothing per slot. Version 1 placements keep working unchanged. +The avatar regression suite also exercises version-1 response downgrading alongside the version-2 own-profile slot. diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index f70b84186..35b53cf79 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -3040,7 +3040,10 @@ fn place_avatars( &CallContext::default(), HostProfilePlaceContactAvatarsRequest::V1(request), )) - .map(|response| HostProfilePlaceContactAvatarsResponse::from_latest(response.into_latest(), 1)) + .map(|response| { + let () = response.into_latest(); + HostProfilePlaceContactAvatarsResponse::from_latest((), 1) + }) .map_err(|error| truapi::frame::downgrade_call_error(error, 1)) } From cbb13e8c9ebe9143c9261c1419c098ab5dba4b54 Mon Sep 17 00:00:00 2001 From: w Date: Tue, 29 Sep 2026 14:11:53 -0400 Subject: [PATCH 21/30] fix(ci): retain iOS test failure diagnostics --- .github/workflows/ios-pr.yml | 24 +++++++++++++++++++----- hosts/ios/README.md | 9 +++++++++ hosts/ios/fastlane/lanes/lane_tests.rb | 15 ++++++--------- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ios-pr.yml b/.github/workflows/ios-pr.yml index df440f4c9..b06f0fdea 100644 --- a/.github/workflows/ios-pr.yml +++ b/.github/workflows/ios-pr.yml @@ -91,8 +91,8 @@ env: # DEBUG MODE: Set to 'true' to enable enhanced debugging for build failures # What it does: # - Enables verbose xcodebuild logging - # - Collects and uploads build artifacts (logs, test results) - # - Continues build even on failures to gather maximum information + # - Collects and uploads build artifacts + # Test results and diagnostics are uploaded regardless of this setting. DEBUG_CI: false jobs: @@ -469,13 +469,27 @@ jobs: bundle exec fastlane run_unit_tests fi - # Upload test artifacts for debugging - only when DEBUG_CI is enabled + - name: Export test diagnostics + if: always() + working-directory: hosts/ios + run: | + set -euo pipefail + result="fastlane/test_output/polkadot-app.xcresult" + if [[ -d "$result" ]]; then + xcrun xcresulttool get test-results summary --path "$result" + xcrun xcresulttool get test-results tests --path "$result" + xcrun xcresulttool export diagnostics --path "$result" \ + --output-path fastlane/test_output/diagnostics + fi + - name: Upload test artifacts - if: env.DEBUG_CI == 'true' && (failure() || success()) + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: test-artifacts-${{ github.run_number }} + name: test-artifacts-${{ github.run_id }}-${{ github.run_attempt }} path: | hosts/ios/fastlane/test_output/ hosts/ios/fastlane/build_logs/ + ~/Library/Logs/DiagnosticReports/*.ips + ~/Library/Logs/DiagnosticReports/*.crash retention-days: 3 diff --git a/hosts/ios/README.md b/hosts/ios/README.md index efbc71410..2a5ab9735 100644 --- a/hosts/ios/README.md +++ b/hosts/ios/README.md @@ -95,6 +95,15 @@ xcodebuild test -project polkadot-app.xcodeproj -scheme polkadot-appTests \ -destination 'platform=iOS Simulator,name=iPhone 16' ``` +The iOS CI test job runs `RUN_IN_CI=true bundle exec fastlane run_unit_tests` +from `hosts/ios`. It retains the `.xcresult` bundle, raw xcodebuild log, +simulator logs, and available crash reports in +`test-artifacts--` for three days, even when tests fail. +The job log also contains the `xcresulttool` test summary and test list. +Download the artifact from the workflow run and open its `.xcresult` in Xcode +to inspect individual failures and diagnostics. `debug:true` adds raw console +output without changing whether test failures fail the job. + ## How it works Polkadot iOS is a self-custodial superapp: your keys are created on your phone, stay on your phone, and everything else — identity, chat, payments, apps — is built on top of them using Polkadot's public chains instead of company servers. diff --git a/hosts/ios/fastlane/lanes/lane_tests.rb b/hosts/ios/fastlane/lanes/lane_tests.rb index 559b6648b..e88b0bb4e 100644 --- a/hosts/ios/fastlane/lanes/lane_tests.rb +++ b/hosts/ios/fastlane/lanes/lane_tests.rb @@ -19,19 +19,16 @@ # (DevCI), which disables testability and breaks @testable imports in package tests xcargs: "-skipPackagePluginValidation -skipMacroValidation ENABLE_TESTABILITY=YES RUN_IN_CI=#{ENV['RUN_IN_CI']}", output_directory: "./fastlane/test_output/", + result_bundle: true, + buildlog_path: "./fastlane/build_logs/", + include_simulator_logs: true, disable_concurrent_testing: true } - # DEBUG MODE: Helps diagnose build failures (Crashlytics, dependencies, etc.) - # Enables: verbose logs, saves build artifacts, continues on failure - # Use when: normal builds fail with unclear errors + # Raw console output is optional; result bundles and logs are always retained. if debug_mode - UI.important "🔍 Debug mode enabled - verbose logging and artifacts will be collected" - scan_params.merge!({ - buildlog_path: "./fastlane/build_logs/", # Saves xcodebuild logs for analysis - xcpretty_args: "--verbose", # Shows full xcodebuild output - fail_build: false # Continues to collect maximum info even on failure - }) + UI.important "Debug mode enabled - showing raw xcodebuild output" + scan_params[:xcodebuild_formatter] = "" end scan(scan_params) From 0d34d46e2fb3b29177f4e878bd904fad0bc5a7b6 Mon Sep 17 00:00:00 2001 From: w Date: Tue, 29 Sep 2026 20:06:45 -0400 Subject: [PATCH 22/30] feat(profile): share independently with apps and selected contacts --- .changeset/profile-disclose.md | 31 +- README.md | 1 + docs/rfcs/profile-disclosure.md | 271 +++---- js/packages/truapi-host/README.md | 20 +- rust/crates/truapi-chat-v2/src/lib.rs | 107 ++- rust/crates/truapi-client/src/generated.rs | 2 +- rust/crates/truapi/src/api/contacts.rs | 11 +- rust/crates/truapi/src/api/profile.rs | 38 +- rust/crates/truapi/src/host_core.rs | 29 +- rust/crates/truapi/src/lib.rs | 15 +- rust/crates/truapi/src/platform.rs | 25 +- rust/crates/truapi/src/platform/mock.rs | 11 +- rust/crates/truapi/src/runtime.rs | 331 ++++++--- rust/crates/truapi/src/runtime/chat_device.rs | 30 + .../truapi/src/runtime/native_chat/actor.rs | 57 +- .../src/runtime/native_chat/actor/history.rs | 57 +- .../src/runtime/native_chat/actor/profile.rs | 343 +++++++-- .../src/runtime/native_chat/actor/receive.rs | 6 +- .../src/runtime/native_chat/actor/tests.rs | 675 +++++++++++++++++- rust/crates/truapi/src/runtime/profile.rs | 282 +++++++- .../truapi/src/runtime/profile/avatars.rs | 191 ++++- rust/crates/truapi/src/runtime/services.rs | 7 +- rust/crates/truapi/src/runtime/tests.rs | 430 ++++++++++- rust/crates/truapi/src/v02/profile.rs | 69 +- rust/crates/truapi/src/v03.rs | 2 + rust/crates/truapi/src/v03/profile.rs | 31 + rust/crates/truapi/src/versioned/profile.rs | 254 ++++++- 27 files changed, 2818 insertions(+), 508 deletions(-) create mode 100644 rust/crates/truapi/src/v03/profile.rs diff --git a/.changeset/profile-disclose.md b/.changeset/profile-disclose.md index e07b2a0de..1d78e8952 100644 --- a/.changeset/profile-disclose.md +++ b/.changeset/profile-disclose.md @@ -9,16 +9,16 @@ that contact disclosed, so no product holds a contact's reference. The first `di once through `userConfirmation.confirmPermission` with a new `ProfileDisclosure` review, remembered as the `ProfileDisclosure` permission; a refusal is `PermissionDenied`. Hosts must render that review. -This change includes the Chat relay. The host sends the disclosure to every ready Chat v2 contact as a host-private -message and keeps, per contact, the newest frame their host sent back, withdrawals included, whatever order the chat -product opens them in. Both live in wallet- and network-scoped core storage (`ProfileDisclosure`, -`ProfileReferencesReceived`). The host queues the disclosure when the chat product initializes or reconciles, in the +This change includes the Chat relay. In legacy `ChatApps` mode the host sends the disclosure to every ready Chat v2 +contact as a host-private app-scoped message and keeps, per contact, the newest frame their host sent back, withdrawals +included, whatever order the chat product opens them in. Both live in wallet- and network-scoped core storage +(`ProfileDisclosure`, `ProfileReferencesReceived`). The host queues the disclosure when the chat product initializes or reconciles, in the response to a Chat request in which a contact became ready, and, without delaying the call, as soon as `disclose` or `retract` changes it while a Chat of the same wallet is open; the chat product still has to run to submit it. Delivery is best effort: relayed references never take outbox room from other Chat traffic, and one that lapses unacknowledged after a statement lifetime is signed again for a ready contact, at most three frames per contact and disclosure. Every `disclose` call is a new disclosure, even with the reference already held: a profile whose record changed behind the -same reference is sent to every ready contact again, with a fresh attempt count. +same reference is sent to the selected ready recipients again, with a fresh attempt count. Add `profile.placeContactAvatars`. A chat App tells the host where it draws contacts' avatars (surface size and, per avatar, a slot id, peer identity, square rect and clip), and the host draws the photo and mood ring of each contact who @@ -38,3 +38,24 @@ the host to present it without receiving the bearer reference. The core fills th and hands it to the existing callback in the same replacement set as the contact avatars, redraws it when the user discloses or retracts, and still reveals nothing per slot. Version 1 placements keep working unchanged. The avatar regression suite also exercises version-1 response downgrading alongside the version-2 own-profile slot. + +Version 2 of `profile.disclose` adds explicit `ChatApps`, `App { productId }`, and +`Contacts { handles }` audiences. App-scoped and selected-contact personal grants coexist: personal grants are +host-renderable across products, never returned to them. All handles are verified against the host Contacts lookup +before committing the replacement; empty audiences configure only the user's own profile. Existing V1 calls retain +their app-scoped all-Chat behavior. Groups remain product-owned sets of opaque handles, not a new host group API. + +Personal relay uses distinct Chat content 22 (scope 1) and wallet/network-scoped +`ProfilePersonalReferencesReceived` storage. App content 21 is unchanged. Durable revisions, separate scoped +watermarks and withdrawal tombstones prevent an older personal share delivered through another app from reviving a +withdrawn grant. Removing one audience does not revoke an overlapping grant in another scope. Delivery still requires +a ready authenticated Chat channel and a running transport product; Contacts membership alone creates neither. + +Version 2 of `profile.presentContact` accepts either a peer identity or a Contacts handle and hides profile +availability, including host rendering failures. V1 retains its app-only lookup and errors, so it cannot probe new +cross-app personal grants. Version 3 of `profile.placeContactAvatars` accepts the same selectors alongside the own slot; +V1/V2 placement bytes and replies remain compatible. Contacts-change notifications invalidate cached handle lookups +and refresh remembered avatars. App-specific references take precedence over personal ones; personal updates redraw +all affected wallet placements. +Personal revisions also advance the host-rendered freshness timestamp when a newer share arrives through an actor +whose clock is older, preventing a same-reference update from leaving stale cached profile contents. diff --git a/README.md b/README.md index 7229684c8..e7a743260 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ TypeScript client, and hosts and products implement against the same shared type - [TrUAPI reference](https://docs.polkadot.com/reference/apps/protocol/truapi/) - [Rust API reference](https://paritytech.github.io/host-rust-core/) - [Draft: Host-owned native Chat and main-purse payments](docs/rfcs/native-chat-main-purse.md) +- [Draft: Profile disclosure audiences and host-rendered contacts](docs/rfcs/profile-disclosure.md) diff --git a/docs/rfcs/profile-disclosure.md b/docs/rfcs/profile-disclosure.md index 5467de2f1..67c55627d 100644 --- a/docs/rfcs/profile-disclosure.md +++ b/docs/rfcs/profile-disclosure.md @@ -8,11 +8,11 @@ status: draft ## Summary -A product hands the host one opaque profile reference for the user's chat contacts. The host relays it to each contact -over Chat v2 and keeps the references contacts relay back. A chat product then asks the host to show a contact's profile -by naming the contact, and the host presents the reference that contact disclosed through the existing `profile.present` -path. A chat product may also tell the host where it draws contacts' avatars, and the host draws each sharing contact's -photo and mood ring there on its own layer. No product holds another user's reference, and none learns who shared one. +A product hands the host one opaque profile reference and an audience policy. The host relays app-scoped grants or +personal grants to selected Contacts handles over authenticated Chat v2 channels, and keeps received references +inside the host. An app-scoped grant is renderable in that app; a personal grant is renderable across apps on the +recipient's host. Products request host-owned drawers and avatar overlays by peer identity or opaque handle, never +receive another user's reference, and cannot inspect the host's rendering. ## Motivation @@ -23,11 +23,11 @@ the hosts, and Chat v2 leaves ordinary delivery to products. ## Requirements -- **Blind:** the disclosing product never learns who the contacts are. +- **Blind:** products may retain selected opaque handles, but receive no contact names, accounts or contact enumeration. - **Sealed:** no product reads a reference in transit or at rest, on either side. - **Bound:** a presented profile is the one that contact's host sent, not one a product chose. - **Stable:** a change to the referenced profile does not require relaying again. -- **Withdrawable:** the discloser can retract, and contacts drop what they hold. +- **Withdrawable:** narrowing an audience withdraws its grant; recipients stop rendering it once the withdrawal arrives. - **Unobservable:** a product that shows contacts' avatars cannot tell which contacts shared a profile. ## Approach @@ -36,160 +36,98 @@ The design has six parts: - The `Profile` trait gains `disclose`, `retract`, `present_contact` and `place_contact_avatars`. - `disclose` asks the user once per product before anything is stored. -- Core storage holds the user's disclosure and the references received per chat product. +- Core storage holds the user's disclosure, app-scoped received references and wallet-wide personal received references. - The Chat v2 actor relays disclosures through its host-private outbox. - `present_contact` hands the host the stored reference and the contact who sent it. - `place_contact_avatars` substitutes stored references into a host-drawn avatar layer. ### Trait +`Profile` uses wire trait **69**. This change preserves that address and the existing method IDs. + +| Method | ID | Request versions | +| --- | --- | --- | +| `present` | 0 | V1: reference supplied by the caller | +| `disclose` | 1 | V1: reference; V2: reference plus audiences | +| `retract` | 2 | V1 | +| `present_contact` | 3 | V1: peer identity; V2: peer or Contacts handle | +| `place_contact_avatars` | 4 | V1: peer slots; V2: optional own slot; V3: peer/handle slots plus own | +| `own_status` | 5 | V1 | +| `present_own` | 6 | V1 | + +The canonical payloads are in `truapi::latest`; wire envelopes are in `truapi::versioned::profile`. +The new audience and selector shapes are: + ```rust -#[wire_trait(id = 69)] -#[crate::async_trait] -pub trait Profile: Send + Sync { - /// Show the referenced profile in host-owned UI. - #[wire(id = 0)] - async fn present( - &self, - _cx: &CallContext, - _request: HostProfilePresentRequest, - ) -> Result> { - Err(CallError::unavailable()) - } - - /// Give the user's chat contacts this reference. App executions only. - #[wire(id = 1)] - async fn disclose( - &self, - _cx: &CallContext, - _request: HostProfileDiscloseRequest, - ) -> Result> { - Err(CallError::unavailable()) - } - - /// Withdraw the reference this product disclosed. - #[wire(id = 2)] - async fn retract( - &self, - _cx: &CallContext, - _request: HostProfileRetractRequest, - ) -> Result> { - Err(CallError::unavailable()) - } - - /// Show the profile a chat contact disclosed. - #[wire(id = 3)] - async fn present_contact( - &self, - _cx: &CallContext, - _request: HostProfilePresentContactRequest, - ) -> Result> { - Err(CallError::unavailable()) - } - - /// Say where this product draws contacts' avatars. App executions only. - #[wire(id = 4)] - async fn place_contact_avatars( - &self, - _cx: &CallContext, - _request: HostProfilePlaceContactAvatarsRequest, - ) -> Result< - HostProfilePlaceContactAvatarsResponse, - CallError, - > { - Err(CallError::unavailable()) - } +pub enum ProfileAudience { + ChatApps, + App { product_id: String }, + Contacts { handles: Vec }, } - pub struct HostProfileDiscloseRequest { - /// Opaque reference, screened like a `present` reference. pub reference: String, + pub audiences: Vec, } -pub enum HostProfileDiscloseError { - /// The reference is empty, too long, or not printable ASCII. - InvalidReference, - /// The user declined, now or earlier, to let this product disclose a profile. - PermissionDenied, - /// No user is signed in. - NotConnected, - /// Catch-all. - Unknown { reason: String }, -} -pub enum HostProfileRetractError { - /// Another product disclosed the reference the host holds. - NotDiscloser, - /// No user is signed in. - NotConnected, - /// Catch-all. - Unknown { reason: String }, +pub enum ProfileContact { + Peer { peer_identity: [u8; 32] }, + Handle { handle: ContactHandle }, } pub struct HostProfilePresentContactRequest { - /// The contact's authenticated root identity, as the Chat v2 API names it. - pub peer_identity: [u8; 32], -} -pub enum HostProfilePresentContactError { - /// The contact has not disclosed a profile to the user. - NotShared, - /// The stored reference no longer passes screening. - InvalidReference, - /// No user is signed in. - NotConnected, - /// Catch-all. - Unknown { reason: String }, -} -pub struct HostProfilePlaceContactAvatarsRequest { - /// Surface size, in the units of every rect: framebuffer pixels for a PolkaVM product, - /// CSS pixels of the viewport for a web product. 1 to 16384 a side. - pub surface_width: u32, - pub surface_height: u32, - /// Replaces the product's previous placement; empty clears it. At most 64. - pub slots: Vec, + pub contact: ProfileContact, } pub struct ContactAvatarSlot { - /// Product-chosen id, unique in the placement and stable for one on-screen avatar. pub slot: u32, - pub peer_identity: [u8; 32], - /// The avatar circle's bounding box: square, 1 to 1024 a side. + pub contact: ProfileContact, pub rect: AvatarRect, - /// Visible region the avatar is cut to. pub clip: AvatarRect, } -pub struct AvatarRect { pub x: i32, pub y: i32, pub width: u32, pub height: u32 } -pub enum HostProfilePlaceContactAvatarsError { - /// The host cannot draw over the product. - Unsupported, - /// No user is signed in. - NotConnected, - /// Catch-all, including a malformed placement. - Unknown { reason: String }, -} ``` +V1 disclosure maps to `ChatApps`: app-scoped sharing with ready peers of each Chat app, not a wallet-wide personal +grant. `App` selects one normalized product ID. `Contacts` selects personal recipients by opaque handle. A request may +combine these; an empty list retains the own profile without granting delivery. Calls replace the previous audience +policy. At most 64 audience entries and 4096 handles are accepted; duplicates are coalesced. + +The core resolves handles using the same verified Contacts lookup as transaction recipient substitution, rehashes +returned accounts, and rejects the entire disclosure if any handle is invalid or the session/cache generation changes. +It never guesses a translation between a payment account, device key and Chat root identity: a resolved account must +exactly match an authenticated ready peer identity. A Contacts entry does not establish such a channel. + +Seity groups can be sets of handles whose union is passed as `Contacts`. Group names, labels and membership editing +are not host Profile state. Handles are stable pseudonyms across apps and hosts for one user, not unlinkable identities. + ### Consent -Every contact receives the reference, so a product may disclose only once the user has allowed it. The first `disclose` -from a product raises `UserConfirmationReview::ProfileDisclosure { product_id }` through the host's +The first `disclose` from a product raises `UserConfirmationReview::ProfileDisclosure { product_id }` through the host's `confirm_permission`, beside `ChatAuthority`; the answer is remembered per product as -`PermissionAuthorizationRequest::ProfileDisclosure`, and a refusal, then or remembered, is `PermissionDenied` with -nothing stored. `retract` never asks: withdrawing only narrows what contacts hold. +`PermissionAuthorizationRequest::ProfileDisclosure`. A refusal is `PermissionDenied` with nothing stored. +The current review authorizes the disclosing product, not each audience mutation. A product must explain the difference +between sharing inside an app and personal sharing across apps; audience-specific host consent remains a rollout +question. `retract` never asks: it withdraws all grants of the current disclosure. ### Storage -Two core-storage slots hold references, and neither is visible to products. Both are scoped to the signed-in wallet and -the Chat network, as the Chat roster is. `ProfileDisclosure { root_public_key, genesis_hash }` holds the disclosing -product id and the reference. `ProfileReferencesReceived { root_public_key, genesis_hash, product_id }` holds, per chat -product, what each contact's host last sent: its discloser, its frame timestamp, and the reference, or `None` once -withdrawn. Clearing the product clears it. Hosts treat both as secret material. +Three secret core-storage slots are scoped to the signed-in wallet and People network: + +- `ProfileDisclosure { root_public_key, genesis_hash }`: discloser, reference, audience policy and durable revision. + Retraction retains a revision tombstone, so the next share cannot reuse an older sequence after restart. +- `ProfileReferencesReceived { root_public_key, genesis_hash, product_id }`: app-scoped received grants and withdrawals. +- `ProfilePersonalReferencesReceived { root_public_key, genesis_hash }`: personal received grants and withdrawals, + shared across recipient apps but isolated from other wallets and networks. + +Legacy disclosure and watermark records migrate to app-scoped behavior, never to personal grants. A live app-specific +reference takes precedence over a personal one. An app withdrawal removes only that grant, allowing a personal grant +to remain visible; a personal withdrawal leaves app grants intact. ### Relay -A disclosure travels as a new Chat v2 content type, `ProfileReference { discloser_product_id, reference: Option }`, -where `None` withdraws. The Chat actor seals it to each ready peer's devices through the same host-private outbox that -carries payments and rich files, so the chat product submits and retries opaque ciphertext it cannot read, and cannot -prepare the content type itself. A per-peer watermark records what was last sent; each publish sends the current -disclosure to every ready peer whose watermark differs, which covers the first share, a new contact, a replacement and a -withdrawal. On receipt the host screens the frame, stores it for that peer and removes it from the plaintext returned to -the product. Frames from compacted history are dropped. +App grants retain Chat v2 content **21**, `ProfileReference { discloser_product_id, reference: Option }`, byte for byte. +Personal grants use distinct content **22**, with validated personal scope byte **1**, a nonzero durable disclosure +revision, the disclosing product and optional reference. `None` withdraws in that scope only. +The Chat actor seals frames to ready peer devices through the host-private outbox. The product submits opaque +ciphertext, cannot prepare profile content itself, and receives no profile content in opened history. +Per-peer, per-scope watermarks track shares, replacements and withdrawals. Personal frames are addressed only to selected +resolved accounts. Frames from compacted history are dropped. A Chat actor publishes: @@ -201,41 +139,40 @@ A Chat actor publishes: call, and asks every open Chat actor of the same wallet and network, whatever its product, to publish on a task of its own, so the call never waits on it. -Publishes on one actor run one at a time, so one that read an older disclosure never queues it after a newer one. -Without a new disclosure and with nothing lapsed, a publish reads the disclosure and checks watermarks, nothing more. +A wallet/network profile-state gate serializes disclosure replacement, publication and received-store updates. +Without a new disclosure and with nothing lapsed, publication reads the disclosure and checks watermarks. +Narrowing an audience removes obsolete unsent frames, even for peers that are no longer ready, and retains withdrawal +watermarks for later delivery. Already returned signed frames cannot be recalled. -The product decides the order it opens statements in, so frames are ordered by their timestamp, not by arrival. Each -frame a host sends a peer is timestamped later than the one before it, even if its clock steps back. The receiving host -applies a frame only if it is strictly newer than the one it holds, and keeps a withdrawal as a row rather than deleting -it, so a disclosure opened after its own withdrawal cannot bring the reference back. +App frames retain timestamp ordering. Personal frames use the durable disclosure revision across actors: independent +app clocks must not allow an old share to undo a newer withdrawal. Received withdrawals remain tombstones, so replaying +an older share cannot restore it after the newer withdrawal has been received. -Delivery is best effort. References have their own outbox budget, one per peer, so they never take a slot payments or -rich files need, and a reference that finds no room waits for a later publish rather than failing the chat product's -initialization. A queued reference is offered for one statement lifetime. If it lapses unacknowledged and the peer is -still ready, it is signed again as a new, later frame and offered for another lifetime, up to three frames per peer and -disclosure; then the host stops until the disclosure changes, which starts a fresh count. A host that predates the -content type rejects the whole statement and never acknowledges it, so it costs at most three statements per disclosure. -The watermark records the attempts and whether the last frame lapsed; state written before it recorded them counts as -one attempt that did not lapse. +Delivery is best effort. References share the existing bounded profile outbox budget, with separate entries per peer +and scope, and never take slots reserved for payments or rich files. A frame that finds no room waits for a later +publish. An unacknowledged frame lapsing after one statement lifetime is signed again for a ready peer, up to three +frames per scope and disclosure; a new disclosure starts a fresh count. Hosts predating a content type reject it and +never acknowledge it. Migration retains existing app watermarks and pending withdrawals. The host only prepares statements: the chat product submits them. A publish outside the product's own requests, after `disclose` or `retract`, queues the reference while the chat actor is open, and it reaches the contact once the chat product next runs and submits what its responses offer. -A reference may name a mutable record, such as a registry slot, and a discloser such as Seity re-shares the same -reference when only the record behind it changes. Each `disclose` call therefore stores the disclosure with a new -revision, later than the stored one, and the watermark records the disclosure by a digest that includes it. Disclosing -the reference already held starts a new round to every ready peer: a new, later frame with its own request id and -signature, and a fresh attempt count. Automatic publishes (initialize, reconcile, a peer becoming ready) compare the -same revision and never resend a round already sent. A disclosure stored before revisions reads as revision 0 and keeps -the digest it was sent under, so an upgrade sends nothing. +A reference may name a mutable record. Every `disclose`, even with an unchanged reference, advances the durable +revision and starts a new round for the selected recipients with fresh attempt counts. Initialize, reconcile and +readiness-triggered publishes do not resend a round already delivered. A pre-revision disclosure reads as revision 0 +and preserves its legacy app digest, so migration alone does not broaden or resend it. ### Presentation -`present_contact` looks up the caller's received reference for the named peer, screens it again, and hands it to +V1 `present_contact` reads only the caller's app-scoped grant and preserves its existing errors. It cannot probe personal +grants through `NotShared`. V2 accepts a peer or verified Contacts handle, selects the live app grant then personal +fallback, and answers uniformly for an absent, unknown or unreadable profile, including host drawing failures. +Neither path returns the reference. The core hands a found reference to `ProfilePlatform::present_contact_profile(product, PresentedContactProfile { reference, peer_identity, shared_at, -username })`, where `shared_at` is the sender timestamp (Unix ms) of the frame the reference came from. `username` is -the host's own name for the contact, never one from the product: the name the calling product's Chat roster holds for +username })`. `shared_at` is a frame freshness timestamp; personal grants advance it monotonically even when a newer +revision arrives from an actor with an older clock. `username` is the host's own name for the contact, never one from +the product: the name the calling product's Chat roster holds for that peer, verified when the contact was bound or first authenticated, else the peer's verified dotNS name. The core waits at most 2 seconds for it and passes `None` when it knows none, so a slow directory never holds the drawer back; the host then names the contact generically, never by address. The default calls `present_profile` with the reference @@ -260,11 +197,10 @@ A chat product draws its own conversation list and header, so only it knows wher `place_contact_avatars` with its surface size and, per avatar, a slot id, the contact's peer identity, the circle's square bounding box and the region it is cut to, in surface units. Each call replaces the product's placement. -The core keeps only the slots whose contact holds a current reference in the caller's `ProfileReferencesReceived`, -withdrawals excluded, and hands them with those references to `ProfilePlatform::place_contact_avatars(product, -PlacedAvatars { surface_width, surface_height, avatars })`. Each avatar carries `shared_at`, the sender timestamp (Unix ms) of -the frame its reference came from: a newer frame with the same reference means the contact updated the record behind -it, and the host should drop any profile it cached for that reference. The host draws each contact's photo and mood ring, when +The core keeps only slots with an effective live app or personal reference, withdrawals excluded, and hands them to +`ProfilePlatform::place_contact_avatars(product, PlacedAvatars { surface_width, surface_height, avatars })`. +Each avatar carries `shared_at`, a monotonically advancing freshness timestamp within its grant scope. A newer value +for the same reference means the host should drop cached profile contents. The host draws each photo and mood ring, when they have one, on a layer over the product that lets pointer input through; a tap still reaches the product, which opens the profile with `present_contact`. The default callback draws nothing, so a host draws avatars only once it implements it. @@ -280,6 +216,12 @@ hands it to the host in the same `PlacedAvatars` set as the contact avatars, so overlay. Slot ids are unique across `own` and the contact slots. Disclosing or retracting redraws every remembered placement for that wallet, as a contact's reference change does. A version 1 placement is one with no own slot. +Version 3 retains the own slot and accepts `ProfileContact` selectors for contact slots. V1/V2 requests and replies +remain compatible. Handle placements revalidate the session and Contacts cache generation on redraw. +`notifyContactsChanged` clears stale handle resolution and clears the old overlay before resolving it again, so +removed handles cannot leave old avatars visible. Personal receives and withdrawals refresh all wallet placements. +Removing a Contacts entry invalidates lookup but does not itself edit an already approved disclosure's recipient set. + No leak: the product must not learn who shared a profile. The core answers `Ok` to any well-formed placement from a signed-in user however many avatars, if any, are drawn; it returns nothing per slot, logs nothing about slots, and treats a host drawing failure as success, since it could depend on which avatars were drawn. Only what the product @@ -290,7 +232,8 @@ read it. ## Trade-offs -- One reference for all contacts, so withdrawing it from one contact means rotating it for all of them. +- One reference is shared by all audiences. A narrower audience withdraws rendering only for the removed grants; + overlapping grants remain effective. It cannot invalidate copies of the bearer reference. - A retraction cannot make a contact's host forget a reference it already resolved. - The watermark advances when the message is queued. A message that never arrives is sent again only when it lapses unacknowledged, three frames at most per disclosure, so a contact whose host misses all three is not sent it again @@ -303,12 +246,12 @@ read it. ## Open questions -- The wire trait id. The prototype uses 69, clear of the sequential range, and moves to the next free id when it lands. -- The content-type index. The prototype uses V2 index 21, which native Chat has to agree to. +- Chat content indices 21 (app) and 22 (personal) require coordination with native Chat before rollout. - Several disclosing products. There is one `ProfileDisclosure` slot, so the last product to disclose replaces the others and the earlier one can no longer retract. The alternative is one slot per product, with the host relaying the one from a product the user designates, as RFC 0024 designates a personhood provider. -- Consent covers the product, not the reference: once allowed, a product may replace its disclosure without asking. +- Consent covers the product, not each audience mutation: once allowed, a product may replace its disclosure without + asking. Selected-contact and cross-app personal-sharing review must be agreed with the host Contacts owner. - Devices. Only the host that took `disclose` knows the disclosure, so contacts that reach the user's other devices are not sent it. - Resolution. Hosts parse references today; a shared resolver in the core would need the reference format specified here diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 239c5b3b1..45a6218e7 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -193,7 +193,7 @@ and printable ASCII without whitespace; parsing the format is the host's. `profile.presentContactProfile(product, presented)` shows the profile a Chat contact shared when a chat product calls `profile.presentContact`. `presented` carries the `reference`, the `peerIdentity` of the contact whose authenticated -Chat device delivered it, the `sharedAt` (Unix ms, a `bigint`) of that share and, when the core knows it, the contact's +Chat device delivered it, the `sharedAt` freshness timestamp (`bigint`) and, when the core knows it, the contact's `username`, so the drawer can say who shared it rather than which product asked. The username is the one the core's Chat roster verified for that contact, else the contact's verified dotNS name, looked up for at most 2 seconds; never a name from the product. Without one, name the contact generically, never by address. It names who sent the reference, @@ -204,9 +204,9 @@ presented through `presentProfile`. `profile.placeContactAvatars(product, placed)` draws contacts' avatars over a chat product. `placed` carries the product's surface size and, per avatar, the product's `slot` id, a square `rect`, the `clip` region it is cut to, all in surface units (framebuffer pixels for a PolkaVM product, CSS pixels of the viewport for a web product), and the -`reference` that contact disclosed, so the host can draw their photo and mood ring, with `sharedAt` (Unix ms, a -`bigint`) of the share it came from. A contact re-shares the same reference when the record behind it changes, so a -larger `sharedAt` for a reference the host has cached means the cached profile is stale. Each call replaces what was +`reference` that contact disclosed, so the host can draw their photo and mood ring, with a `sharedAt` freshness token +(`bigint`). Contact tokens use Unix ms, advanced monotonically for personal revisions across relay actors; the own +avatar uses the disclosure revision, not a date. A changed token invalidates cached contents. Each call replaces what was drawn for the product; an empty `avatars` clears it. The core calls it again with the same geometry when a contact shares, re-shares or withdraws a profile, and with no avatars when the product's connection goes away. Draw on a layer the product cannot @@ -214,10 +214,18 @@ read that lets pointer input through, and never tell the product what was drawn. `RequiredHostCallbacks`, so a `profile` group implements it and `presentContactProfile` alongside `presentProfile`. `profile.disclose` needs no `profile` group, but the first call from a product asks the user through -`userConfirmation.confirmPermission` with a `ProfileDisclosure` review naming that product: every Chat contact receives -the reference. The answer is kept like any other permission, as `ProfileDisclosure`. A host that cannot render the +`userConfirmation.confirmPermission` with a `ProfileDisclosure` review naming that product. V1 shares app-scoped +references with every ready Chat contact; V2 can select apps or opaque Contacts handles. Personal grants are +host-renderable across recipient apps. The answer is kept like any other permission, as `ProfileDisclosure`. +Audience mutations currently reuse that product-level consent. A host that cannot render the review should reject the call rather than answer `Deny`: the product is refused, but no refusal is remembered. +`presentContact` V2 accepts peer or Contacts-handle selectors and hides sharing availability; V1 remains app-only. +`placeContactAvatars` V3 accepts those selectors alongside the V2 own slot. V1/V2 placement bytes remain compatible. +Hosts must call `notifyContactsChanged()` after directory changes so stale handle resolution and overlays clear. +These APIs do not create a Chat channel or a group editor. See the +[Profile RFC](../../../docs/rfcs/profile-disclosure.md) for audience, transport and withdrawal semantics. + Under `createWebWorkerPairingHostRuntime` the presence of each optional group is reported to the worker in its `init` message, so the core sees the same capability set on both sides of the boundary. diff --git a/rust/crates/truapi-chat-v2/src/lib.rs b/rust/crates/truapi-chat-v2/src/lib.rs index 03ffbc28b..fac5846a9 100644 --- a/rust/crates/truapi-chat-v2/src/lib.rs +++ b/rust/crates/truapi-chat-v2/src/lib.rs @@ -386,6 +386,13 @@ pub enum V2ChatMessageContent { discloser_product_id: String, reference: Option, }, + /// Wallet-wide personal grant, explicitly distinguished from app-scoped + /// index 21. Content index 22 carries a validated scope byte of 1. + PersonalProfileReference { + discloser_product_id: String, + reference: Option, + revision: u64, + }, /// The envelope was valid enough to recover id/timestamp, but the versioned /// content wrapper is not yet represented by this SDK surface. UnsupportedVersion { version_index: u8 }, @@ -1009,6 +1016,38 @@ pub fn encode_profile_reference_message( }) } +/// Encode a wallet-wide personal profile grant (content 22, personal scope 1). +/// Hosts must never fall back to the app-scoped content type for this grant. +pub fn encode_personal_profile_reference_message( + message_id: &str, + timestamp: u64, + revision: u64, + discloser_product_id: &str, + reference: Option<&str>, +) -> Result, ChatError> { + if revision == 0 { + return Err(ChatError::InvalidEncoding( + "invalid personal profile revision".into(), + )); + } + encode_message(message_id, timestamp, |out| { + out.push(22); + out.push(1); + out.extend_from_slice(&revision.to_le_bytes()); + encode_string(out, discloser_product_id)?; + match reference { + Some(reference) => { + out.push(1); + encode_string(out, reference) + } + None => { + out.push(0); + Ok(()) + } + } + }) +} + /// Encode a v2 compacted-messages reference (content index 19). pub fn encode_compacted_messages_message( message_id: &str, @@ -1304,7 +1343,23 @@ pub fn decode_message(data: &[u8]) -> Result { }, } } - 21 => { + 21 | 22 => { + if content_index == 22 && cursor.read_u8("profile_scope")? != 1 { + return Err(ChatError::InvalidEncoding( + "invalid personal profile scope".into(), + )); + } + let revision = if content_index == 22 { + let revision = cursor.read_u64("profile_revision")?; + if revision == 0 { + return Err(ChatError::InvalidEncoding( + "invalid personal profile revision".into(), + )); + } + revision + } else { + 0 + }; let discloser_product_id = cursor.read_string("discloser_product_id")?; let reference = match cursor.read_u8("reference_option")? { 0 => None, @@ -1316,9 +1371,17 @@ pub fn decode_message(data: &[u8]) -> Result { } }; cursor.finish()?; - V2ChatMessageContent::ProfileReference { - discloser_product_id, - reference, + if content_index == 22 { + V2ChatMessageContent::PersonalProfileReference { + discloser_product_id, + reference, + revision, + } + } else { + V2ChatMessageContent::ProfileReference { + discloser_product_id, + reference, + } } } index => V2ChatMessageContent::UnsupportedContent { @@ -3679,6 +3742,42 @@ mod tests { assert!(decode_message(&bad).is_err()); } + #[test] + fn personal_profile_wire_requires_explicit_scope_and_durable_revision() { + for reference in [Some("profile:secret"), None] { + let encoded = + encode_personal_profile_reference_message("p", 5, 42, "seity.dot", reference) + .unwrap(); + assert_eq!( + decode_message(&encoded).unwrap().content, + V2ChatMessageContent::PersonalProfileReference { + discloser_product_id: "seity.dot".into(), + reference: reference.map(String::from), + revision: 42, + } + ); + let mut bad_scope = encoded.clone(); + bad_scope[12] = 0; + assert!( + decode_message(&bad_scope).is_err(), + "personal cannot silently become app-scoped" + ); + let mut bad_revision = encoded.clone(); + bad_revision[13..21].fill(0); + assert!(decode_message(&bad_revision).is_err()); + let mut trailing = encoded.clone(); + trailing.push(0); + assert!(decode_message(&trailing).is_err()); + assert!(decode_message(&encoded[..20]).is_err()); + } + assert!(encode_personal_profile_reference_message("p", 5, 0, "seity.dot", None).is_err()); + assert_eq!( + encode_profile_reference_message("p", 5, "s", None).unwrap(), + vec![4, b'p', 5, 0, 0, 0, 0, 0, 0, 0, 0, 21, 4, b's', 0], + "legacy content 21 keeps its original byte layout" + ); + } + #[test] fn current_multi_device_wire_roundtrips() { let added = encode_device_added_message("add", 1, &[1; 32], &[2; 32]).unwrap(); diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 17478eacd..481014e30 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "df3802dbfb5dcf65"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "0bd782cc4aca62dc"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; diff --git a/rust/crates/truapi/src/api/contacts.rs b/rust/crates/truapi/src/api/contacts.rs index f360068a1..239cbfa0c 100644 --- a/rust/crates/truapi/src/api/contacts.rs +++ b/rust/crates/truapi/src/api/contacts.rs @@ -24,11 +24,12 @@ pub trait Contacts: Send + Sync { /// Resolves with the chosen contact's handle, or with why nothing was /// chosen. A host that serves no picker answers `Unsupported`. /// - /// The handle is not an address and cannot be turned into one. To pay the - /// person it names, put the handle where the recipient goes in the call and - /// list it in `contacts` on the transaction payload: the host replaces it - /// with their account before anything is signed or shown. A handle sent - /// anywhere else is 32 bytes that resolve to nobody. + /// The handle is not an address and cannot be turned into one by a product. + /// To pay the person, put the handle where the recipient goes in the call + /// and list it in `contacts` on the transaction payload: the host replaces + /// it with their account before anything is signed or shown. Profile also + /// accepts handles as disclosure recipients and as contacts to present or + /// draw avatars for, without returning accounts or profile contents. /// /// ```ts /// const result = await truapi.contacts.pick({}); diff --git a/rust/crates/truapi/src/api/profile.rs b/rust/crates/truapi/src/api/profile.rs index ab32cd0f0..d4111df4f 100644 --- a/rust/crates/truapi/src/api/profile.rs +++ b/rust/crates/truapi/src/api/profile.rs @@ -40,18 +40,20 @@ pub trait Profile: Send + Sync { ) -> Result> { Err(CallError::unavailable()) } - /// Give the user's chat contacts this reference to their profile. + /// Store the user's profile and replace its independent delivery audiences. /// - /// The host stores it as the user's own and relays it to each contact, - /// replacing whatever it sent before; the product never learns who they - /// are. App executions only. The first disclosure asks the user once for - /// this product; a refusal, then or remembered, is `PermissionDenied`. A - /// reference this core cannot screen is `InvalidReference`, and with no - /// user signed in the call is `NotConnected`. + /// `ChatApps` shares within every ready Chat App. `App` selects one Chat + /// App's audience. `Contacts` shares personally with picked opaque handles; + /// those received profiles may render in any App. An empty audience list + /// retains the own profile but withdraws all grants. Unknown handles reject + /// the whole disclosure. App executions only. The first disclosure asks + /// the user once per product; a refusal is `PermissionDenied`. + /// v0.1 callers retain the `ChatApps` audience. /// /// ```ts /// const result = await truapi.profile.disclose({ /// reference: "seity-contacts:v1:" + "00".repeat(64), + /// audiences: [{ tag: "ChatApps" }], /// }); /// console.log("profile disclosed:", result); /// ``` @@ -80,15 +82,21 @@ pub trait Profile: Send + Sync { Err(CallError::unavailable()) } - /// Show a chat contact's profile in host-owned UI. + /// Show a contact's available profile in host-owned UI. /// - /// The product names the contact; the host looks up the reference that - /// contact shared and presents it as `present` would. The reference never - /// reaches the product. A contact who shared nothing is `NotShared`. + /// The selector names a Chat peer or a picked opaque handle. App-scoped + /// profiles take precedence over personal profiles. References, names, + /// resolved accounts and availability never return to the product. Unknown + /// handles, absent profiles and host presentation failures return the same + /// success. v0.1 callers retain their `NotShared` and presentation errors + /// for App-scoped shares only; personal drawers require v0.2 so a legacy + /// raw-peer request cannot disclose personal sharing availability. /// /// ```ts /// const result = await truapi.profile.presentContact({ - /// peerIdentity: "0x0000000000000000000000000000000000000000000000000000000000000000", + /// contact: { tag: "Peer", value: { + /// peerIdentity: "0x0000000000000000000000000000000000000000000000000000000000000000", + /// } }, /// }); /// console.log("contact profile presentation:", result); /// ``` @@ -101,7 +109,7 @@ pub trait Profile: Send + Sync { Err(CallError::unavailable()) } - /// Tell the host where this product draws chat contacts' avatars, and + /// Tell the host where this product draws contacts' avatars, and /// optionally the signed-in user's own, so it can draw each shared photo /// and mood ring over them on its own layer. /// @@ -135,7 +143,9 @@ pub trait Profile: Send + Sync { /// slots: [ /// { /// slot: 0, - /// peerIdentity: "0x0000000000000000000000000000000000000000000000000000000000000000", + /// contact: { tag: "Peer", value: { + /// peerIdentity: "0x0000000000000000000000000000000000000000000000000000000000000000", + /// } }, /// rect: { x: 16, y: 80, width: 44, height: 44 }, /// clip: { x: 0, y: 64, width: 360, height: 576 }, /// }, diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 42a56252f..0bb8027b9 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -290,12 +290,14 @@ impl PairingHostRuntime { self.services.install_contacts_platform(platform) } - /// Tell the core the host's contacts changed, so no handle resolves from - /// what it cached before. Call it whenever a contact is removed or blocked; - /// the next transaction naming a contact reads the list again. + /// Invalidate cached contact handles and refresh host-drawn contact avatars. + /// Call whenever a contact is removed or blocked. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.notify_contacts_changed"))] pub fn notify_contacts_changed(&self) { self.services.contact_handles.clear(); + self.services + .contact_avatars + .contacts_changed(&self.services.spawner); } /// Build a product-facing runtime from this pairing host. @@ -737,12 +739,14 @@ impl SigningHostRuntime { self.services.install_contacts_platform(platform) } - /// Tell the core the host's contacts changed, so no handle resolves from - /// what it cached before. Call it whenever a contact is removed or blocked; - /// the next transaction naming a contact reads the list again. + /// Invalidate cached contact handles and refresh host-drawn contact avatars. + /// Call whenever a contact is removed or blocked. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.notify_contacts_changed"))] pub fn notify_contacts_changed(&self) { self.services.contact_handles.clear(); + self.services + .contact_avatars + .contacts_changed(&self.services.spawner); } /// Install the host's [`DevicePairingObserver`], told whenever a device @@ -1605,15 +1609,12 @@ impl Drop for WorkerReference { } impl ProductRuntime { - /// Tell the core the host's contacts changed, so no handle resolves from - /// what it cached before. For an embedder that holds only this runtime; - /// one holding the host runtime calls it there. + /// Invalidate contact handles and refresh avatars for this host's runtimes. + /// Embedders holding the host runtime may notify it instead. pub fn notify_contacts_changed(&self) { - self.admin - .product_runtime - .services() - .contact_handles - .clear(); + let services = self.admin.product_runtime.services(); + services.contact_handles.clear(); + services.contact_avatars.contacts_changed(&services.spawner); } /// Build a product-facing host core around a platform implementation and diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index dcb65370c..fc62dcfa8 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -115,11 +115,12 @@ pub mod latest { HostNativeChatInvitation, HostNativeChatMessages, HostNativeChatPayment, HostNativeChatPaymentDirection, HostNativeChatPaymentFailure, HostNativeChatPaymentState, HostNativeChatPeer, HostNativeChatPeerDevice, HostNativeChatRichMessage, - HostNativeChatRichMessageKind, + HostNativeChatRichMessageKind, OwnAvatarSlot, ProfileAudience, ProfileContact, }; pub use crate::v03::{ - HostNativeChatBinding, HostNativeChatMigrationInvitation, HostNativeChatOpenPage, - HostNativeChatOpened, HostNativeChatPrepared, HostNativeChatRoute, HostNativeChatStatePage, + ContactAvatarSlot, HostNativeChatBinding, HostNativeChatMigrationInvitation, + HostNativeChatOpenPage, HostNativeChatOpened, HostNativeChatPrepared, HostNativeChatRoute, + HostNativeChatStatePage, }; /// Latest payload type of a versioned envelope. @@ -219,6 +220,14 @@ pub mod latest { /// Contact avatar placement failure. pub type HostProfilePlaceContactAvatarsError = LatestOf; + /// Profile disclosure request with explicit audiences. + pub type HostProfileDiscloseRequest = LatestOf; + /// Contact profile presentation selector. + pub type HostProfilePresentContactRequest = + LatestOf; + /// Contact and own avatar geometry. + pub type HostProfilePlaceContactAvatarsRequest = + LatestOf; /// Push notification scheduling request. pub type HostPushNotificationRequest = LatestOf; diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index f45b1de02..0f4df2dc1 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -1952,6 +1952,15 @@ pub enum CoreStorageKey { /// Chat product whose contacts sent the references. product_id: String, }, + /// Wallet-wide personal profile grants, including replay tombstones. + /// These bearer capabilities are independent of the receiving product. + #[codec(index = 19)] + ProfilePersonalReferencesReceived { + /// Wallet whose authenticated peers sent the references. + root_public_key: [u8; 32], + /// Host-selected Chat network. + genesis_hash: [u8; 32], + }, } /// Stable metadata describing one strictly decoded [`CoreStorageKey`]. @@ -2012,6 +2021,9 @@ pub fn describe_core_storage_key( CoreStorageKey::ProfileReferencesReceived { product_id, .. } => { ("ProfileReferencesReceived", Some(product_id)) } + CoreStorageKey::ProfilePersonalReferencesReceived { .. } => { + ("ProfilePersonalReferencesReceived", None) + } CoreStorageKey::NativeChatFileChunk { product_id, .. } => { ("NativeChatFileChunk", Some(product_id)) } @@ -3996,8 +4008,8 @@ pub struct PresentedContactProfile { /// The contact whose authenticated Chat device delivered the reference: /// who shared it, not necessarily whose profile it is. pub peer_identity: [u8; 32], - /// When the contact's host sent the share, in Unix milliseconds, as in - /// [`PlacedAvatar::shared_at`]. + /// The share's freshness timestamp, as in [`PlacedAvatar::shared_at`]. + /// Personal grants advance it monotonically across relay actors. pub shared_at: u64, /// The contact's username, when the core knows one: the name its Chat /// roster holds for `peer_identity`, verified when the contact was bound @@ -4051,10 +4063,11 @@ pub struct PlacedAvatar { /// The profile reference the contact disclosed. A bearer capability, as /// in [`ProfilePlatform::present_profile`]. pub reference: String, - /// When the contact's host sent the share this reflects, in Unix - /// milliseconds. A contact re-shares the same reference when the record - /// behind it changes; a larger `shared_at` for the same reference means - /// any cached copy of that profile is stale. + /// Freshness token for this reference. Contact shares use Unix + /// milliseconds, advanced monotonically for personal revisions even + /// across relay actors with different clocks. The own avatar uses the + /// disclosure revision. A changed token invalidates cached contents; + /// do not interpret an own-profile token as a wall-clock date. pub shared_at: u64, } diff --git a/rust/crates/truapi/src/platform/mock.rs b/rust/crates/truapi/src/platform/mock.rs index ddafa366d..2427a8a5a 100644 --- a/rust/crates/truapi/src/platform/mock.rs +++ b/rust/crates/truapi/src/platform/mock.rs @@ -41,7 +41,8 @@ use crate::platform::{ HopProvider, JsonRpcConnection, LocaleHost, NativeChatFileExportRequest, NativeChatFilePickRequest, NativeChatFilesHost, NativeChatPickedFile, Navigation, Notifications, PermissionDecision, Permissions, PreimageHost, ProductContext, - ProductOperations, ProductStorage, ProviderError, ThemeHost, UserConfirmation, UserConfirmationReview, + ProductOperations, ProductStorage, ProviderError, ThemeHost, UserConfirmation, + UserConfirmationReview, }; /// How the mock answers a permission prompt for one capability. @@ -847,6 +848,14 @@ fn core_key(key: &CoreStorageKey) -> String { hex_key(root_public_key), hex_key(genesis_hash) ), + CoreStorageKey::ProfilePersonalReferencesReceived { + root_public_key, + genesis_hash, + } => format!( + "core:profile-personal-references-received:{}:{}", + hex_key(root_public_key), + hex_key(genesis_hash) + ), CoreStorageKey::NativeChatFileChunk { root_public_key, genesis_hash, diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index e14b70366..3c03ce326 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -1358,6 +1358,19 @@ impl ProductRuntimeHost { if declared.is_empty() { return Ok(call_data); } + let resolved = self.resolve_contact_handles(&declared_bytes).await?; + crate::host_logic::contact_substitution::substitute(&call_data, &resolved) + .map_err(|_| ContactResolutionError::UnknownContact) + } + + async fn resolve_contact_handles( + &self, + requested: &[[u8; 32]], + ) -> Result)>, ContactResolutionError> { + if requested.is_empty() { + return Ok(Vec::new()); + } + let session = self.authority.current_session(); let (platform, handles) = self.contacts_picker().map_err(|error| match error { CallError::Unsupported => ContactResolutionError::Unsupported, CallError::Domain(v01::HostContactsPickError::NotConnected) => { @@ -1368,48 +1381,13 @@ impl ProductRuntimeHost { } other => ContactResolutionError::Host(format!("{other:?}")), })?; - let mut resolved: Vec<([u8; 32], Option<[u8; 32]>)> = declared_bytes - .iter() - .map(|handle| (*handle, cache.get(handle, &handles))) - .collect(); - // The host is asked only about handles the cache cannot answer, all - // of them in one lookup. - let misses: Vec<[u8; 32]> = resolved - .iter() - .filter(|(_, account)| account.is_none()) - .map(|(handle, _)| *handle) - .collect(); - if !misses.is_empty() { - let generation = cache.generation(); - let lookup = crate::platform::HostContactLookup { - handle_key: handles.handle_key(), - handles: misses, - }; - let matches = platform - .contacts(&lookup) - .await - .map_err(|error| ContactResolutionError::Host(error.reason))?; - // One answer per handle, or the answers cannot be paired up. - if matches.accounts.len() != lookup.handles.len() { - return Err(ContactResolutionError::Host(format!( - "contacts lookup answered {} of {} handles", - matches.accounts.len(), - lookup.handles.len() - ))); - } - let mut answers = matches.accounts.into_iter(); - for (handle, account) in resolved.iter_mut().filter(|(_, account)| account.is_none()) { - let answer = answers.next().expect("one answer per miss; qed"); - // A host answer is checked, not trusted: an account that does - // not hash to its handle is treated as no contact at all. - *account = answer.filter(|account| handles.names(handle, account)); - if let Some(account) = account { - cache.insert(*handle, *account, generation); - } - } + let resolved = + resolve_contact_accounts(&self.services, platform.as_ref(), &handles, requested) + .await?; + if self.authority.current_session() != session { + return Err(ContactResolutionError::NotConnected); } - crate::host_logic::contact_substitution::substitute(&call_data, &resolved) - .map_err(|_| ContactResolutionError::UnknownContact) + Ok(resolved) } /// The contact picker for this connection, plus the key its handles are @@ -1447,6 +1425,56 @@ impl ProductRuntimeHost { } } +async fn resolve_contact_accounts( + services: &RuntimeServices, + platform: &dyn crate::platform::ContactsPlatform, + handles: &contacts::ContactHandles, + requested: &[[u8; 32]], +) -> Result)>, ContactResolutionError> { + let cache = &services.contact_handles; + let generation = cache.generation(); + let mut resolved: Vec<([u8; 32], Option<[u8; 32]>)> = requested + .iter() + .map(|handle| (*handle, cache.get(handle, handles))) + .collect(); + let misses: Vec<[u8; 32]> = resolved + .iter() + .filter(|(_, account)| account.is_none()) + .map(|(handle, _)| *handle) + .collect(); + if !misses.is_empty() { + let lookup = crate::platform::HostContactLookup { + handle_key: handles.handle_key(), + handles: misses, + }; + let matches = platform + .contacts(&lookup) + .await + .map_err(|error| ContactResolutionError::Host(error.reason))?; + if matches.accounts.len() != lookup.handles.len() { + return Err(ContactResolutionError::Host(format!( + "contacts lookup answered {} of {} handles", + matches.accounts.len(), + lookup.handles.len() + ))); + } + let mut answers = matches.accounts.into_iter(); + for (handle, account) in resolved.iter_mut().filter(|(_, account)| account.is_none()) { + let answer = answers.next().expect("one answer per miss; qed"); + *account = answer.filter(|account| handles.names(handle, account)); + if let Some(account) = account { + cache.insert(*handle, *account, generation); + } + } + } + if cache.generation() != generation { + for (_, account) in &mut resolved { + *account = None; + } + } + Ok(resolved) +} + #[crate::platform::async_trait] impl Contacts for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "contacts.pick"))] @@ -1727,16 +1755,51 @@ impl Profile for ProductRuntimeHost { if self.product.execution_kind != crate::platform::ProductExecutionKind::App { return Err(CallError::Denied); } - let domain = |error| CallError::Domain(HostProfileDiscloseError::V1(error)); - let HostProfileDiscloseRequest::V1(request) = request; + use truapi::latest::ProfileAudience; + use truapi::versioned::{FromLatest, IntoLatest, Versioned}; + let version = request.version(); + let domain = + |error| CallError::Domain(HostProfileDiscloseError::from_latest(error, version)); + let request = request.into_latest(); if !is_screened_profile_reference(&request.reference) { return Err(domain(v01::HostProfileDiscloseError::InvalidReference)); } let owner = self .profile_owner() .ok_or_else(|| domain(v01::HostProfileDiscloseError::NotConnected))?; - // Every contact receives the reference, so the user decides once per - // product whether it may hand one over. + if request.audiences.len() > 64 { + return Err(domain(v01::HostProfileDiscloseError::Unknown { + reason: "too many profile audiences".to_string(), + })); + } + let mut all_chat_apps = false; + let mut app_products = Vec::new(); + let mut requested_handles = Vec::new(); + for audience in request.audiences { + match audience { + ProfileAudience::ChatApps => all_chat_apps = true, + ProfileAudience::App { product_id } => { + let product_id = normalize_product_identifier(&product_id).map_err(|_| { + domain(v01::HostProfileDiscloseError::Unknown { + reason: "invalid profile audience".to_string(), + }) + })?; + app_products.push(product_id); + } + ProfileAudience::Contacts { handles } => { + if handles.len() > 4096usize.saturating_sub(requested_handles.len()) { + return Err(domain(v01::HostProfileDiscloseError::Unknown { + reason: "too many profile contacts".to_string(), + })); + } + requested_handles.extend(handles.into_iter().map(|handle| handle.bytes)); + } + } + } + app_products.sort_unstable(); + app_products.dedup(); + requested_handles.sort_unstable(); + requested_handles.dedup(); match self.profile_disclosure_authorization().await { Ok(PermissionAuthorizationStatus::Authorized) => {} Ok( @@ -1747,28 +1810,53 @@ impl Profile for ProductRuntimeHost { } Err(reason) => return Err(domain(v01::HostProfileDiscloseError::Unknown { reason })), } - let storage = self.platform.as_ref(); - // A later revision than the stored one even when the reference is the - // same: the record behind it changed, and contacts are sent it again. - let previous = profile::read_disclosure(storage, owner) + let guard = self.services.profile_state_gate.lock().await; + let generation = self.services.contact_handles.generation(); + let mut contacts = self + .resolve_contact_handles(&requested_handles) .await - .ok() - .flatten() - .map_or(0, |disclosure| disclosure.revision); + .map_err(|_| { + domain(v01::HostProfileDiscloseError::Unknown { + reason: "profile contacts unavailable".to_string(), + }) + })? + .into_iter() + .map(|(_, account)| account) + .collect::>>() + .ok_or_else(|| { + domain(v01::HostProfileDiscloseError::Unknown { + reason: "invalid profile audience".to_string(), + }) + })?; + contacts.sort_unstable(); + contacts.dedup(); + if self.profile_owner() != Some(owner) { + return Err(domain(v01::HostProfileDiscloseError::NotConnected)); + } + let storage = self.platform.as_ref(); + if self.services.contact_handles.generation() != generation && !contacts.is_empty() { + return Err(domain(v01::HostProfileDiscloseError::Unknown { + reason: "profile contacts changed".to_string(), + })); + } let now = crate::unix_time::current_unix_secs().saturating_mul(1000); let disclosure = profile::Disclosure { product_id: self.product_id(), reference: request.reference, - revision: now.max(previous.saturating_add(1)), + revision: now, + all_chat_apps, + app_products, + contacts, }; profile::write_disclosure(storage, owner, &disclosure) .await .map_err(|reason| domain(v01::HostProfileDiscloseError::Unknown { reason }))?; + drop(guard); self.profile_disclosure_changed(); self.services .contact_avatars .redraw_owner(owner, &self.services.spawner); - Ok(HostProfileDiscloseResponse::V1) + Ok(HostProfileDiscloseResponse::from_latest((), version)) } #[instrument(skip_all, fields(runtime.method = "profile.retract"))] @@ -1786,6 +1874,7 @@ impl Profile for ProductRuntimeHost { .profile_owner() .ok_or_else(|| domain(v01::HostProfileRetractError::NotConnected))?; let storage = self.platform.as_ref(); + let guard = self.services.profile_state_gate.lock().await; match profile::read_disclosure(storage, owner) .await .map_err(unknown)? @@ -1799,6 +1888,7 @@ impl Profile for ProductRuntimeHost { profile::clear_disclosure(storage, owner) .await .map_err(unknown)?; + drop(guard); self.profile_disclosure_changed(); self.services .contact_avatars @@ -1815,59 +1905,109 @@ impl Profile for ProductRuntimeHost { request: HostProfilePresentContactRequest, ) -> Result> { let platform = self.profile_platform()?; - let HostProfilePresentContactRequest::V1(request) = request; - let domain = |error| CallError::Domain(HostProfilePresentContactError::V1(error)); + use truapi::latest::ProfileContact; + use truapi::versioned::{FromLatest, IntoLatest, Versioned}; + let version = request.version(); + let domain = + |error| CallError::Domain(HostProfilePresentContactError::from_latest(error, version)); + let success = || HostProfilePresentContactResponse::from_latest((), version); + let generation = self.services.contact_handles.generation(); + let contact = request.into_latest().contact; let owner = self .profile_owner() .ok_or_else(|| domain(v01::HostProfilePresentContactError::NotConnected))?; - // Stored only when it arrived over the authenticated Chat channel from - // this peer, so the peer is who shared it. - let (reference, shared_at) = profile::received_reference( - self.platform.as_ref(), - owner, - &self.product_id(), - &request.peer_identity, - ) - .await - .map_err(|reason| domain(v01::HostProfilePresentContactError::Unknown { reason }))? - .and_then(|received| { + let peer_identity = match contact { + ProfileContact::Peer { peer_identity } => peer_identity, + ProfileContact::Handle { handle } => { + let resolved = self.resolve_contact_handles(&[handle.bytes]).await; + let Some(account) = resolved + .ok() + .and_then(|mut resolved| resolved.pop()) + .and_then(|(_, account)| account) + else { + return Ok(success()); + }; + account + } + }; + let received = if version == 1 { + profile::received_app_reference( + self.platform.as_ref(), + owner, + &self.product_id(), + &peer_identity, + ) + .await + } else { + profile::received_reference( + self.platform.as_ref(), + owner, + &self.product_id(), + &peer_identity, + ) + .await + }; + let received = match received { + Ok(received) => received, + Err(_) if version >= 2 => return Ok(success()), + Err(reason) => { + return Err(domain(v01::HostProfilePresentContactError::Unknown { + reason, + })); + } + }; + let Some((reference, shared_at)) = received.and_then(|received| { received .reference .map(|reference| (reference, received.timestamp)) - }) - .ok_or_else(|| domain(v01::HostProfilePresentContactError::NotShared))?; + }) else { + return if version >= 2 { + Ok(success()) + } else { + Err(domain(v01::HostProfilePresentContactError::NotShared)) + }; + }; // A stored reference passed the same screen when it arrived; check // again rather than trust storage. if !is_screened_profile_reference(&reference) { + if version >= 2 { + return Ok(success()); + } return Err(domain( v01::HostProfilePresentContactError::InvalidReference, )); } - // The name comes from the host, never from the request: the product - // names only the peer identity. - let username = self.contact_username(&request.peer_identity).await; - platform + let username = self.contact_username(&peer_identity).await; + if self.profile_owner() != Some(owner) + || (matches!(contact, ProfileContact::Handle { .. }) + && self.services.contact_handles.generation() != generation) + { + return Ok(success()); + } + let result = platform .present_contact_profile( &self.product, crate::platform::PresentedContactProfile { reference, - peer_identity: request.peer_identity, + peer_identity, shared_at, username, }, ) - .await - .map(|()| HostProfilePresentContactResponse::V1) - .map_err(|error| { - domain(match error { - v01::HostProfilePresentError::InvalidReference => { - v01::HostProfilePresentContactError::InvalidReference - } - v01::HostProfilePresentError::Unknown { reason } => { - v01::HostProfilePresentContactError::Unknown { reason } - } - }) + .await; + if version >= 2 { + return Ok(success()); + } + result.map(|()| success()).map_err(|error| { + domain(match error { + v01::HostProfilePresentError::InvalidReference => { + v01::HostProfilePresentContactError::InvalidReference + } + v01::HostProfilePresentError::Unknown { reason } => { + v01::HostProfilePresentContactError::Unknown { reason } + } }) + }) } #[instrument(skip_all, fields(runtime.method = "profile.place_contact_avatars"))] @@ -1885,9 +2025,14 @@ impl Profile for ProductRuntimeHost { return Err(CallError::Denied); } let platform = self.profile_platform()?; - // A v0.1 placement is a v0.2 one with no own slot. - let request = truapi::versioned::IntoLatest::into_latest(request); - let domain = |error| CallError::Domain(HostProfilePlaceContactAvatarsError::V2(error)); + use truapi::versioned::{FromLatest, IntoLatest, Versioned}; + let version = request.version(); + let request = request.into_latest(); + let domain = |error| { + CallError::Domain(HostProfilePlaceContactAvatarsError::from_latest( + error, version, + )) + }; profile::avatars::validate(&request).map_err(|reason| { domain(v01::HostProfilePlaceContactAvatarsError::Unknown { reason }) })?; @@ -1899,6 +2044,7 @@ impl Profile for ProductRuntimeHost { platform, self.platform.clone(), self.product.clone(), + Arc::downgrade(&self.services), ) }); let Some(owner) = self.profile_owner() else { @@ -1908,10 +2054,15 @@ impl Profile for ProductRuntimeHost { v01::HostProfilePlaceContactAvatarsError::NotConnected, )); }; + let authority = request + .slots + .iter() + .any(|slot| matches!(slot.contact, truapi::latest::ProfileContact::Handle { .. })) + .then(|| Arc::downgrade(&self.authority)); placement - .place(owner, request) + .place(owner, request, authority) .await - .map(|()| HostProfilePlaceContactAvatarsResponse::V2) + .map(|()| HostProfilePlaceContactAvatarsResponse::from_latest((), version)) .map_err(domain) } diff --git a/rust/crates/truapi/src/runtime/chat_device.rs b/rust/crates/truapi/src/runtime/chat_device.rs index 660abb352..e90579003 100644 --- a/rust/crates/truapi/src/runtime/chat_device.rs +++ b/rust/crates/truapi/src/runtime/chat_device.rs @@ -121,6 +121,10 @@ pub(crate) struct ProfileReferenceFrame { pub(crate) discloser_product_id: String, /// The reference, or `None` for a withdrawal. pub(crate) reference: Option, + /// Legacy frames remain app-scoped; only explicit personal frames broaden visibility. + pub(crate) scope: crate::runtime::profile::ProfileScope, + /// Personal grants use a durable cross-app sequence; legacy app frames use timestamps. + pub(crate) revision: u64, } /// The same bound and alphabet the core screens a product's reference with. @@ -568,6 +572,30 @@ pub(crate) fn classify_message( } V2ChatMessageContent::ContactAdded => DeviceLifecycle::ContactAdded, V2ChatMessageContent::LeftChat => DeviceLifecycle::LeftChat, + V2ChatMessageContent::PersonalProfileReference { + discloser_product_id, + reference, + revision, + } => { + validate_id(&message.message_id)?; + if !screened_ascii(&discloser_product_id, MAX_PROFILE_PRODUCT_ID_BYTES) + || reference.as_deref().is_some_and(|reference| { + !screened_ascii(reference, MAX_PROFILE_REFERENCE_BYTES) + }) + { + return Err(ChatDeviceError::InvalidEncoding); + } + return Ok(OpenedDeviceMessage::ProfileReference( + ProfileReferenceFrame { + message_id: message.message_id, + timestamp: message.timestamp, + discloser_product_id, + reference, + scope: crate::runtime::profile::ProfileScope::Personal, + revision, + }, + )); + } V2ChatMessageContent::ProfileReference { discloser_product_id, reference, @@ -586,6 +614,8 @@ pub(crate) fn classify_message( timestamp: message.timestamp, discloser_product_id, reference, + scope: crate::runtime::profile::ProfileScope::App, + revision: 0, }, )); } diff --git a/rust/crates/truapi/src/runtime/native_chat/actor.rs b/rust/crates/truapi/src/runtime/native_chat/actor.rs index bec7e2a1d..7d539e230 100644 --- a/rust/crates/truapi/src/runtime/native_chat/actor.rs +++ b/rust/crates/truapi/src/runtime/native_chat/actor.rs @@ -25,7 +25,8 @@ use super::{ store::ChatStateStore, }; use crate::host_logic::statement_store::{ - decode_signed_statement, sign_statement_fields, signed_statement_to_scale, statement_fields_from_v01, + decode_signed_statement, sign_statement_fields, signed_statement_to_scale, + statement_fields_from_v01, }; use crate::host_logic::{product_account::*, sso::pairing::derive_identity_chat_private_key}; use crate::runtime::{ @@ -37,7 +38,7 @@ use crate::unix_time::current_unix_secs; type Error = HostProductDeviceChatError; const MAX_PEERS: usize = 256; const MAX_OUTBOX: usize = 256; -/// Profile references are budgeted apart from other traffic, one per peer. +/// Profile references have a fixed budget apart from other traffic. const MAX_PROFILE_OUTBOX: usize = MAX_PEERS; const MAX_RECEIPTS: usize = 4096; const MAX_HISTORY_BATCHES: usize = 256; @@ -151,6 +152,19 @@ enum OutgoingKind { Rich([u8; 32]), /// Appended last so earlier snapshots still decode. ProfileReference([u8; 32]), + /// Personal grants never replace an app scope's pending frame. + PersonalProfileReference([u8; 32]), +} + +impl OutgoingKind { + fn profile_scope(&self) -> Option { + use crate::runtime::profile::ProfileScope; + match self { + Self::ProfileReference(_) => Some(ProfileScope::App), + Self::PersonalProfileReference(_) => Some(ProfileScope::Personal), + _ => None, + } + } } #[derive(Clone, Encode, Decode)] @@ -215,6 +229,7 @@ struct State { rich_messages: Vec, marker: [u8; 4], boundary: BoundaryState, + profile_marker: [u8; 4], /// Trailing, and absent from snapshots written before it existed. profile_shared: Vec, } @@ -239,6 +254,7 @@ impl State { rich_messages: Vec::new(), marker: *b"HCN3", boundary: BoundaryState::default(), + profile_marker: profile::WATERMARK_MARKER, profile_shared: Vec::new(), }) } @@ -271,7 +287,7 @@ impl State { } return Ok(()); } - let profile = matches!(outgoing.kind, OutgoingKind::ProfileReference(_)); + let profile = outgoing.kind.profile_scope().is_some(); let limit = if profile { MAX_PROFILE_OUTBOX } else { @@ -288,7 +304,7 @@ impl State { fn outbox_used(&self, profile: bool) -> usize { self.outbox .iter() - .filter(|entry| matches!(entry.kind, OutgoingKind::ProfileReference(_)) == profile) + .filter(|entry| entry.kind.profile_scope().is_some() == profile) .count() } } @@ -355,6 +371,7 @@ impl Decode for State { rich_messages, marker: *b"HCN3", boundary, + profile_marker: profile::WATERMARK_MARKER, profile_shared, }) } @@ -402,7 +419,6 @@ pub(super) struct NativeChatActor { file_transfer_gate: futures::lock::Mutex<()>, file_export_gate: futures::lock::Mutex<()>, file_cursor: AtomicUsize, - profile_gate: futures::lock::Mutex<()>, } impl NativeChatActor { @@ -453,7 +469,6 @@ impl NativeChatActor { file_transfer_gate: futures::lock::Mutex::new(()), file_export_gate: futures::lock::Mutex::new(()), file_cursor: AtomicUsize::new(0), - profile_gate: futures::lock::Mutex::new(()), }); actor .store @@ -467,6 +482,7 @@ impl NativeChatActor { || state.invitations.len() > 16 || state.outbox_used(false) > MAX_OUTBOX || state.outbox_used(true) > MAX_PROFILE_OUTBOX + || state.profile_shared.len() > MAX_PEERS * 2 || state.received.len() > MAX_RECEIPTS || state.sent.len() > MAX_RECEIPTS || state.accepted_payments.len() > MAX_RECEIPTS @@ -654,6 +670,17 @@ impl NativeChatActor { }) .await?; } + let _profile_state = context.services.profile_state_gate.lock().await; + let (profile_revision, disclosure) = crate::runtime::profile::read_disclosure_state( + &*context.services.platform, + profile::profile_owner(context), + ) + .await + .map_err(|_| Error::StorageUnavailable)?; + let disclosure = disclosure.map(|disclosure| { + let digest = profile::disclosure_digest(&disclosure); + (disclosure, digest) + }); self.store .read(|state| { let prepared = state @@ -666,8 +693,18 @@ impl NativeChatActor { OutgoingKind::Payment(_) | OutgoingKind::Rich(_) | OutgoingKind::ProfileReference(_) + | OutgoingKind::PersonalProfileReference(_) ) }) + .filter(|entry| { + !profile::superseded( + entry, + &state.profile_shared, + disclosure.as_ref(), + &self.product, + profile_revision, + ) + }) .map(|entry| entry.prepared(state)) .collect(); Ok(HostProductDeviceChatResponse { @@ -736,9 +773,11 @@ impl NativeChatActor { state .outbox .retain(|entry| matches!(entry.kind, OutgoingKind::Payment(_))); - // Profile references queued before the migration are dropped with - // it, so forget what was sent and let the reconcile resend. - state.profile_shared.clear(); + // Keep grants so a removed audience still receives withdrawal. + // Retired frames can be offered again within the existing limit. + for watermark in &mut state.profile_shared { + watermark.lapsed = true; + } state.messages.clear(); state.acknowledgments.clear(); state.sent.clear(); diff --git a/rust/crates/truapi/src/runtime/native_chat/actor/history.rs b/rust/crates/truapi/src/runtime/native_chat/actor/history.rs index 40a76324a..d327f7a83 100644 --- a/rust/crates/truapi/src/runtime/native_chat/actor/history.rs +++ b/rust/crates/truapi/src/runtime/native_chat/actor/history.rs @@ -384,10 +384,8 @@ impl NativeChatActor { self.continue_open(context, id, 0).await } - /// Keep the newest profile reference each frame carries for `peer`, in - /// this product's received-reference slot. `None` withdraws it, and a - /// frame older than the one held changes nothing. Contact avatars placed - /// over this product are redrawn when anything changed. + /// Apply authenticated grants within their own scope. Personal changes + /// redraw all placements of the wallet, app changes only this product. async fn record_profile_references( &self, context: &NativeChatContext, @@ -395,21 +393,50 @@ impl NativeChatActor { frames: Vec, ) -> Result<(), Error> { let owner = super::profile::profile_owner(context); + let profile_state = context.services.profile_state_gate.lock().await; let mut changed = false; + let mut personal_changed = false; for frame in frames { - changed |= crate::runtime::profile::record_received_reference( - &*context.services.platform, - owner, - &self.product, - peer, - frame.discloser_product_id, - frame.timestamp, - frame.reference, - ) - .await + use crate::runtime::profile::{ + ProfileScope, record_personal_received_reference, record_received_reference, + }; + let kept = match frame.scope { + ProfileScope::App => { + record_received_reference( + &*context.services.platform, + owner, + &self.product, + peer, + frame.discloser_product_id, + frame.timestamp, + frame.reference, + ) + .await + } + ProfileScope::Personal => { + record_personal_received_reference( + &*context.services.platform, + owner, + peer, + frame.discloser_product_id, + frame.timestamp, + frame.revision, + frame.reference, + ) + .await + } + } .map_err(|_| Error::StorageUnavailable)?; + changed |= kept; + personal_changed |= kept && frame.scope == ProfileScope::Personal; } - if changed { + drop(profile_state); + if personal_changed { + context + .services + .contact_avatars + .redraw_owner(owner, &context.services.spawner); + } else if changed { context.services.contact_avatars.redraw( owner, &self.product, diff --git a/rust/crates/truapi/src/runtime/native_chat/actor/profile.rs b/rust/crates/truapi/src/runtime/native_chat/actor/profile.rs index 28348368d..7f38fb13d 100644 --- a/rust/crates/truapi/src/runtime/native_chat/actor/profile.rs +++ b/rust/crates/truapi/src/runtime/native_chat/actor/profile.rs @@ -19,8 +19,8 @@ //! Publishes on one actor run one at a time, so one that read an older //! disclosure never queues it after a newer one. //! -//! Delivery is best effort. References have their own outbox budget, one per -//! peer, so they never take a slot user traffic needs; a reference that finds +//! Delivery is best effort. References have their own fixed outbox budget, +//! so they never take a slot user traffic needs; a reference that finds //! no room is left for a later publish. A queued reference is offered for one //! statement lifetime. If it lapses unacknowledged, it is signed again and //! offered to a ready peer for another lifetime, up to @@ -30,16 +30,20 @@ use super::*; use crate::runtime::native_chat::background::require_authorized; -use crate::runtime::profile::{Disclosure, ProfileOwner, read_disclosure}; +use crate::runtime::profile::{Disclosure, ProfileOwner, ProfileScope, read_disclosure_state}; /// Frames signed for one disclosure to one peer, the first included, before /// the Host stops offering it until the disclosure changes. pub(super) const MAX_PROFILE_ATTEMPTS: u8 = 3; +pub(super) const WATERMARK_MARKER: [u8; 4] = [0xff, b'P', b'R', 2]; + /// What this Host last queued to one peer. #[derive(Clone, PartialEq, Eq, Encode, Decode)] pub(super) struct ProfileWatermark { pub(super) peer: [u8; 32], + pub(super) scope: ProfileScope, + pub(super) revision: u64, /// Digest of the disclosure sent, identifying it without keeping it; /// `None` once a withdrawal was sent. pub(super) digest: Option<[u8; 32]>, @@ -53,6 +57,17 @@ pub(super) struct ProfileWatermark { pub(super) lapsed: bool, } +/// App-scoped watermarks written before independent personal grants. +#[derive(Decode)] +struct AppWatermark { + peer: [u8; 32], + digest: Option<[u8; 32]>, + discloser_product_id: String, + timestamp: u64, + attempts: u8, + lapsed: bool, +} + /// A watermark as written from 571f348f4 until lapsed frames were resent: no /// attempt count and no lapse marker. #[derive(Decode)] @@ -84,14 +99,35 @@ pub(super) fn decode_watermarks( bytes: &[u8], ) -> Result, parity_scale_codec::Error> { use parity_scale_codec::DecodeAll; - if let Ok(current) = Vec::::decode_all(&mut &bytes[..]) { - return Ok(current); + if let Some(current) = bytes.strip_prefix(&WATERMARK_MARKER) { + let watermarks = Vec::::decode_all(&mut ¤t[..])?; + if watermarks.len() > MAX_PEERS * 2 { + return Err("too many profile watermarks".into()); + } + return Ok(watermarks); + } + if let Ok(app) = Vec::::decode_all(&mut &bytes[..]) { + return Ok(app + .into_iter() + .map(|watermark| ProfileWatermark { + peer: watermark.peer, + scope: ProfileScope::App, + revision: 0, + digest: watermark.digest, + discloser_product_id: watermark.discloser_product_id, + timestamp: watermark.timestamp, + attempts: watermark.attempts, + lapsed: watermark.lapsed, + }) + .collect()); } if let Ok(single) = Vec::::decode_all(&mut &bytes[..]) { return Ok(single .into_iter() .map(|watermark| ProfileWatermark { peer: watermark.peer, + scope: ProfileScope::App, + revision: 0, digest: watermark.digest, discloser_product_id: watermark.discloser_product_id, timestamp: watermark.timestamp, @@ -116,7 +152,7 @@ pub(super) fn profile_owner(context: &NativeChatContext) -> ProfileOwner { /// its own revision and so its own digest, and starts a new round even for /// the same reference; automatic publishes of one disclosure share it. A /// disclosure stored before revisions keeps the digest it was sent under. -fn disclosure_digest(disclosure: &Disclosure) -> [u8; 32] { +pub(super) fn disclosure_digest(disclosure: &Disclosure) -> [u8; 32] { if disclosure.revision == 0 { return hash( &( @@ -149,6 +185,27 @@ struct Frame { attempts: u8, } +fn next_attempt( + disclosure: Option<&(Disclosure, [u8; 32])>, + current: Option<&ProfileWatermark>, + revision: u64, +) -> Option { + if disclosure.is_none() && current.is_none() { + return None; + } + let digest = disclosure.map(|(_, digest)| *digest); + match current { + Some(watermark) + if watermark.digest == digest + && (watermark.scope == ProfileScope::App || watermark.revision == revision) => + { + (watermark.lapsed && watermark.attempts < MAX_PROFILE_ATTEMPTS) + .then(|| watermark.attempts + 1) + } + _ => Some(1), + } +} + /// What one peer should be sent now, given the user's disclosure and its /// digest: the disclosure, a withdrawal of the one it holds, or the frame it /// was last sent again, once that lapsed with attempts to spare. `None` when @@ -156,7 +213,9 @@ struct Frame { fn wanted( disclosure: Option<&(Disclosure, [u8; 32])>, current: Option<&ProfileWatermark>, + revision: u64, ) -> Option { + let attempts = next_attempt(disclosure, current, revision)?; let (discloser, reference, digest) = match (disclosure, current) { (Some((disclosure, digest)), _) => ( &disclosure.product_id, @@ -166,15 +225,6 @@ fn wanted( (None, Some(watermark)) => (&watermark.discloser_product_id, None, None), (None, None) => return None, }; - let attempts = match current { - Some(watermark) if watermark.digest == digest => { - if !watermark.lapsed || watermark.attempts >= MAX_PROFILE_ATTEMPTS { - return None; - } - watermark.attempts + 1 - } - _ => 1, - }; Some(Frame { discloser: discloser.clone(), reference: reference.cloned(), @@ -185,13 +235,35 @@ fn wanted( /// A queued reference whose statement lifetime is over. fn lapsed(entry: &Outgoing, now: u64) -> bool { - matches!(entry.kind, OutgoingKind::ProfileReference(_)) + entry.kind.profile_scope().is_some() && entry .statement .expiry .is_none_or(|expiry| (expiry >> 32) <= now) } +pub(super) fn superseded( + entry: &Outgoing, + watermarks: &[ProfileWatermark], + disclosure: Option<&(Disclosure, [u8; 32])>, + product: &str, + revision: u64, +) -> bool { + let Some(scope) = entry.kind.profile_scope() else { + return false; + }; + let Some(current) = watermarks + .iter() + .find(|watermark| watermark.peer == entry.peer && watermark.scope == scope) + else { + return scope == ProfileScope::Personal; + }; + let desired = disclosure + .filter(|(disclosure, _)| disclosure.grants(scope, product, &entry.peer)) + .map(|(_, digest)| *digest); + current.digest != desired || (scope == ProfileScope::Personal && current.revision != revision) +} + impl NativeChatActor { /// Queue a profile reference (or withdrawal) for every ready peer whose /// watermark differs from the user's current disclosure, or whose last @@ -204,7 +276,7 @@ impl NativeChatActor { context.require_current()?; // Each publish reads the disclosure and then queues it; two at once // could queue the older one last. - let _publishing = self.profile_gate.lock().await; + let _profile_state = context.services.profile_state_gate.lock().await; if self .store .read(|state| state.boundary.legacy_pending) @@ -213,29 +285,70 @@ impl NativeChatActor { return Ok(false); } self.retire_lapsed_profile_references(context).await?; - let disclosure = read_disclosure(&*context.services.platform, profile_owner(context)) - .await - .map_err(|_| Error::StorageUnavailable)? - .map(|disclosure| { - let digest = disclosure_digest(&disclosure); - (disclosure, digest) - }); + let (revision, disclosure) = + read_disclosure_state(&*context.services.platform, profile_owner(context)) + .await + .map_err(|_| Error::StorageUnavailable)?; + let disclosure = disclosure.map(|disclosure| { + let digest = disclosure_digest(&disclosure); + (disclosure, digest) + }); + // Remove superseded shares even for an unready peer. Keep its + // watermark so the withdrawal is still due after restart. + let obsolete = self + .store + .read(|state| { + state.outbox.iter().any(|entry| { + superseded( + entry, + &state.profile_shared, + disclosure.as_ref(), + &self.product, + revision, + ) + }) + }) + .await?; + if obsolete { + let current_disclosure = disclosure.clone(); + let product = self.product.clone(); + let valid = context.session_valid.clone(); + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + state.outbox.retain(|entry| { + !superseded( + entry, + &state.profile_shared, + current_disclosure.as_ref(), + &product, + revision, + ) + }); + Ok(()) + }) + .await?; + } let stale = self .store .read(|state| { - state - .peers - .iter() - .filter(|peer| peer.ready()) - .filter(|peer| { - let current = state - .profile_shared - .iter() - .find(|watermark| watermark.peer == peer.identity); - wanted(disclosure.as_ref(), current).is_some() - }) - .map(|peer| peer.identity) - .collect::>() + let mut stale = Vec::new(); + for peer in state.peers.iter().filter(|peer| peer.ready()) { + for scope in [ProfileScope::App, ProfileScope::Personal] { + let current = state.profile_shared.iter().find(|watermark| { + watermark.peer == peer.identity && watermark.scope == scope + }); + let granted = disclosure.as_ref().filter(|(disclosure, _)| { + disclosure.grants(scope, &self.product, &peer.identity) + }); + if next_attempt(granted, current, revision).is_some() { + stale.push((peer.identity, scope)); + } + } + } + stale }) .await?; if stale.is_empty() { @@ -251,7 +364,7 @@ impl NativeChatActor { } let now = current_unix_secs().saturating_mul(1000); let mut queued = false; - for identity in stale { + for (identity, scope) in stale { let peer = state.peer(&identity)?.clone(); if !peer.ready() { continue; @@ -259,8 +372,11 @@ impl NativeChatActor { let current = state .profile_shared .iter() - .find(|watermark| watermark.peer == identity); - let Some(frame) = wanted(disclosure.as_ref(), current) else { + .find(|watermark| watermark.peer == identity && watermark.scope == scope); + let granted = disclosure.as_ref().filter(|(disclosure, _)| { + disclosure.grants(scope, &actor.product, &identity) + }); + let Some(frame) = wanted(granted, current, revision) else { continue; }; // Later than anything sent to this peer before, even @@ -269,15 +385,38 @@ impl NativeChatActor { let timestamp = current.map_or(now, |watermark| { now.max(watermark.timestamp.saturating_add(1)) }); - let tag = - hash(&(identity, &frame.discloser, &frame.reference, timestamp).encode()); + let tag = match scope { + ProfileScope::App => hash( + &(identity, &frame.discloser, &frame.reference, timestamp).encode(), + ), + ProfileScope::Personal => hash( + &( + identity, + scope, + revision, + &frame.discloser, + &frame.reference, + timestamp, + ) + .encode(), + ), + }; let request_id = format!("profile-{}", hex::encode(&tag[..8])); - let bytes = wire::encode_profile_reference_message( - &request_id, - timestamp, - &frame.discloser, - frame.reference.as_deref(), - ) + let bytes = match scope { + ProfileScope::App => wire::encode_profile_reference_message( + &request_id, + timestamp, + &frame.discloser, + frame.reference.as_deref(), + ), + ProfileScope::Personal => wire::encode_personal_profile_reference_message( + &request_id, + timestamp, + revision, + &frame.discloser, + frame.reference.as_deref(), + ), + } .map_err(|_| Error::InvalidRequest)?; let messages = Zeroizing::new(vec![bytes]); let statement = actor.multi_statement( @@ -287,16 +426,18 @@ impl NativeChatActor { &request_id, &messages, )?; - // Only the newest disclosure is worth delivering. + // Each scope keeps its own pending share or withdrawal. state.outbox.retain(|entry| { - entry.peer != identity - || !matches!(entry.kind, OutgoingKind::ProfileReference(_)) + entry.peer != identity || entry.kind.profile_scope() != Some(scope) }); match state.queue(Outgoing { peer: identity, request_id, digest: hash(&messages.encode()), - kind: OutgoingKind::ProfileReference(tag), + kind: match scope { + ProfileScope::App => OutgoingKind::ProfileReference(tag), + ProfileScope::Personal => OutgoingKind::PersonalProfileReference(tag), + }, roster_revision: peer.revision, statement, last_attempt: 0, @@ -309,9 +450,15 @@ impl NativeChatActor { } state .profile_shared - .retain(|watermark| watermark.peer != identity); + .retain(|watermark| watermark.peer != identity || watermark.scope != scope); state.profile_shared.push(ProfileWatermark { peer: identity, + scope, + revision: if scope == ProfileScope::Personal { + revision + } else { + 0 + }, digest: frame.digest, discloser_product_id: frame.discloser, timestamp, @@ -410,13 +557,13 @@ impl NativeChatActor { if !valid() { return Err(Error::NotConnected); } - // A peer has one reference queued at most, the one its - // watermark records. + // A peer has at most one pending frame per scope. for watermark in &mut state.profile_shared { - watermark.lapsed |= state - .outbox - .iter() - .any(|entry| entry.peer == watermark.peer && lapsed(entry, now)); + watermark.lapsed |= state.outbox.iter().any(|entry| { + entry.peer == watermark.peer + && entry.kind.profile_scope() == Some(watermark.scope) + && lapsed(entry, now) + }); } state.outbox.retain(|entry| !lapsed(entry, now)); Ok(()) @@ -434,6 +581,9 @@ mod tests { product_id: "seity.dot".into(), reference: reference.into(), revision: 1, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), }; let digest = disclosure_digest(&disclosure); (disclosure, digest) @@ -444,21 +594,23 @@ mod tests { let current = disclosure("seity-contacts:v1:aa"); let held = ProfileWatermark { peer: [1; 32], + scope: ProfileScope::App, + revision: 0, digest: Some(current.1), discloser_product_id: "seity.dot".into(), timestamp: 1, attempts: 1, lapsed: false, }; - assert!(wanted(Some(¤t), Some(&held)).is_none()); - let replacement = wanted(Some(&disclosure("seity-contacts:v1:bb")), Some(&held)) + assert!(wanted(Some(¤t), Some(&held), 0).is_none()); + let replacement = wanted(Some(&disclosure("seity-contacts:v1:bb")), Some(&held), 0) .expect("a replacement is sent"); assert_eq!( (replacement.reference.as_deref(), replacement.attempts), (Some("seity-contacts:v1:bb"), 1) ); assert_eq!( - wanted(None, Some(&held)).expect("a withdrawal is sent to a holder"), + wanted(None, Some(&held), 0).expect("a withdrawal is sent to a holder"), Frame { discloser: "seity.dot".into(), reference: None, @@ -471,19 +623,19 @@ mod tests { ..held }; assert!( - wanted(None, Some(&withdrawn)).is_none(), + wanted(None, Some(&withdrawn), 0).is_none(), "a withdrawal is sent once" ); assert!( - wanted(Some(¤t), Some(&withdrawn)).is_some(), + wanted(Some(¤t), Some(&withdrawn), 0).is_some(), "a withdrawn peer is sent a new disclosure" ); assert!( - wanted(None, None).is_none(), + wanted(None, None, 0).is_none(), "nothing to withdraw from a new peer" ); assert!( - wanted(Some(¤t), None).is_some(), + wanted(Some(¤t), None, 0).is_some(), "a new peer is sent the disclosure" ); } @@ -493,14 +645,20 @@ mod tests { let current = disclosure("seity-contacts:v1:aa"); let lapsed_watermark = |digest, attempts| ProfileWatermark { peer: [1; 32], + scope: ProfileScope::App, + revision: 0, digest, discloser_product_id: "seity.dot".into(), timestamp: 1, attempts, lapsed: true, }; - let resent = wanted(Some(¤t), Some(&lapsed_watermark(Some(current.1), 1))) - .expect("a lapsed disclosure is sent again"); + let resent = wanted( + Some(¤t), + Some(&lapsed_watermark(Some(current.1), 1)), + 0, + ) + .expect("a lapsed disclosure is sent again"); assert_eq!( (resent.digest, resent.attempts), (Some(current.1), 2), @@ -509,7 +667,8 @@ mod tests { assert!( wanted( Some(¤t), - Some(&lapsed_watermark(Some(current.1), MAX_PROFILE_ATTEMPTS)) + Some(&lapsed_watermark(Some(current.1), MAX_PROFILE_ATTEMPTS)), + 0 ) .is_none(), "not once its attempts are spent" @@ -517,7 +676,8 @@ mod tests { assert_eq!( wanted( Some(&disclosure("seity-contacts:v1:bb")), - Some(&lapsed_watermark(Some(current.1), MAX_PROFILE_ATTEMPTS)) + Some(&lapsed_watermark(Some(current.1), MAX_PROFILE_ATTEMPTS)), + 0 ) .expect("a new disclosure is sent") .attempts, @@ -525,11 +685,50 @@ mod tests { "with attempts of its own" ); assert_eq!( - wanted(None, Some(&lapsed_watermark(None, 1))) + wanted(None, Some(&lapsed_watermark(None, 1)), 0) .expect("a lapsed withdrawal is sent again") .attempts, 2 ); - assert!(wanted(None, Some(&lapsed_watermark(None, MAX_PROFILE_ATTEMPTS))).is_none()); + assert!(wanted(None, Some(&lapsed_watermark(None, MAX_PROFILE_ATTEMPTS)), 0).is_none()); + } + + #[test] + fn current_app_watermarks_migrate_without_broadening_or_losing_pending_withdrawal() { + let current = disclosure("profile:secret"); + let bytes = vec![( + [1u8; 32], + Some(current.1), + "seity.dot".to_string(), + 91u64, + 2u8, + true, + )] + .encode(); + let migrated = decode_watermarks(&bytes).unwrap(); + assert_eq!(migrated[0].scope, ProfileScope::App); + assert_eq!(migrated[0].timestamp, 91); + assert_eq!( + wanted(Some(¤t), Some(&migrated[0]), 9) + .unwrap() + .attempts, + 3 + ); + assert_eq!( + wanted(None, Some(&migrated[0]), 10).unwrap().reference, + None + ); + let withdrawn = ProfileWatermark { + scope: ProfileScope::Personal, + revision: 10, + digest: None, + lapsed: false, + ..migrated[0].clone() + }; + assert!(wanted(None, Some(&withdrawn), 10).is_none()); + assert!( + wanted(None, Some(&withdrawn), 12).is_some(), + "an actor that missed a regrant must send the newer withdrawal" + ); } } diff --git a/rust/crates/truapi/src/runtime/native_chat/actor/receive.rs b/rust/crates/truapi/src/runtime/native_chat/actor/receive.rs index e3884625e..690f26ecf 100644 --- a/rust/crates/truapi/src/runtime/native_chat/actor/receive.rs +++ b/rust/crates/truapi/src/runtime/native_chat/actor/receive.rs @@ -960,7 +960,11 @@ fn exchange_digest(messages: &[OpenedDeviceMessage]) -> Result<[u8; 32], Error> continue; } OpenedDeviceMessage::ProfileReference(frame) => { - hasher.update(&[6]); + let personal = frame.scope == crate::runtime::profile::ProfileScope::Personal; + hasher.update(&[if personal { 7 } else { 6 }]); + if personal { + hasher.update(&frame.revision.to_le_bytes()); + } let bytes = ( frame.message_id.as_str(), frame.timestamp, diff --git a/rust/crates/truapi/src/runtime/native_chat/actor/tests.rs b/rust/crates/truapi/src/runtime/native_chat/actor/tests.rs index 745b8e552..9e3df2e63 100644 --- a/rust/crates/truapi/src/runtime/native_chat/actor/tests.rs +++ b/rust/crates/truapi/src/runtime/native_chat/actor/tests.rs @@ -6,11 +6,13 @@ mod hop_history; mod native_wallet; use super::*; +use crate::platform::CoreStorageKey; use crate::{ host_logic::statement_store::decode_verified_statement_data, runtime::{authority::AuthoritySession, services::RuntimeServices}, subscription::Spawner, test_support::{StubPlatform, core_storage_test_key, wait_until}, + versioned::IntoLatest, }; use futures::{ executor::block_on, @@ -18,7 +20,6 @@ use futures::{ }; use parking_lot::Mutex; use truapi_coinage::{MemoEntry, TransferMemo}; -use crate::platform::CoreStorageKey; const PRODUCT: &str = "chat.dot"; @@ -1624,7 +1625,7 @@ fn a_snapshot_with_legacy_profile_watermarks_opens_and_resends() { // The same state with its watermarks in the legacy layout. let trailing = state.profile_shared.encode(); - let mut legacy = current[..current.len() - trailing.len()].to_vec(); + let mut legacy = current[..current.len() - trailing.len() - 4].to_vec(); legacy.extend( state .profile_shared @@ -1700,7 +1701,7 @@ fn a_snapshot_with_single_attempt_profile_watermarks_keeps_them() { .update(|state| { let current = state.encode(); let trailing = state.profile_shared.encode(); - let mut single = current[..current.len() - trailing.len()].to_vec(); + let mut single = current[..current.len() - trailing.len() - 4].to_vec(); single.extend( state .profile_shared @@ -1787,6 +1788,9 @@ fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retrac product_id: "seity.dot".into(), reference: PROFILE_REFERENCE.into(), revision: 1, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), }, ) .await @@ -1828,6 +1832,9 @@ fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retrac product_id: "seity.dot".into(), reference: format!("{PROFILE_REFERENCE}ff"), revision: 1, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), }, ) .await @@ -1882,6 +1889,9 @@ fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retrac product_id: "seity.dot".into(), reference: PROFILE_REFERENCE.into(), revision: 1, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), }, ) .await @@ -1913,6 +1923,9 @@ fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retrac product_id: "seity.dot".into(), reference: PROFILE_REFERENCE.into(), revision: 2, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), }, ) .await @@ -2033,6 +2046,7 @@ fn contact_avatars_over_the_product_follow_what_the_contact_shares() { host.clone(), fixture.platform.clone(), crate::platform::ProductContext::new(PRODUCT.to_string()).unwrap(), + Arc::downgrade(&fixture.context.services), ) }); let rect = truapi::v01::AvatarRect { @@ -2050,17 +2064,21 @@ fn contact_avatars_over_the_product_follow_what_the_contact_shares() { placement .place( profile::profile_owner(&fixture.context), - truapi::v02::HostProfilePlaceContactAvatarsRequest { - surface_width: 360, - surface_height: 640, - own: None, - slots: vec![truapi::v01::ContactAvatarSlot { - slot: 7, - peer_identity: identity.account, - rect, - clip, - }], - }, + truapi::versioned::profile::HostProfilePlaceContactAvatarsRequest::V2( + truapi::v02::HostProfilePlaceContactAvatarsRequest { + surface_width: 360, + surface_height: 640, + own: None, + slots: vec![truapi::v01::ContactAvatarSlot { + slot: 7, + peer_identity: identity.account, + rect, + clip, + }], + }, + ) + .into_latest(), + None, ) .await .unwrap(); @@ -2096,10 +2114,7 @@ fn contact_avatars_over_the_product_follow_what_the_contact_shares() { }; assert_eq!( host.wait_for(2), - vec![ - placed(Vec::new()), - placed(vec![avatar(fixture.timestamp)]), - ] + vec![placed(Vec::new()), placed(vec![avatar(fixture.timestamp)]),] ); // The contact re-shares the same reference (its record changed): the // host is told, with the newer frame's time, so it drops its cache. @@ -2257,6 +2272,9 @@ async fn disclose_for(fixture: &Fixture) { product_id: "seity.dot".into(), reference: PROFILE_REFERENCE.into(), revision: 1, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), }, ) .await @@ -2476,6 +2494,9 @@ fn an_unacknowledged_reference_is_resent_a_bounded_number_of_times_per_disclosur product_id: "seity.dot".into(), reference: format!("{PROFILE_REFERENCE}ff"), revision: 1, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), }, ) .await @@ -2689,6 +2710,9 @@ fn a_changed_disclosure_is_relayed_by_the_open_chats_of_its_wallet() { product_id: "seity.dot".into(), reference: PROFILE_REFERENCE.into(), revision: 1, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), }, )) .unwrap(); @@ -2719,3 +2743,618 @@ fn a_changed_disclosure_is_relayed_by_the_open_chats_of_its_wallet() { "another wallet's Chat is not told" ); } + +async fn open_personal_profile_frame( + fixture: &Fixture, + actor: &Arc, + identity: &IdentityFixture, + peer: &DeviceFixture, + request_id: &str, + (revision, timestamp): (u64, u64), + reference: Option<&str>, +) { + let frame = wire::encode_personal_profile_reference_message( + &format!("{request_id}-frame"), + timestamp, + revision, + "seity.dot", + reference, + ) + .unwrap(); + let plaintext = wire::encode_transport_request_plaintext(request_id, &[frame]).unwrap(); + let packet = native_packet(actor, identity, peer, &plaintext, false, false); + let (opened, _) = actor + .open_statement(&fixture.context, &NativeChatRegistry::default(), packet) + .await + .unwrap(); + assert!( + opened + .iter() + .all(|opened| !contains(&opened.plaintext, PROFILE_REFERENCE.as_bytes())) + ); +} + +async fn queued_profile_contents( + fixture: &Fixture, + actor: &Arc, + identity: &IdentityFixture, + device: &DeviceFixture, +) -> Vec { + actor + .public_view(&fixture.context, Vec::new()) + .await + .unwrap() + .prepared + .into_iter() + .filter(|entry| entry.peer_identity == identity.account) + .map(|entry| { + let wire::V2StatementTransportData::MultiRequest(native) = + open_output(actor, identity, &entry.statement, false, false) + else { + panic!("profile must use authenticated multi-device transport"); + }; + let body = open_body( + actor, + device, + &native.encrypted_request, + &native.devices_info, + ); + let request = wire::decode_message_exchange_request_plaintext(&body).unwrap(); + assert_eq!(request.messages.len(), 1); + wire::decode_message(&request.messages[0]).unwrap().content + }) + .collect() +} + +#[test] +fn profile_audiences_select_exact_identity_accounts_and_keep_scopes_independent_after_restart() { + block_on(async { + use crate::runtime::profile::{Disclosure, read_disclosure_state, write_disclosure}; + let fixture = Fixture::new(); + for product in [PRODUCT, "other.dot"] { + set_product_grants( + &fixture.platform, + product, + crate::platform::PermissionAuthorizationStatus::Authorized, + ) + .await; + } + let actor = fixture.actor().await; + let other = NativeChatActor::open(&fixture.context, "other.dot") + .await + .unwrap(); + let selected = IdentityFixture::new(); + let bystander = IdentityFixture { + account: keypair(0x72).public.to_bytes(), + secret: [0x73; 32], + }; + let device = DeviceFixture::new(1); + let other_device = DeviceFixture::new(2); + for chat in [&actor, &other] { + seed_peer(chat, &selected, &[&device]).await; + seed_peer(chat, &bystander, &[&other_device]).await; + } + let owner = profile::profile_owner(&fixture.context); + let mut disclosure = Disclosure { + product_id: "seity.dot".into(), + reference: PROFILE_REFERENCE.into(), + revision: 1, + all_chat_apps: false, + app_products: vec![PRODUCT.into()], + contacts: vec![selected.account, other_device.account()], + }; + write_disclosure(fixture.platform.as_ref(), owner, &disclosure) + .await + .unwrap(); + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + assert!( + other + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + let personal_share = wire::V2ChatMessageContent::PersonalProfileReference { + discloser_product_id: "seity.dot".into(), + reference: Some(PROFILE_REFERENCE.into()), + revision: 1, + }; + assert_eq!( + queued_profile_contents(&fixture, &other, &selected, &device).await, + vec![personal_share.clone()] + ); + assert_eq!( + queued_profile_contents(&fixture, &actor, &selected, &device).await, + vec![ + wire::V2ChatMessageContent::ProfileReference { + discloser_product_id: "seity.dot".into(), + reference: Some(PROFILE_REFERENCE.into()), + }, + personal_share + ] + ); + assert_eq!( + queued_profile_contents(&fixture, &actor, &bystander, &other_device).await, + vec![wire::V2ChatMessageContent::ProfileReference { + discloser_product_id: "seity.dot".into(), + reference: Some(PROFILE_REFERENCE.into()), + }] + ); + assert!( + queued_profile_contents(&fixture, &other, &bystander, &other_device) + .await + .is_empty(), + "a selected device account must not be translated to its peer identity" + ); + + disclosure.contacts.clear(); + write_disclosure(fixture.platform.as_ref(), owner, &disclosure) + .await + .unwrap(); + assert!( + queued_profile_contents(&fixture, &actor, &selected, &device) + .await + .is_empty(), + "a response cannot expose superseded pending shares before the relay runs" + ); + let platform = fixture.platform.clone(); + fixture.tasks.stop(); + drop(actor); + drop(other); + drop(fixture); + let fixture = Fixture::on_platform(platform); + let actor = fixture.actor().await; + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + let revision = read_disclosure_state(fixture.platform.as_ref(), owner) + .await + .unwrap() + .0; + assert_eq!( + queued_profile_contents(&fixture, &actor, &selected, &device).await, + vec![ + wire::V2ChatMessageContent::ProfileReference { + discloser_product_id: "seity.dot".into(), + reference: Some(PROFILE_REFERENCE.into()), + }, + wire::V2ChatMessageContent::PersonalProfileReference { + discloser_product_id: "seity.dot".into(), + reference: None, + revision, + }, + ], + "a never-delivered personal share still requires a durable withdrawal" + ); + + disclosure.app_products.clear(); + disclosure.contacts = vec![selected.account]; + write_disclosure(fixture.platform.as_ref(), owner, &disclosure) + .await + .unwrap(); + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + let revision = read_disclosure_state(fixture.platform.as_ref(), owner) + .await + .unwrap() + .0; + assert_eq!( + queued_profile_contents(&fixture, &actor, &selected, &device).await, + vec![ + wire::V2ChatMessageContent::ProfileReference { + discloser_product_id: "seity.dot".into(), + reference: None, + }, + wire::V2ChatMessageContent::PersonalProfileReference { + discloser_product_id: "seity.dot".into(), + reference: Some(PROFILE_REFERENCE.into()), + revision, + }, + ], + "a personal share cannot overwrite a pending app withdrawal" + ); + }); +} + +#[test] +fn personal_references_render_across_apps_with_independent_withdrawals_and_global_replay_order() { + block_on(async { + use crate::runtime::profile::{avatars::ContactAvatarPlacement, received_reference}; + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let other = NativeChatActor::open(&fixture.context, "other.dot") + .await + .unwrap(); + let identity = IdentityFixture::new(); + let device = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&device]).await; + seed_peer(&other, &identity, &[&device]).await; + let owner = profile::profile_owner(&fixture.context); + let host = Arc::new(crate::test_support::RecordingAvatarHost::default()); + let placement = fixture + .context + .services + .contact_avatars + .for_runtime(41, || { + ContactAvatarPlacement::new( + host.clone(), + fixture.platform.clone(), + crate::platform::ProductContext::new("not-chat.dot".into()).unwrap(), + Arc::downgrade(&fixture.context.services), + ) + }); + placement + .place( + owner, + truapi::versioned::profile::HostProfilePlaceContactAvatarsRequest::V2( + truapi::v02::HostProfilePlaceContactAvatarsRequest { + surface_width: 100, + surface_height: 100, + own: None, + slots: vec![truapi::v01::ContactAvatarSlot { + slot: 1, + peer_identity: identity.account, + rect: truapi::v01::AvatarRect { + x: 0, + y: 0, + width: 40, + height: 40, + }, + clip: truapi::v01::AvatarRect { + x: 0, + y: 0, + width: 100, + height: 100, + }, + }], + }, + ) + .into_latest(), + None, + ) + .await + .unwrap(); + open_personal_profile_frame( + &fixture, + &other, + &identity, + &device, + "personal-share", + (10, fixture.timestamp), + Some(PROFILE_REFERENCE), + ) + .await; + let draws = host.wait_for(2); + assert_eq!(draws[1].0, "not-chat.dot"); + assert_eq!(draws[1].1.avatars[0].reference, PROFILE_REFERENCE); + for product in [PRODUCT, "other.dot", "not-chat.dot"] { + assert_eq!( + received_reference(fixture.platform.as_ref(), owner, product, &identity.account) + .await + .unwrap() + .unwrap() + .reference + .as_deref(), + Some(PROFILE_REFERENCE) + ); + } + let app_reference = format!("{PROFILE_REFERENCE}ff"); + open_profile_frame( + &fixture, + &actor, + &identity, + &device, + "app-share", + fixture.timestamp + 1, + Some(&app_reference), + ) + .await; + assert_eq!( + held_reference(&fixture, &identity).await.as_deref(), + Some(app_reference.as_str()) + ); + open_personal_profile_frame( + &fixture, + &actor, + &identity, + &device, + "personal-withdrawal", + (11, fixture.timestamp + 2), + None, + ) + .await; + assert_eq!( + held_reference(&fixture, &identity).await.as_deref(), + Some(app_reference.as_str()), + "a personal withdrawal cannot remove an app grant" + ); + assert!(host.wait_for(3)[2].1.avatars.is_empty()); + open_personal_profile_frame( + &fixture, + &other, + &identity, + &device, + "late-stale-share", + (10, fixture.timestamp + 100), + Some(PROFILE_REFERENCE), + ) + .await; + assert!( + received_reference( + fixture.platform.as_ref(), + owner, + "not-chat.dot", + &identity.account + ) + .await + .unwrap() + .unwrap() + .reference + .is_none(), + "relay time cannot defeat a cross-app tombstone" + ); + open_personal_profile_frame( + &fixture, + &other, + &identity, + &device, + "personal-regrant", + (12, fixture.timestamp), + Some(PROFILE_REFERENCE), + ) + .await; + let redraws = host.wait_for(4); + assert!( + redraws[3].1.avatars[0].shared_at > draws[1].1.avatars[0].shared_at, + "a newer personal revision refreshes the profile despite another actor's older clock" + ); + open_profile_frame( + &fixture, + &actor, + &identity, + &device, + "app-withdrawal", + fixture.timestamp + 4, + None, + ) + .await; + assert_eq!( + held_reference(&fixture, &identity).await.as_deref(), + Some(PROFILE_REFERENCE), + "an app withdrawal exposes the independent personal fallback" + ); + open_personal_profile_frame( + &fixture, + &actor, + &identity, + &device, + "late-stale-withdrawal", + (11, fixture.timestamp + 101), + None, + ) + .await; + assert_eq!( + held_reference(&fixture, &identity).await.as_deref(), + Some(PROFILE_REFERENCE) + ); + let platform = fixture.platform.clone(); + fixture.tasks.stop(); + drop(actor); + drop(other); + drop(fixture); + let fixture = Fixture::on_platform(platform); + let actor = fixture.actor().await; + open_personal_profile_frame( + &fixture, + &actor, + &identity, + &device, + "restart-stale-share", + (10, fixture.timestamp + 200), + Some(&app_reference), + ) + .await; + assert_eq!( + held_reference(&fixture, &identity).await.as_deref(), + Some(PROFILE_REFERENCE) + ); + let other_owner = crate::runtime::profile::ProfileOwner { + genesis_hash: [99; 32], + ..owner + }; + assert!( + received_reference( + fixture.platform.as_ref(), + other_owner, + PRODUCT, + &identity.account + ) + .await + .unwrap() + .is_none() + ); + }); +} + +#[test] +fn profile_storage_migrates_legacy_audiences_and_retains_retraction_sequence() { + block_on(async { + use crate::runtime::profile::{ + clear_disclosure, read_disclosure, read_disclosure_state, write_disclosure, + }; + let fixture = Fixture::new(); + let owner = profile::profile_owner(&fixture.context); + for (raw, revision) in [ + ( + ("seity.dot".to_string(), PROFILE_REFERENCE.to_string()).encode(), + 0, + ), + ( + ( + "seity.dot".to_string(), + PROFILE_REFERENCE.to_string(), + 42u64, + ) + .encode(), + 42, + ), + ] { + crate::platform::CoreStorage::write_core_storage( + fixture.platform.as_ref(), + owner.disclosure_key(), + raw, + ) + .await + .unwrap(); + let legacy = read_disclosure(fixture.platform.as_ref(), owner) + .await + .unwrap() + .unwrap(); + assert!(legacy.all_chat_apps); + assert!(legacy.app_products.is_empty() && legacy.contacts.is_empty()); + assert_eq!(legacy.revision, revision); + clear_disclosure(fixture.platform.as_ref(), owner) + .await + .unwrap(); + assert_eq!( + read_disclosure_state(fixture.platform.as_ref(), owner) + .await + .unwrap(), + (revision + 1, None) + ); + write_disclosure(fixture.platform.as_ref(), owner, &legacy) + .await + .unwrap(); + assert_eq!( + read_disclosure_state(fixture.platform.as_ref(), owner) + .await + .unwrap() + .0, + revision + 2 + ); + } + }); +} + +#[test] +fn personal_pending_shares_are_removed_while_unready_and_withdrawn_when_ready() { + block_on(async { + use crate::runtime::profile::{ + Disclosure, ProfileScope, clear_disclosure, write_disclosure, + }; + let fixture = Fixture::new(); + set_product_grants( + &fixture.platform, + PRODUCT, + crate::platform::PermissionAuthorizationStatus::Authorized, + ) + .await; + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let device = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&device]).await; + let owner = profile::profile_owner(&fixture.context); + write_disclosure( + fixture.platform.as_ref(), + owner, + &Disclosure { + product_id: "seity.dot".into(), + reference: PROFILE_REFERENCE.into(), + revision: 1, + all_chat_apps: false, + app_products: Vec::new(), + contacts: vec![identity.account], + }, + ) + .await + .unwrap(); + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(); + let peer = identity.account; + actor + .store + .update(move |state| { + state.peer_mut(&peer)?.established = false; + Ok(()) + }) + .await + .unwrap(); + clear_disclosure(fixture.platform.as_ref(), owner) + .await + .unwrap(); + assert!( + !actor + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + actor + .store + .read(|state| { + assert!( + state + .outbox + .iter() + .all(|entry| entry.kind.profile_scope() != Some(ProfileScope::Personal)) + ); + assert_eq!(state.profile_shared[0].scope, ProfileScope::Personal); + assert!(state.profile_shared[0].digest.is_some()); + }) + .await + .unwrap(); + actor + .store + .update(move |state| { + state.peer_mut(&peer)?.established = true; + Ok(()) + }) + .await + .unwrap(); + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(); + assert_eq!( + queued_profile_contents(&fixture, &actor, &identity, &device).await, + vec![wire::V2ChatMessageContent::PersonalProfileReference { + discloser_product_id: "seity.dot".into(), + reference: None, + revision: 2, + }] + ); + for _ in 1..profile::MAX_PROFILE_ATTEMPTS { + lapse_profile_references(&actor).await; + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + } + lapse_profile_references(&actor).await; + assert!( + !actor + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + assert!( + queued_profile_contents(&fixture, &actor, &identity, &device) + .await + .is_empty() + ); + }); +} diff --git a/rust/crates/truapi/src/runtime/profile.rs b/rust/crates/truapi/src/runtime/profile.rs index 6841e9ea8..c0305cada 100644 --- a/rust/crates/truapi/src/runtime/profile.rs +++ b/rust/crates/truapi/src/runtime/profile.rs @@ -8,8 +8,8 @@ pub(crate) mod avatars; -use parity_scale_codec::{Decode, DecodeAll, Encode}; use crate::platform::{CoreStorage, CoreStorageKey}; +use parity_scale_codec::{Decode, DecodeAll, Encode}; /// The wallet and Chat network a disclosure, and what contacts sent back, /// belong to. @@ -36,6 +36,13 @@ impl ProfileOwner { product_id: product_id.to_string(), } } + + fn personal_received_key(&self) -> CoreStorageKey { + CoreStorageKey::ProfilePersonalReferencesReceived { + root_public_key: self.root_public_key, + genesis_hash: self.genesis_hash, + } + } } /// The user's own disclosed reference and the product that disclosed it. @@ -48,6 +55,40 @@ pub(crate) struct Disclosure { /// it changed) starts a new round to every contact. `0` for a disclosure /// stored before revisions existed. pub(crate) revision: u64, + /// Legacy sharing to every ready peer of every authorized Chat app. + pub(crate) all_chat_apps: bool, + /// Selected app-scoped audiences, independent of personal grants. + pub(crate) app_products: Vec, + /// Exact authenticated peer identity accounts selected through Contacts. + pub(crate) contacts: Vec<[u8; 32]>, +} + +/// A disclosure written before selected audiences existed. +#[derive(Decode)] +struct AllChatDisclosure { + product_id: String, + reference: String, + revision: u64, +} + +/// Independent grants for the receiving app or the receiving wallet. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub(crate) enum ProfileScope { + #[codec(index = 0)] + App, + #[codec(index = 1)] + Personal, +} + +impl Disclosure { + pub(crate) fn grants(&self, scope: ProfileScope, product: &str, peer: &[u8; 32]) -> bool { + match scope { + ProfileScope::App => { + self.all_chat_apps || self.app_products.iter().any(|id| id == product) + } + ProfileScope::Personal => self.contacts.contains(peer), + } + } } /// A disclosure as stored before revisions: product and reference only. @@ -63,8 +104,8 @@ pub(crate) struct ReceivedReference { pub(crate) peer_identity: [u8; 32], /// The product on the contact's side that disclosed it. pub(crate) discloser_product_id: String, - /// Sender timestamp of the frame this reflects; only a later frame - /// replaces it. + /// Frame freshness timestamp. Personal grants order by durable revision + /// and advance this value even when another actor's relay clock is older. pub(crate) timestamp: u64, /// `None` once withdrawn. The withdrawal is kept, so an older disclosure /// opened after it cannot bring the reference back. @@ -78,6 +119,14 @@ enum StoredReferences { V1(Vec), } +#[derive(Encode, Decode)] +struct PersonalReference { + received: ReceivedReference, + revision: u64, +} + +const DISCLOSURE_MARKER: [u8; 4] = [0xff, b'P', b'D', 2]; + /// A contact roster is bounded; so is what the host keeps for it. const MAX_RECEIVED_REFERENCES: usize = 4096; @@ -89,24 +138,60 @@ pub(crate) async fn read_disclosure( storage: &(impl CoreStorage + ?Sized), owner: ProfileOwner, ) -> Result, String> { + Ok(read_disclosure_state(storage, owner).await?.1) +} + +/// The sequence survives retraction so every Chat app orders personal grants alike. +pub(crate) async fn read_disclosure_state( + storage: &(impl CoreStorage + ?Sized), + owner: ProfileOwner, +) -> Result<(u64, Option), String> { let Some(raw) = storage .read_core_storage(owner.disclosure_key()) .await .map_err(storage_error)? else { - return Ok(None); + return Ok((0, None)); }; let bytes = raw.as_slice(); - if let Ok(current) = Disclosure::decode_all(&mut &bytes[..]) { - return Ok(Some(current)); + if let Some(current) = bytes.strip_prefix(&DISCLOSURE_MARKER) { + let state = <(u64, Option)>::decode_all(&mut ¤t[..]) + .map_err(|error| format!("stored profile disclosure is unreadable: {error}"))?; + if state + .1 + .as_ref() + .is_some_and(|disclosure| disclosure.revision != state.0) + { + return Err("stored profile revision is inconsistent".into()); + } + return Ok(state); } - UnrevisedDisclosure::decode_all(&mut &bytes[..]) - .map(|old| { + if let Ok(old) = AllChatDisclosure::decode_all(&mut &bytes[..]) { + return Ok(( + old.revision, Some(Disclosure { product_id: old.product_id, reference: old.reference, - revision: 0, - }) + revision: old.revision, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), + }), + )); + } + UnrevisedDisclosure::decode_all(&mut &bytes[..]) + .map(|old| { + ( + 0, + Some(Disclosure { + product_id: old.product_id, + reference: old.reference, + revision: 0, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), + }), + ) }) .map_err(|error| format!("stored profile disclosure is unreadable: {error}")) } @@ -116,8 +201,23 @@ pub(crate) async fn write_disclosure( owner: ProfileOwner, disclosure: &Disclosure, ) -> Result<(), String> { + let previous = read_disclosure_state(storage, owner).await?.0; + let revision = disclosure.revision.max( + previous + .checked_add(1) + .ok_or("profile revision exhausted")?, + ); + let fields = ( + &disclosure.product_id, + &disclosure.reference, + revision, + disclosure.all_chat_apps, + &disclosure.app_products, + &disclosure.contacts, + ); + let bytes = (DISCLOSURE_MARKER, revision, Some(fields)).encode(); storage - .write_core_storage(owner.disclosure_key(), disclosure.encode()) + .write_core_storage(owner.disclosure_key(), bytes) .await .map_err(storage_error) } @@ -126,43 +226,121 @@ pub(crate) async fn clear_disclosure( storage: &(impl CoreStorage + ?Sized), owner: ProfileOwner, ) -> Result<(), String> { + let revision = read_disclosure_state(storage, owner) + .await? + .0 + .checked_add(1) + .ok_or("profile revision exhausted")?; storage - .clear_core_storage(owner.disclosure_key()) + .write_core_storage( + owner.disclosure_key(), + (DISCLOSURE_MARKER, revision, None::).encode(), + ) .await .map_err(storage_error) } -async fn read_received( +async fn read_received_slot( storage: &(impl CoreStorage + ?Sized), - owner: ProfileOwner, - product_id: &str, + key: CoreStorageKey, ) -> Result, String> { let Some(raw) = storage - .read_core_storage(owner.received_key(product_id)) + .read_core_storage(key) .await .map_err(storage_error)? else { return Ok(Vec::new()); }; - match StoredReferences::decode(&mut raw.as_slice()) { - Ok(StoredReferences::V1(entries)) => Ok(entries), + match StoredReferences::decode_all(&mut raw.as_slice()) { + Ok(StoredReferences::V1(entries)) if entries.len() <= MAX_RECEIVED_REFERENCES => { + Ok(entries) + } + Ok(_) => Err("too many contact profile references".to_string()), Err(error) => Err(format!("stored profile references are unreadable: {error}")), } } -/// What a contact's host last sent this product's user, withdrawals included. -pub(crate) async fn received_reference( +async fn read_personal_received( + storage: &(impl CoreStorage + ?Sized), + owner: ProfileOwner, +) -> Result, String> { + let Some(raw) = storage + .read_core_storage(owner.personal_received_key()) + .await + .map_err(storage_error)? + else { + return Ok(Vec::new()); + }; + let (version, entries) = <(u8, Vec)>::decode_all(&mut raw.as_slice()) + .map_err(|error| format!("stored personal profile references are unreadable: {error}"))?; + if version != 1 || entries.len() > MAX_RECEIVED_REFERENCES { + return Err("invalid personal profile references".into()); + } + Ok(entries) +} + +async fn read_received( + storage: &(impl CoreStorage + ?Sized), + owner: ProfileOwner, + product_id: &str, +) -> Result, String> { + use std::collections::{BTreeMap, btree_map::Entry}; + let mut entries = read_received_slot(storage, owner.received_key(product_id)) + .await? + .into_iter() + .map(|entry| (entry.peer_identity, entry)) + .collect::>(); + for PersonalReference { received, .. } in read_personal_received(storage, owner).await? { + match entries.entry(received.peer_identity) { + Entry::Occupied(mut held) + if held.get().reference.is_none() && received.reference.is_some() => + { + held.insert(received); + } + Entry::Vacant(slot) => { + slot.insert(received); + } + _ => {} + } + } + Ok(entries.into_values().collect()) +} + +/// App-only lookup for APIs whose result may reveal whether an app grant exists. +pub(crate) async fn received_app_reference( storage: &(impl CoreStorage + ?Sized), owner: ProfileOwner, product_id: &str, peer_identity: &[u8; 32], ) -> Result, String> { - Ok(read_received(storage, owner, product_id) + Ok(read_received_slot(storage, owner.received_key(product_id)) .await? .into_iter() .find(|entry| &entry.peer_identity == peer_identity)) } +/// Effective grant: a live app reference takes precedence over a personal grant. +pub(crate) async fn received_reference( + storage: &(impl CoreStorage + ?Sized), + owner: ProfileOwner, + product_id: &str, + peer_identity: &[u8; 32], +) -> Result, String> { + let app = received_app_reference(storage, owner, product_id, peer_identity).await?; + if app.as_ref().is_some_and(|entry| entry.reference.is_some()) { + return Ok(app); + } + let personal = read_personal_received(storage, owner) + .await? + .into_iter() + .find(|entry| &entry.received.peer_identity == peer_identity) + .map(|entry| entry.received); + Ok(match personal { + Some(personal) if personal.reference.is_some() || app.is_none() => Some(personal), + _ => app, + }) +} + /// Record a frame a contact's host sent, if it is newer than the one held: /// a reference replaces the old one, and `None` withdraws it. A frame that is /// not strictly newer is a replay or was overtaken, and changes nothing. @@ -176,7 +354,8 @@ pub(crate) async fn record_received_reference( timestamp: u64, reference: Option, ) -> Result { - let mut entries = read_received(storage, owner, product_id).await?; + let key = owner.received_key(product_id); + let mut entries = read_received_slot(storage, key.clone()).await?; let received = ReceivedReference { peer_identity, discloser_product_id, @@ -195,10 +374,61 @@ pub(crate) async fn record_received_reference( None => entries.push(received), } storage - .write_core_storage( - owner.received_key(product_id), - StoredReferences::V1(entries).encode(), - ) + .write_core_storage(key, StoredReferences::V1(entries).encode()) + .await + .map_err(storage_error)?; + Ok(true) +} + +/// Record a personal frame by the sender's durable revision, not its relay time. +/// Callers serialize updates across products with the host's profile state gate. +pub(crate) async fn record_personal_received_reference( + storage: &(impl CoreStorage + ?Sized), + owner: ProfileOwner, + peer_identity: [u8; 32], + discloser_product_id: String, + timestamp: u64, + revision: u64, + reference: Option, +) -> Result { + if revision == 0 { + return Err("invalid personal profile revision".into()); + } + let mut entries = read_personal_received(storage, owner).await?; + let mut entry = PersonalReference { + received: ReceivedReference { + peer_identity, + discloser_product_id, + timestamp, + reference, + }, + revision, + }; + match entries + .iter() + .position(|held| held.received.peer_identity == peer_identity) + { + Some(index) if entries[index].revision >= revision => return Ok(false), + Some(index) => { + // Hosts invalidate resolved profile caches using shared_at. + // Cross-app ordering is by revision, but that must also advance + // the render token when the newer actor's clock is behind. + entry.received.timestamp = timestamp.max( + entries[index] + .received + .timestamp + .checked_add(1) + .ok_or("personal profile freshness exhausted")?, + ); + entries[index] = entry; + } + None if entries.len() >= MAX_RECEIVED_REFERENCES => { + return Err("too many personal profile references".into()); + } + None => entries.push(entry), + } + storage + .write_core_storage(owner.personal_received_key(), (1u8, entries).encode()) .await .map_err(storage_error)?; Ok(true) diff --git a/rust/crates/truapi/src/runtime/profile/avatars.rs b/rust/crates/truapi/src/runtime/profile/avatars.rs index 35d4ae70e..8d9e18d62 100644 --- a/rust/crates/truapi/src/runtime/profile/avatars.rs +++ b/rust/crates/truapi/src/runtime/profile/avatars.rs @@ -1,7 +1,7 @@ //! Avatars the host draws over a chat product: its contacts' and the signed-in //! user's own. //! -//! A product says where it draws each contact's avatar, by peer identity, and +//! A product says where it draws each contact's avatar, by identity or handle, and //! optionally where it draws the user's own. The core fills in the reference //! each contact shared, and the user's own disclosure, and hands the host only //! the avatars it can draw. The product gets the same answer whoever shared, and @@ -12,14 +12,20 @@ //! disappears without the product sending it again. use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, Weak}; -use tracing::debug; -use truapi::{v01, v02}; use crate::platform::{PlacedAvatar, PlacedAvatars, Platform, ProductContext, ProfilePlatform}; +use tracing::debug; +use truapi::latest::{ + ContactAvatarSlot, HostProfilePlaceContactAvatarsError, HostProfilePlaceContactAvatarsRequest, + ProfileContact, +}; use super::{ProfileOwner, read_disclosure, read_received}; -use crate::runtime::is_screened_profile_reference; +use crate::runtime::{ + ProductAuthority, RuntimeServices, contacts::ContactHandles, is_screened_profile_reference, + resolve_contact_accounts, +}; use crate::subscription::Spawner; /// Most avatars one placement may hold: a screenful of list rows and a header. @@ -31,7 +37,7 @@ const MAX_AVATAR_SIDE: u32 = 1024; /// Why a placement is malformed, if it is. Only input the product controls is /// judged here, never what any contact shared. -pub(crate) fn validate(request: &v02::HostProfilePlaceContactAvatarsRequest) -> Result<(), String> { +pub(crate) fn validate(request: &HostProfilePlaceContactAvatarsRequest) -> Result<(), String> { let surface = 1..=MAX_SURFACE_SIDE; if !surface.contains(&request.surface_width) || !surface.contains(&request.surface_height) { return Err(format!("surface sides must be 1 to {MAX_SURFACE_SIDE}")); @@ -59,15 +65,22 @@ pub(crate) struct ContactAvatarPlacement { platform: Arc, storage: Arc, product: ProductContext, + services: Weak, /// Held across each draw, so the host sees the connection's placements in /// the order they were made. state: futures::lock::Mutex, } +struct RememberedPlacement { + owner: ProfileOwner, + request: HostProfilePlaceContactAvatarsRequest, + authority: Option>, +} + #[derive(Default)] struct PlacementState { /// The last non-empty placement and the wallet it was drawn for. - placed: Option<(ProfileOwner, v02::HostProfilePlaceContactAvatarsRequest)>, + placed: Option, /// The connection is gone; nothing is drawn for it again. closed: bool, } @@ -77,11 +90,13 @@ impl ContactAvatarPlacement { platform: Arc, storage: Arc, product: ProductContext, + services: Weak, ) -> Self { Self { platform, storage, product, + services, state: futures::lock::Mutex::new(PlacementState::default()), } } @@ -92,16 +107,21 @@ impl ContactAvatarPlacement { pub(crate) async fn place( &self, owner: ProfileOwner, - request: v02::HostProfilePlaceContactAvatarsRequest, - ) -> Result<(), v01::HostProfilePlaceContactAvatarsError> { + request: HostProfilePlaceContactAvatarsRequest, + authority: Option>, + ) -> Result<(), HostProfilePlaceContactAvatarsError> { let mut state = self.state.lock().await; if state.closed { return Ok(()); } state.placed = None; - self.draw(owner, &request).await?; + self.draw(owner, &request, authority.as_ref()).await?; if request.own.is_some() || !request.slots.is_empty() { - state.placed = Some((owner, request)); + state.placed = Some(RememberedPlacement { + owner, + request, + authority, + }); } Ok(()) } @@ -120,7 +140,7 @@ impl ContactAvatarPlacement { } async fn clear_drawn(&self, state: &mut PlacementState) { - let Some((_, request)) = state.placed.take() else { + let Some(RememberedPlacement { request, .. }) = state.placed.take() else { return; }; let (surface_width, surface_height) = (request.surface_width, request.surface_height); @@ -142,27 +162,62 @@ impl ContactAvatarPlacement { /// `owner` disclosed, changed. async fn redraw(&self, owner: ProfileOwner) { let state = self.state.lock().await; - let Some((placed_for, request)) = state.placed.as_ref() else { + let Some(RememberedPlacement { + owner: placed_for, + request, + authority, + }) = state.placed.as_ref() + else { return; }; if *placed_for != owner { return; } - if let Err(error) = self.draw(owner, request).await { + if let Err(error) = self.draw(owner, request, authority.as_ref()).await { debug!(?error, "contact avatars were not redrawn"); } } + async fn contacts_changed(&self) { + let state = self.state.lock().await; + let Some(RememberedPlacement { + owner, + request, + authority, + }) = state.placed.as_ref() + else { + return; + }; + if request + .slots + .iter() + .any(|slot| matches!(slot.contact, ProfileContact::Handle { .. })) + { + let _ = self + .platform + .place_contact_avatars( + &self.product, + PlacedAvatars { + surface_width: request.surface_width, + surface_height: request.surface_height, + avatars: Vec::new(), + }, + ) + .await; + if let Err(error) = self.draw(*owner, request, authority.as_ref()).await { + debug!(?error, "contact avatars were not redrawn"); + } + } + } + async fn draw( &self, owner: ProfileOwner, - request: &v02::HostProfilePlaceContactAvatarsRequest, - ) -> Result<(), v01::HostProfilePlaceContactAvatarsError> { - let unknown = |reason| v01::HostProfilePlaceContactAvatarsError::Unknown { reason }; - let mut avatars = self - .drawable(owner, &request.slots) - .await - .map_err(unknown)?; + request: &HostProfilePlaceContactAvatarsRequest, + authority: Option<&Weak>, + ) -> Result<(), HostProfilePlaceContactAvatarsError> { + let unknown = |reason| HostProfilePlaceContactAvatarsError::Unknown { reason }; + let mut own_avatar = None; if let Some(own) = request.own { let disclosure = read_disclosure(self.storage.as_ref(), owner) .await @@ -170,7 +225,7 @@ impl ContactAvatarPlacement { if let Some(disclosure) = disclosure.filter(|disclosure| is_screened_profile_reference(&disclosure.reference)) { - avatars.push(PlacedAvatar { + own_avatar = Some(PlacedAvatar { slot: own.slot, rect: own.rect, clip: own.clip, @@ -181,6 +236,13 @@ impl ContactAvatarPlacement { }); } } + let mut avatars = self + .drawable(owner, &request.slots, authority) + .await + .map_err(unknown)?; + if let Some(own_avatar) = own_avatar { + avatars.push(own_avatar); + } let (surface_width, surface_height) = (request.surface_width, request.surface_height); let placed = PlacedAvatars { surface_width, @@ -193,8 +255,8 @@ impl ContactAvatarPlacement { .await { Ok(()) => Ok(()), - Err(v01::HostProfilePlaceContactAvatarsError::Unsupported) => { - Err(v01::HostProfilePlaceContactAvatarsError::Unsupported) + Err(HostProfilePlaceContactAvatarsError::Unsupported) => { + Err(HostProfilePlaceContactAvatarsError::Unsupported) } // Any other host failure could depend on which avatars it was // given, so the product is not told of it. @@ -210,7 +272,8 @@ impl ContactAvatarPlacement { async fn drawable( &self, owner: ProfileOwner, - slots: &[v01::ContactAvatarSlot], + slots: &[ContactAvatarSlot], + authority: Option<&Weak>, ) -> Result, String> { if slots.is_empty() { return Ok(Vec::new()); @@ -225,11 +288,68 @@ impl ContactAvatarPlacement { .then_some((received.peer_identity, (reference, received.timestamp))) }) .collect(); + let requested: Vec<[u8; 32]> = slots + .iter() + .filter_map(|slot| match slot.contact { + ProfileContact::Handle { handle } => Some(handle.bytes), + ProfileContact::Peer { .. } => None, + }) + .collect(); + let services = self.services.upgrade(); + let mut generation = None; + let resolved = if requested.is_empty() { + Vec::new() + } else if let (Some(services), Some(authority)) = + (services.as_ref(), authority.and_then(Weak::upgrade)) + { + generation = Some(services.contact_handles.generation()); + if let (Some(platform), Some(session)) = ( + services.contacts_platform(), + authority + .current_session() + .filter(|session| session.public_key == owner.root_public_key), + ) { + if let Ok(handle_key) = authority.contacts_handle_key(&session) { + let resolved = resolve_contact_accounts( + services, + platform.as_ref(), + &ContactHandles::from_handle_key(handle_key), + &requested, + ) + .await + .unwrap_or_default(); + if authority.current_session().as_ref() == Some(&session) { + resolved + } else { + Vec::new() + } + } else { + Vec::new() + } + } else { + Vec::new() + } + } else { + Vec::new() + }; + let handles_current = services + .as_ref() + .is_some_and(|services| generation == Some(services.contact_handles.generation())); Ok(slots .iter() .filter_map(|slot| { + let identity = match slot.contact { + ProfileContact::Peer { peer_identity } => peer_identity, + ProfileContact::Handle { handle } if handles_current => { + resolved + .iter() + .find(|(requested, _)| *requested == handle.bytes)? + .1? + } + ProfileContact::Handle { .. } => return None, + }; shared - .get(&slot.peer_identity) + .get(&identity) .map(|(reference, shared_at)| PlacedAvatar { slot: slot.slot, rect: slot.rect, @@ -317,4 +437,23 @@ impl ContactAvatarPlacements { } })); } + + /// Re-resolve handle placements after the host removes or blocks contacts. + pub fn contacts_changed(&self, spawner: &Spawner) { + let placements = self + .by_runtime + .lock() + .expect("contact avatar placements mutex poisoned") + .values() + .cloned() + .collect::>(); + if placements.is_empty() { + return; + } + spawner(Box::pin(async move { + for placement in placements { + placement.contacts_changed().await; + } + })); + } } diff --git a/rust/crates/truapi/src/runtime/services.rs b/rust/crates/truapi/src/runtime/services.rs index 0fcb2f50a..c8e77a7f8 100644 --- a/rust/crates/truapi/src/runtime/services.rs +++ b/rust/crates/truapi/src/runtime/services.rs @@ -10,7 +10,9 @@ use std::sync::{Arc, Mutex, OnceLock}; use crate::chain_runtime::{ChainRuntime, RuntimeChainProvider, RuntimeFailure}; use crate::host_logic::worker::WorkerLedger; -use crate::platform::{CoinageWalletHost, HostInfo, JsonRpcConnection, PermissionStatusHost, Platform}; +use crate::platform::{ + CoinageWalletHost, HostInfo, JsonRpcConnection, PermissionStatusHost, Platform, +}; use crate::runtime::bulletin_rpc::BulletinRpc; use crate::runtime::signing_host::DevicePairingObserver; use crate::runtime::statement_store_rpc::StatementStoreRpc; @@ -50,6 +52,8 @@ pub struct RuntimeServices { /// Where each live product connection draws contact avatars, so they can /// be redrawn when what a contact shared changes. pub(crate) contact_avatars: crate::runtime::profile::avatars::ContactAvatarPlacements, + /// Serializes profile audience updates, relay publication and received grants. + pub(crate) profile_state_gate: futures::lock::Mutex<()>, /// Optional native authenticated username index; only supplies candidates. identity_backend: OnceLock>, pocket_platform: OnceLock>, @@ -149,6 +153,7 @@ impl RuntimeServices { pocket_platform: OnceLock::new(), profile_platform: OnceLock::new(), contact_avatars: Default::default(), + profile_state_gate: Default::default(), identity_backend: OnceLock::new(), contacts_platform: OnceLock::new(), contact_handles: Default::default(), diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 35b53cf79..799ebd795 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -2480,6 +2480,9 @@ fn own_profile_status_and_presentation_resolve_the_host_owned_disclosure() { product_id: "seity.dot".to_string(), reference: CONTACTS_REFERENCE.to_string(), revision: 1, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), }, )) .expect("own disclosure stored"); @@ -2586,8 +2589,8 @@ fn profile_disclose_stores_the_reference_and_only_its_discloser_may_retract_it() #[test] fn a_disclosure_stored_before_revisions_still_reads() { - use parity_scale_codec::Encode; use crate::platform::CoreStorage; + use parity_scale_codec::Encode; let platform = consenting_platform(); let seity = app_host(&platform, "seity.dot"); let owner = owner_of(&seity); @@ -2605,10 +2608,299 @@ fn a_disclosure_stored_before_revisions_still_reads() { product_id: "seity.dot".into(), reference: CONTACTS_REFERENCE.into(), revision: 0, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), } ); } +fn picked_handle(host: &ProductRuntimeHost) -> truapi::latest::ContactHandle { + let HostContactsPickResponse::V1(response) = pick(host).expect("picker succeeds"); + let v01::ContactPickOutcome::Picked { handle } = response.outcome else { + panic!("fixture picks a contact"); + }; + handle +} + +fn disclose_audiences( + host: &ProductRuntimeHost, + audiences: Vec, +) -> Result> { + futures::executor::block_on(Profile::disclose( + host, + &CallContext::default(), + HostProfileDiscloseRequest::V2(truapi::latest::HostProfileDiscloseRequest { + reference: CONTACTS_REFERENCE.to_string(), + audiences, + }), + )) +} + +#[test] +fn profile_audiences_are_independent_and_invalid_handles_leave_the_disclosure_unchanged() { + use truapi::latest::{ContactHandle, ProfileAudience}; + let platform = consenting_platform(); + let account = [0xa1; 32]; + let host = contacts_host( + "seity.dot", + platform.clone(), + Some(StubContactsPlatform::picking(account)), + true, + ); + let handle = picked_handle(&host); + let owner = owner_of(&host); + let read = || { + futures::executor::block_on(profile::read_disclosure(platform.as_ref(), owner)) + .unwrap() + .unwrap() + }; + disclose_audiences( + &host, + vec![ + ProfileAudience::App { + product_id: "egui-chat.dot".into(), + }, + ProfileAudience::Contacts { + handles: vec![handle, handle], + }, + ProfileAudience::App { + product_id: "egui-chat.dot".into(), + }, + ], + ) + .expect("independent audiences accepted"); + let original = read(); + assert_eq!( + ( + original.all_chat_apps, + &original.app_products, + &original.contacts + ), + (false, &vec!["egui-chat.dot".to_string()], &vec![account]), + ); + for audiences in [ + vec![ProfileAudience::Contacts { + handles: vec![handle, ContactHandle { bytes: [0xee; 32] }], + }], + vec![ProfileAudience::App { + product_id: "invalid product".into(), + }], + vec![ProfileAudience::Contacts { + handles: vec![handle; 4097], + }], + vec![ProfileAudience::ChatApps; 65], + ] { + assert!(matches!( + disclose_audiences(&host, audiences), + Err(CallError::Domain(HostProfileDiscloseError::V2( + v01::HostProfileDiscloseError::Unknown { .. } + ))) + )); + assert_eq!( + read(), + original, + "a rejected audience cannot partially change grants" + ); + } + disclose_audiences(&host, Vec::new()).expect("retaining own profile without grants"); + let retained = read(); + assert_eq!( + ( + retained.reference.as_str(), + retained.all_chat_apps, + retained.app_products, + retained.contacts + ), + (CONTACTS_REFERENCE, false, Vec::new(), Vec::new()), + ); + assert_eq!( + own_profile_status(&host).unwrap(), + HostProfileOwnStatusResponse::V1(v01::HostProfileOwnStatusResponse { configured: true }) + ); + disclose(&host, CONTACTS_REFERENCE).unwrap(); + let legacy = read(); + assert_eq!( + (legacy.all_chat_apps, legacy.app_products, legacy.contacts), + (true, Vec::new(), Vec::new()) + ); +} + +fn present_selected_contact( + host: &ProductRuntimeHost, + contact: truapi::latest::ProfileContact, +) -> Result> { + futures::executor::block_on(Profile::present_contact( + host, + &CallContext::default(), + HostProfilePresentContactRequest::V2(truapi::latest::HostProfilePresentContactRequest { + contact, + }), + )) +} + +#[test] +fn profile_handle_presentation_hides_absence_and_removed_contacts_in_an_unrelated_app() { + use truapi::latest::{ContactHandle, ProfileContact}; + let platform = stub_platform(); + let account = [0xa1; 32]; + let contacts = StubContactsPlatform::picking(account); + let presented = Arc::new(RecordingProfilePlatform::default()); + let mut host = contacts_host("notes.dot", platform.clone(), Some(contacts.clone()), true); + host.profile_platform = Some(presented.clone()); + let handle = picked_handle(&host); + let selected = ProfileContact::Handle { handle }; + let success = Ok(HostProfilePresentContactResponse::V2); + assert_eq!(present_selected_contact(&host, selected), success); + let not_shared = Err(CallError::Domain(HostProfilePresentContactError::V1( + v01::HostProfilePresentContactError::NotShared, + ))); + assert_eq!(present_contact(&host, account), not_shared); + futures::executor::block_on(profile::record_personal_received_reference( + platform.as_ref(), + owner_of(&host), + account, + "seity.dot".into(), + 1, + 1, + Some(CONTACTS_REFERENCE.into()), + )) + .unwrap(); + assert_eq!( + present_contact(&host, account), + not_shared, + "legacy raw-peer requests must not reveal that a personal profile became available" + ); + assert_eq!(present_selected_contact(&host, selected), success); + assert_eq!( + present_selected_contact( + &host, + ProfileContact::Handle { + handle: ContactHandle { bytes: [0xee; 32] }, + } + ), + success + ); + contacts + .listed + .lock() + .expect("listed mutex poisoned") + .clear(); + host.services.contact_handles.clear(); + assert_eq!(present_selected_contact(&host, selected), success); + assert_eq!( + presented + .presented + .lock() + .expect("presented mutex poisoned") + .as_slice(), + [("notes.dot".to_string(), CONTACTS_REFERENCE.to_string())], + "only a current verified contact reaches host UI, never the reference or availability response", + ); +} + +#[test] +fn profile_v2_presentation_does_not_expose_host_parse_failures() { + struct RejectingProfile; + #[truapi::async_trait] + impl crate::platform::ProfilePlatform for RejectingProfile { + async fn present_profile( + &self, + _product: &ProductContext, + _request: truapi::latest::HostProfilePresentRequest, + ) -> Result<(), truapi::latest::HostProfilePresentError> { + Err(v01::HostProfilePresentError::InvalidReference) + } + } + let platform = stub_platform(); + let host = signed_in( + profile_host_on( + platform.clone(), + egui_chat(), + Some(Arc::new(RejectingProfile)), + ), + WALLET, + ); + let account = [0xa1; 32]; + futures::executor::block_on(profile::record_received_reference( + platform.as_ref(), + owner_of(&host), + "egui-chat.dot", + account, + "seity.dot".into(), + 1, + Some(CONTACTS_REFERENCE.into()), + )) + .unwrap(); + assert_eq!( + present_selected_contact( + &host, + truapi::latest::ProfileContact::Peer { + peer_identity: account + } + ), + Ok(HostProfilePresentContactResponse::V2), + ); + assert_eq!( + present_contact(&host, account), + Err(CallError::Domain(HostProfilePresentContactError::V1( + v01::HostProfilePresentContactError::InvalidReference, + ))), + ); +} + +#[test] +fn a_contact_removed_during_lookup_never_becomes_a_transaction_recipient_or_profile_grant() { + struct RemovingContacts { + services: std::sync::Weak, + account: [u8; 32], + } + #[truapi::async_trait] + impl crate::platform::ContactsPlatform for RemovingContacts { + async fn contacts( + &self, + lookup: &crate::platform::HostContactLookup, + ) -> Result { + self.services.upgrade().unwrap().contact_handles.clear(); + Ok(crate::platform::HostContactMatches { + accounts: vec![Some(self.account); lookup.handles.len()], + }) + } + } + let platform = consenting_platform(); + let host = contacts_host("seity.dot", platform.clone(), None, true); + let account = [0xa1; 32]; + host.services + .install_contacts_platform(Arc::new(RemovingContacts { + services: Arc::downgrade(&host.services), + account, + })); + let (_, handles) = host.contacts_picker().unwrap(); + let handle = truapi::latest::ContactHandle { + bytes: handles.mint(&account), + }; + assert_eq!( + futures::executor::block_on( + host.substitute_declared_contacts(handle.bytes.to_vec(), &[handle]) + ), + Err(ContactResolutionError::UnknownContact), + ); + assert!( + disclose_audiences( + &host, + vec![truapi::latest::ProfileAudience::Contacts { + handles: vec![handle] + }] + ) + .is_err() + ); + assert_eq!( + futures::executor::block_on(profile::read_disclosure(platform.as_ref(), owner_of(&host))) + .unwrap(), + None, + ); +} + #[test] fn profile_disclose_asks_once_per_product_and_a_refusal_stores_nothing() { let platform = Arc::new(StubPlatform::default()); @@ -3060,6 +3352,131 @@ fn place_profile_avatars( )) } +#[test] +fn handle_avatars_render_personal_profiles_without_reviving_removed_contacts_on_redraw() { + use truapi::latest::{ContactAvatarSlot, ContactHandle, ProfileContact}; + let platform = stub_platform(); + let account = [0xa1; 32]; + let contacts = StubContactsPlatform::picking(account); + let avatars = Arc::new(RecordingAvatarHost::default()); + let mut host = contacts_host("notes.dot", platform.clone(), Some(contacts.clone()), true); + host.profile_platform = Some(avatars.clone()); + let handle = picked_handle(&host); + let owner = owner_of(&host); + futures::executor::block_on(profile::record_personal_received_reference( + platform.as_ref(), + owner, + account, + "seity.dot".into(), + 1, + 1, + Some(CONTACTS_REFERENCE.into()), + )) + .unwrap(); + let request = truapi::latest::HostProfilePlaceContactAvatarsRequest { + surface_width: 360, + surface_height: 640, + own: None, + slots: [handle, ContactHandle { bytes: [0xee; 32] }] + .into_iter() + .enumerate() + .map(|(index, handle)| ContactAvatarSlot { + slot: index as u32, + contact: ProfileContact::Handle { handle }, + rect: avatar_rect(16, 80 + 56 * index as i32, 44), + clip: AVATAR_CLIP, + }) + .collect(), + }; + assert_eq!( + futures::executor::block_on(Profile::place_contact_avatars( + &host, + &CallContext::default(), + HostProfilePlaceContactAvatarsRequest::V3(request), + )), + Ok(HostProfilePlaceContactAvatarsResponse::V3), + ); + assert_eq!( + avatars.placements(), + vec![( + "notes.dot".to_string(), + placed_avatars(vec![placed_avatar(0, CONTACTS_REFERENCE)]) + )], + ); + contacts + .listed + .lock() + .expect("listed mutex poisoned") + .clear(); + host.services.contact_handles.clear(); + host.services + .contact_avatars + .contacts_changed(&host.services.spawner); + let empty = ("notes.dot".to_string(), placed_avatars(Vec::new())); + assert_eq!(avatars.wait_for(3)[1..], [empty.clone(), empty.clone()]); + host.services + .contact_avatars + .redraw_owner(owner, &host.services.spawner); + assert_eq!( + avatars.wait_for(4)[3], + empty, + "a later profile update cannot reuse the removed handle's account" + ); +} + +#[test] +fn handle_avatar_redraw_does_not_resolve_under_a_signed_out_wallet() { + let platform = stub_platform(); + let account = [0xa1; 32]; + let contacts = StubContactsPlatform::picking(account); + let avatars = Arc::new(RecordingAvatarHost::default()); + let mut host = contacts_host("notes.dot", platform.clone(), Some(contacts), true); + host.profile_platform = Some(avatars.clone()); + let handle = picked_handle(&host); + let owner = owner_of(&host); + futures::executor::block_on(profile::record_personal_received_reference( + platform.as_ref(), + owner, + account, + "seity.dot".into(), + 1, + 1, + Some(CONTACTS_REFERENCE.into()), + )) + .unwrap(); + let request = truapi::latest::HostProfilePlaceContactAvatarsRequest { + surface_width: 360, + surface_height: 640, + own: None, + slots: vec![truapi::latest::ContactAvatarSlot { + slot: 0, + contact: truapi::latest::ProfileContact::Handle { handle }, + rect: avatar_rect(16, 80, 44), + clip: AVATAR_CLIP, + }], + }; + futures::executor::block_on(Profile::place_contact_avatars( + &host, + &CallContext::default(), + HostProfilePlaceContactAvatarsRequest::V3(request), + )) + .unwrap(); + host.test_session_state().clear_session(); + host.services + .contact_avatars + .redraw_owner(owner, &host.services.spawner); + assert_eq!( + avatars.wait_for(2), + vec![ + ( + "notes.dot".to_string(), + placed_avatars(vec![placed_avatar(0, CONTACTS_REFERENCE)]) + ), + ("notes.dot".to_string(), placed_avatars(Vec::new())), + ] + ); +} + #[test] fn own_avatar_placement_draws_the_disclosed_profile_without_returning_its_reference() { let platform = stub_platform(); @@ -3073,6 +3490,9 @@ fn own_avatar_placement_draws_the_disclosed_profile_without_returning_its_refere product_id: "seity.dot".to_string(), reference: CONTACTS_REFERENCE.to_string(), revision: 7, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), }, )) .expect("own disclosure stored"); @@ -3120,8 +3540,12 @@ fn own_and_contact_slots_share_one_slot_namespace() { clip: AVATAR_CLIP, }); assert!(matches!( - place_profile_avatars(&chat, request), - Err(CallError::Domain(HostProfilePlaceContactAvatarsError::V2( + futures::executor::block_on(Profile::place_contact_avatars( + &chat, + &CallContext::default(), + HostProfilePlaceContactAvatarsRequest::V3(request), + )), + Err(CallError::Domain(HostProfilePlaceContactAvatarsError::V3( v01::HostProfilePlaceContactAvatarsError::Unknown { .. } ))) )); diff --git a/rust/crates/truapi/src/v02/profile.rs b/rust/crates/truapi/src/v02/profile.rs index 87aac2171..5dfcf72c3 100644 --- a/rust/crates/truapi/src/v02/profile.rs +++ b/rust/crates/truapi/src/v02/profile.rs @@ -1,7 +1,8 @@ -use alloc::vec::Vec; +use alloc::{string::String, vec::Vec}; +use core::fmt; use parity_scale_codec::{Decode, Encode}; -use crate::v01::{AvatarRect, ContactAvatarSlot}; +use crate::v01::{AvatarRect, ContactAvatarSlot, ContactHandle}; /// Where a chat product draws avatars the host fills in: its contacts' and, /// optionally, the signed-in user's own. @@ -36,3 +37,67 @@ pub struct OwnAvatarSlot { /// Visible region the avatar is cut to. pub clip: AvatarRect, } + +/// Recipients of one profile reference. Multiple audiences form a union. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum ProfileAudience { + /// Every ready Chat App, with profiles scoped to that App. + ChatApps, + /// Contacts of this Chat App, with profiles scoped to that App. + App { + /// Canonical product identifier. + product_id: String, + }, + /// Selected contacts, with profiles available in any receiving App. + Contacts { + /// Opaque handles returned by the host's contact picker, at most 4096 + /// across the request. + handles: Vec, + }, +} + +/// Replace the user's disclosed reference and its complete set of audiences. +/// +/// An empty audience retains the user's own profile but withdraws all delivery +/// grants. A contact handle that no longer resolves rejects the whole request. +#[derive(Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostProfileDiscloseRequest { + /// Opaque bearer reference, retained and relayed only by the host. + pub reference: String, + /// Independent grants for this reference, at most 64. + pub audiences: Vec, +} + +impl fmt::Debug for HostProfileDiscloseRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HostProfileDiscloseRequest") + .field("reference", &"[REDACTED]") + .field("audiences", &"[REDACTED]") + .finish() + } +} + +/// A contact named without exposing a handle's account to the product. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum ProfileContact { + /// An authenticated Chat network identity already known to the product. + Peer { + /// The contact's identity account, not a product device account. + peer_identity: [u8; 32], + }, + /// An opaque host-issued contact selection. + Handle { + /// A handle returned by the contact picker. + handle: ContactHandle, + }, +} + +/// Ask the host to present a contact's available profile. +/// +/// Unknown handles, absent profiles and presentation failures return success, +/// without disclosing whether the contact shares a profile. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostProfilePresentContactRequest { + /// The contact whose profile the host may present. + pub contact: ProfileContact, +} diff --git a/rust/crates/truapi/src/v03.rs b/rust/crates/truapi/src/v03.rs index 21af6a709..e89a03793 100644 --- a/rust/crates/truapi/src/v03.rs +++ b/rust/crates/truapi/src/v03.rs @@ -4,5 +4,7 @@ //! Unchanged public metadata and errors retain their [`crate::v02`] types. mod account; +mod profile; pub use account::*; +pub use profile::*; diff --git a/rust/crates/truapi/src/v03/profile.rs b/rust/crates/truapi/src/v03/profile.rs new file mode 100644 index 000000000..7810fd62d --- /dev/null +++ b/rust/crates/truapi/src/v03/profile.rs @@ -0,0 +1,31 @@ +use alloc::vec::Vec; +use parity_scale_codec::{Decode, Encode}; + +use crate::v01::AvatarRect; +use crate::v02::{OwnAvatarSlot, ProfileContact}; + +/// A host-rendered avatar placement using peer identities or opaque handles. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostProfilePlaceContactAvatarsRequest { + /// Width of the drawing surface, from 1 to 16384 units. + pub surface_width: u32, + /// Height of the drawing surface, from 1 to 16384 units. + pub surface_height: u32, + /// Optional slot for the signed-in user's own disclosed profile. + pub own: Option, + /// Complete replacement of the contact slots, at most 64. + pub slots: Vec, +} + +/// Geometry and opaque contact selection for one host-rendered avatar. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ContactAvatarSlot { + /// Product-chosen id, unique across contact and own slots. + pub slot: u32, + /// A peer identity or host-issued handle. Unresolved handles remain blank. + pub contact: ProfileContact, + /// Square avatar bounds, from 1 to 1024 units per side. + pub rect: AvatarRect, + /// Visible region to which the avatar is clipped. + pub clip: AvatarRect, +} diff --git a/rust/crates/truapi/src/versioned/profile.rs b/rust/crates/truapi/src/versioned/profile.rs index 8583d6b04..4e67f5641 100644 --- a/rust/crates/truapi/src/versioned/profile.rs +++ b/rust/crates/truapi/src/versioned/profile.rs @@ -1,33 +1,45 @@ //! Versioned wrappers for [`Profile`](crate::api::Profile) methods. -//! -//! v0.2 of `place_contact_avatars` adds an optional slot for the signed-in -//! user's own avatar. A v0.1 placement upgrades to one with no own slot, which -//! is exactly what v0.1 meant; the response and error keep their v0.1 shape. +//! Each method keeps its original payload decodable while upgrading into the +//! current audience and contact-selector model. use crate::versioned::{FromLatest, IntoLatest}; -use crate::{v01, v02}; +use crate::{v01, v02, v03}; truapi_macros::versioned_type! { pub enum HostProfilePresentRequest { V1 => v01::HostProfilePresentRequest } pub enum HostProfilePresentResponse { V1 } pub enum HostProfilePresentError { V1 => v01::HostProfilePresentError } - pub enum HostProfileDiscloseRequest { V1 => v01::HostProfileDiscloseRequest } - pub enum HostProfileDiscloseResponse { V1 } - pub enum HostProfileDiscloseError { V1 => v01::HostProfileDiscloseError } + pub enum HostProfileDiscloseRequest { + V1 => v01::HostProfileDiscloseRequest, + V2 => v02::HostProfileDiscloseRequest, + } + pub enum HostProfileDiscloseResponse { V1, V2 } + pub enum HostProfileDiscloseError { + V1 => v01::HostProfileDiscloseError, + V2 => v01::HostProfileDiscloseError, + } pub enum HostProfileRetractRequest { V1 } pub enum HostProfileRetractResponse { V1 } pub enum HostProfileRetractError { V1 => v01::HostProfileRetractError } - pub enum HostProfilePresentContactRequest { V1 => v01::HostProfilePresentContactRequest } - pub enum HostProfilePresentContactResponse { V1 } - pub enum HostProfilePresentContactError { V1 => v01::HostProfilePresentContactError } + pub enum HostProfilePresentContactRequest { + V1 => v01::HostProfilePresentContactRequest, + V2 => v02::HostProfilePresentContactRequest, + } + pub enum HostProfilePresentContactResponse { V1, V2 } + pub enum HostProfilePresentContactError { + V1 => v01::HostProfilePresentContactError, + V2 => v01::HostProfilePresentContactError, + } pub enum HostProfilePlaceContactAvatarsRequest { V1 => v01::HostProfilePlaceContactAvatarsRequest, V2 => v02::HostProfilePlaceContactAvatarsRequest, + V3 => v03::HostProfilePlaceContactAvatarsRequest, } - pub enum HostProfilePlaceContactAvatarsResponse { V1, V2 } + pub enum HostProfilePlaceContactAvatarsResponse { V1, V2, V3 } pub enum HostProfilePlaceContactAvatarsError { V1 => v01::HostProfilePlaceContactAvatarsError, V2 => v01::HostProfilePlaceContactAvatarsError, + V3 => v01::HostProfilePlaceContactAvatarsError, } pub enum HostProfileOwnStatusRequest { V1 } pub enum HostProfileOwnStatusResponse { V1 => v01::HostProfileOwnStatusResponse } @@ -37,6 +49,87 @@ truapi_macros::versioned_type! { pub enum HostProfilePresentOwnError { V1 => v01::HostProfilePresentOwnError } } +impl IntoLatest for HostProfileDiscloseRequest { + fn into_latest(self) -> Self::Latest { + match self { + Self::V1(request) => v02::HostProfileDiscloseRequest { + reference: request.reference, + audiences: alloc::vec![v02::ProfileAudience::ChatApps], + }, + Self::V2(request) => request, + } + } +} + +impl IntoLatest for HostProfilePresentContactRequest { + fn into_latest(self) -> Self::Latest { + match self { + Self::V1(request) => v02::HostProfilePresentContactRequest { + contact: v02::ProfileContact::Peer { + peer_identity: request.peer_identity, + }, + }, + Self::V2(request) => request, + } + } +} + +impl IntoLatest for HostProfileDiscloseResponse { + fn into_latest(self) -> Self::Latest {} +} + +impl FromLatest for HostProfileDiscloseResponse { + fn from_latest((): Self::Latest, target: u8) -> Self { + if target >= 2 { Self::V2 } else { Self::V1 } + } +} + +impl IntoLatest for HostProfileDiscloseError { + fn into_latest(self) -> Self::Latest { + match self { + Self::V1(error) | Self::V2(error) => error, + } + } +} + +impl FromLatest for HostProfileDiscloseError { + fn from_latest(error: Self::Latest, target: u8) -> Self { + if target >= 2 { + Self::V2(error) + } else { + Self::V1(error) + } + } +} + +impl IntoLatest for HostProfilePresentContactResponse { + fn into_latest(self) -> Self::Latest {} +} + +impl FromLatest for HostProfilePresentContactResponse { + fn from_latest((): Self::Latest, target: u8) -> Self { + if target >= 2 { Self::V2 } else { Self::V1 } + } +} + +impl IntoLatest for HostProfilePresentContactError { + fn into_latest(self) -> Self::Latest { + match self { + Self::V1(error) | Self::V2(error) => error, + } + } +} + +impl FromLatest for HostProfilePresentContactError { + fn from_latest(error: Self::Latest, target: u8) -> Self { + if target >= 2 { + Self::V2(error) + } else { + Self::V1(error) + } + } +} + impl IntoLatest for HostProfilePlaceContactAvatarsRequest { fn into_latest(self) -> Self::Latest { match self { @@ -44,21 +137,33 @@ impl IntoLatest for HostProfilePlaceContactAvatarsRequest { surface_width, surface_height, slots, - }) => v02::HostProfilePlaceContactAvatarsRequest { + }) => v03::HostProfilePlaceContactAvatarsRequest { surface_width, surface_height, own: None, - slots, + slots: slots.into_iter().map(upgrade_slot).collect(), }, - Self::V2(latest) => latest, + Self::V2(request) => v03::HostProfilePlaceContactAvatarsRequest { + surface_width: request.surface_width, + surface_height: request.surface_height, + own: request.own, + slots: request.slots.into_iter().map(upgrade_slot).collect(), + }, + Self::V3(latest) => latest, } } } -// The response and error did not change shape in v0.2. They still gain a V2 -// variant, because a method's version is uniform across its request, response -// and error — without one the generated client would keep every placement -// pinned to V1 and no product could reach the own slot. +fn upgrade_slot(slot: v01::ContactAvatarSlot) -> v03::ContactAvatarSlot { + v03::ContactAvatarSlot { + slot: slot.slot, + contact: v02::ProfileContact::Peer { + peer_identity: slot.peer_identity, + }, + rect: slot.rect, + clip: slot.clip, + } +} impl IntoLatest for HostProfilePlaceContactAvatarsResponse { fn into_latest(self) -> Self::Latest {} @@ -66,24 +171,129 @@ impl IntoLatest for HostProfilePlaceContactAvatarsResponse { impl FromLatest for HostProfilePlaceContactAvatarsResponse { fn from_latest((): Self::Latest, target: u8) -> Self { - if target >= 2 { Self::V2 } else { Self::V1 } + if target >= 3 { + Self::V3 + } else if target == 2 { + Self::V2 + } else { + Self::V1 + } } } impl IntoLatest for HostProfilePlaceContactAvatarsError { fn into_latest(self) -> Self::Latest { match self { - Self::V1(error) | Self::V2(error) => error, + Self::V1(error) | Self::V2(error) | Self::V3(error) => error, } } } impl FromLatest for HostProfilePlaceContactAvatarsError { fn from_latest(latest: Self::Latest, target: u8) -> Self { - if target >= 2 { + if target >= 3 { + Self::V3(latest) + } else if target == 2 { Self::V2(latest) } else { Self::V1(latest) } } } + +#[cfg(test)] +mod tests { + use super::*; + use parity_scale_codec::{DecodeAll, Encode}; + + #[test] + fn original_disclose_bytes_keep_the_all_chat_audience() { + let request = HostProfileDiscloseRequest::decode_all(&mut &[0, 4, b'x'][..]).unwrap(); + assert_eq!( + request.into_latest(), + v02::HostProfileDiscloseRequest { + reference: "x".into(), + audiences: alloc::vec![v02::ProfileAudience::ChatApps], + }, + ); + assert_eq!( + HostProfileDiscloseRequest::V2(v02::HostProfileDiscloseRequest { + reference: "x".into(), + audiences: alloc::vec![], + }) + .encode(), + alloc::vec![1, 4, b'x', 0], + "an explicitly empty audience must not acquire the legacy grant", + ); + } + + #[test] + fn original_contact_bytes_remain_a_peer_not_a_handle() { + let bytes = (0u8, [7u8; 32]).encode(); + let request = HostProfilePresentContactRequest::decode_all(&mut &bytes[..]).unwrap(); + assert_eq!( + request.into_latest().contact, + v02::ProfileContact::Peer { + peer_identity: [7; 32] + }, + ); + let bytes = (1u8, 1u8, [7u8; 32]).encode(); + let request = HostProfilePresentContactRequest::decode_all(&mut &bytes[..]).unwrap(); + assert_eq!( + request.into_latest().contact, + v02::ProfileContact::Handle { + handle: v01::ContactHandle { bytes: [7; 32] } + }, + ); + } + + #[test] + fn old_avatar_layouts_preserve_geometry_and_the_optional_own_slot() { + let rect = v01::AvatarRect { + x: -1, + y: 20, + width: 44, + height: 44, + }; + let slot = v01::ContactAvatarSlot { + slot: 2, + peer_identity: [7; 32], + rect, + clip: rect, + }; + let own = v02::OwnAvatarSlot { + slot: 1, + rect, + clip: rect, + }; + for (bytes, expected_own) in [ + ( + (0u8, 360u32, 640u32, alloc::vec![slot.clone()]).encode(), + None, + ), + ( + (1u8, 360u32, 640u32, Some(own), alloc::vec![slot]).encode(), + Some(own), + ), + ] { + let request = + HostProfilePlaceContactAvatarsRequest::decode_all(&mut &bytes[..]).unwrap(); + assert_eq!( + request.into_latest(), + v03::HostProfilePlaceContactAvatarsRequest { + surface_width: 360, + surface_height: 640, + own: expected_own, + slots: alloc::vec![v03::ContactAvatarSlot { + slot: 2, + contact: v02::ProfileContact::Peer { + peer_identity: [7; 32] + }, + rect, + clip: rect, + }], + } + ); + } + } +} From 2a6fd0add56e587acbe2b812016e9d55624439f5 Mon Sep 17 00:00:00 2001 From: w Date: Tue, 29 Sep 2026 20:28:42 -0400 Subject: [PATCH 23/30] docs: clarify Profile audience consent scope --- rust/crates/truapi/src/platform.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index 0f4df2dc1..08a860826 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -3686,9 +3686,10 @@ pub struct ChatAuthorityReview { pub product_id: String, } -/// Review shown before a product first discloses a profile reference to the -/// user's Chat contacts. The host relays it to every contact, so the prompt -/// names the product, never the contacts or the reference. +/// Review shown before a product discloses a profile reference to an app +/// audience or selected contacts. Personal grants permit host rendering across +/// recipient apps. This authorizes the product, not individual audience edits. +/// The prompt names the product, never the contacts or the reference. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] #[cfg_attr( all(feature = "runtime", not(target_arch = "wasm32")), From 1cbc95d2399a43b632ee7ab34c067bb4dce06a32 Mon Sep 17 00:00:00 2001 From: w Date: Fri, 2 Oct 2026 01:08:49 -0400 Subject: [PATCH 24/30] feat(contacts): support opaque audience selection and host labels --- README.md | 8 + .../kotlin/io/parity/truapi/TrUAPIHost.kt | 28 ++ docs/rfcs/contacts-api.md | 47 +++- .../Sources/TrUAPIHost/TrUAPIHost.swift | 36 +++ js/packages/truapi-host/README.md | 31 ++- .../truapi-host/src/adapter-support.ts | 16 ++ rust/crates/truapi-client/src/generated.rs | 62 ++++- .../truapi-codegen/src/ts/host_callbacks.rs | 4 + rust/crates/truapi/RUNTIME.md | 19 +- rust/crates/truapi/src/api/contacts.rs | 54 +++- rust/crates/truapi/src/host_core.rs | 7 +- rust/crates/truapi/src/lib.rs | 18 ++ rust/crates/truapi/src/native/callbacks.rs | 14 + rust/crates/truapi/src/native/errors.rs | 6 + rust/crates/truapi/src/native/platform.rs | 17 ++ rust/crates/truapi/src/platform.rs | 83 +++++- rust/crates/truapi/src/runtime.rs | 178 ++++++++++++- rust/crates/truapi/src/runtime/contacts.rs | 142 ++++++++-- rust/crates/truapi/src/runtime/native_chat.rs | 2 +- .../crates/truapi/src/runtime/pairing_host.rs | 2 + rust/crates/truapi/src/runtime/services.rs | 14 + .../crates/truapi/src/runtime/signing_host.rs | 4 +- rust/crates/truapi/src/runtime/tests.rs | 252 +++++++++++++++++- rust/crates/truapi/src/v01/contacts.rs | 96 +++++++ rust/crates/truapi/src/versioned/contacts.rs | 6 + 25 files changed, 1080 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 02aa43168..06e1d735c 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,14 @@ authenticated, ready peers from authorized native Chat products, scopes them to and never exposes a product or SSO directory method. Actors opened on this version are durably indexed; historical unindexed Chat products must be opened once before their peers can appear. Pairing hosts do not supply this directory. +Contacts trait 20 retains the single picker at method 0, adds `pickMany({ selected })` at method 1, and +`placeLabels({ surfaceWidth, surfaceHeight, slots })` at method 2. Multi-select confirmation returns only +wallet-scoped handles, including a confirmed empty selection; dismissal never edits the audience. Host-owned +labels show directory usernames or account fallbacks independently of Profile photos, without returning names, +accounts or per-slot availability. Selections and placements are bounded to 256 entries; unresolved initial +selections fail closed. Hosts implement `pickContacts(product, ContactSelection)` and +`placeContactLabels(product, PlacedContactLabels)` through the canonical native/WASM/worker callbacks. + The [native Chat/main-purse RFC](docs/rfcs/native-chat-main-purse.md) specifies the method 12 request/response and compatibility contract, device eligibility, custody-before-ACK rule, and delivery versus clearing semantics. It is a draft for review in #709, not an approved standard or a release claim. It builds on diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt index 6047ed94c..f2bc3a749 100644 --- a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -94,6 +94,10 @@ import uniffi.truapi.ProductExecutionConfig import uniffi.truapi.HostContactLookup import uniffi.truapi.HostContactMatches import uniffi.truapi.HostContactPick +import uniffi.truapi.ContactSelection +import uniffi.truapi.HostContactsPick +import uniffi.truapi.PlacedContactLabels +import uniffi.truapi.HostContactsPlaceLabelsException import uniffi.truapi.NativeContactsCallbacks import uniffi.truapi.SsoRequestOutcome @@ -762,6 +766,17 @@ interface ContactsHostBridge { */ @Throws(HostRejection::class) suspend fun pickContact(productId: String): HostContactPick + + /** Edit the complete audience; cancelling does not confirm an empty one. */ + @Throws(HostRejection::class) + suspend fun pickContacts(productId: String, selection: ContactSelection): HostContactsPick = + HostContactsPick.Unsupported + + /** Replace names on the host surface without exposing them to products. */ + @Throws(HostContactsPlaceLabelsException::class) + suspend fun placeContactLabels(productId: String, placed: PlacedContactLabels) { + throw HostContactsPlaceLabelsException.Unsupported() + } } private class ContactsCallbackAdapter(private val bridge: ContactsHostBridge) : NativeContactsCallbacks { @@ -776,6 +791,19 @@ private class ContactsCallbackAdapter(private val bridge: ContactsHostBridge) : } catch (error: Throwable) { throw HostRejection.Rejected(hostRejectionReason(error)) } + + override suspend fun pickContacts(productId: String, selection: ContactSelection): HostContactsPick = + withHostRejection { bridge.pickContacts(productId, selection) } + + override suspend fun placeContactLabels(productId: String, placed: PlacedContactLabels) { + try { + bridge.placeContactLabels(productId, placed) + } catch (error: HostContactsPlaceLabelsException) { + throw error + } catch (error: Throwable) { + throw HostContactsPlaceLabelsException.Unknown("contact label callback failed") + } + } } private class PocketCallbackAdapter(private val bridge: PocketHostBridge) : NativePocketCallbacks { diff --git a/docs/rfcs/contacts-api.md b/docs/rfcs/contacts-api.md index 81364c609..36fac6f24 100644 --- a/docs/rfcs/contacts-api.md +++ b/docs/rfcs/contacts-api.md @@ -11,10 +11,11 @@ status: draft _How the implemented pieces fit together is in [Contacts Pick, End to End](../design/contacts-pick-end-to-end.md)._ -A product asks the Host to let the user pick a contact. The Host renders an overlay from its Chat -workers' chat lists, the user selects one person, and the product receives one opaque handle — never -the list, a name, or an account. The handle is not an address: the core resolves it when building a -transaction. +A product asks the Host to let the user pick one or more contacts. The Host renders the +picker from its contact directory and returns opaque handles, never the list, names or +accounts. The handle is not an address: the core resolves it when building a transaction. +Host-owned name labels let users recognize selected handles without sharing the names +or requiring a Profile photo. ## Motivation @@ -49,12 +50,34 @@ names it as the recipient and the core substitutes the account when it builds th product-scoped address is not derivable at all, which is why the handle is resolvable rather than directly usable. +### Multi-select audiences + +Trait 20 method 0 remains `pick`. Method 1, `pickMany({ selected })`, edits a complete +selection of at most 256 handles. The core deduplicates and resolves the initial +selection before opening the picker; any unresolved handle rejects the whole request. +The host callback `pickContacts(product, ContactSelection { selected })` receives +accounts only inside the trusted host boundary. Confirming an empty selection returns +`Picked { handles: [] }`; closing the picker returns `Dismissed`. Session or directory +invalidation during resolution or confirmation cancels the change. + +### Host-owned contact labels + +Method 2, `placeLabels({ surfaceWidth, surfaceHeight, slots })`, replaces at most 256 +name rectangles. Each slot supplies `{ slot, handle, rect, clip }`, reusing `AvatarRect`. +The host resolves handles and draws directory usernames, or account fallbacks, on its +own layer. Names do not depend on Profile disclosure. Missing contacts leave no label +and produce the same success response; products never receive names or availability. +Surfaces and rectangle sides are bounded to 16384 units, clip sides may be zero, and +slot ids must be unique. Empty slots, connection teardown and session changes clear +the layer. On same-wallet directory invalidation, the host clears stale names and +refreshes the latest live placement without another product request. + + ## Trade-offs - A host that serves no picker answers `Unsupported`, which a product cannot retry its way out of. - `NoContacts` reveals whether the user has any contacts — zero-or-not, never a count. -- No product-rendered contact UI, every selection is a user interaction, one contact per call, - read-only. +- No product-rendered contact directory: every selection is a host-owned user interaction. - Dropped: returning the list scoped per product (`display_name` was a correlator no scoping fixed, and it needed a permission over the whole social graph); per-product handles (forfeit a durable shared id, break under contact sync); returning the chat account (transactable, but a global @@ -67,11 +90,9 @@ A product declares the handles its call names, on the transaction payload, and t Substitution happens before the confirmation, so the signing overlay is drawn from a call that names an account the Host can put a name to. That is what closes the display gap for the flow that matters: a product renders a neutral chip, and the user sees who they are paying in trusted UI at the moment of consent. -## Open questions +## Recognition outside signing -How a product shows the user which contact they picked outside a signature. A product holds 32 bytes and no name, so it -renders a neutral chip. Two parts close that, and neither is specified here: the Host redraws the name -in its own signing confirmation, which knows the account and is where consent is given, so a product -never needs the name for the flow to be safe; and a product labels the handle itself, letting the user -name those 32 bytes once. A user-supplied label keeps the Host from handing back the correlator that -ruled out `display_name`. +A product holds only handles and reserves rectangles for `placeLabels`. The host +draws names in those rectangles without returning a global correlator. Profile avatar +slots remain separate and photo-only, so users can recognize a contact even when that +contact has never shared a profile. diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index be68b1ad9..1e3bda869 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -334,6 +334,22 @@ public protocol ContactsHostBridge: AnyObject, Sendable { /// did. With no contacts, answer `.noContacts` instead of drawing an empty /// overlay. func pickContact(productId: String) async throws -> HostContactPick + + /// Edit the complete selected audience; cancelling does not confirm empty. + func pickContacts(productId: String, selection: ContactSelection) async throws -> HostContactsPick + + /// Replace host-owned contact labels, independent of shared profile photos. + func placeContactLabels(productId: String, placed: PlacedContactLabels) async throws +} + +public extension ContactsHostBridge { + func pickContacts(productId: String, selection: ContactSelection) async throws -> HostContactsPick { + .unsupported + } + + func placeContactLabels(productId: String, placed: PlacedContactLabels) async throws { + throw HostContactsPlaceLabelsError.Unsupported + } } public extension HostBridge { @@ -525,6 +541,26 @@ private final class ContactsCallbackAdapter: NativeContactsCallbacks, @unchecked throw HostRejection.Rejected(reason: hostRejectionReason(error)) } } + + func pickContacts(productId: String, selection: ContactSelection) async throws -> HostContactsPick { + do { + return try await bridge.pickContacts(productId: productId, selection: selection) + } catch let error as HostRejection { + throw error + } catch { + throw HostRejection.Rejected(reason: hostRejectionReason(error)) + } + } + + func placeContactLabels(productId: String, placed: PlacedContactLabels) async throws { + do { + try await bridge.placeContactLabels(productId: productId, placed: placed) + } catch let error as HostContactsPlaceLabelsError { + throw error + } catch { + throw HostContactsPlaceLabelsError.Unknown(reason: "contact label callback failed") + } + } } /// Adapter that bridges the public `HostBridge` to the generated UniFFI diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 73a1cacf3..d68ac1c4a 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -312,12 +312,31 @@ The index crosses as a SCALE-encoded `DerivationIndex`, the same value a review code behind it stays core-owned and a host never reconstructs it. `productAccountAddress` applies the prefix host-spec C.6 fixes, rather than leaving each host to choose one. -`contacts` needs both callbacks, or the group counts as absent. `pickContact` draws the picker and returns the chosen -account, or `NoContacts` when there is nobody to show. `contacts({ handleKey, handles })` resolves the handles a -transaction names: one entry per handle, in order, the account or `undefined`. A contact's handle is BLAKE2b-256 keyed -with `handleKey` over its 32-byte account (`blake2b(account, { key: handleKey, dkLen: 32 })` in `@noble/hashes`). The -core re-checks every account returned. It caches what it resolves, so call `notifyContactsChanged()` whenever a contact -is removed or blocked. Omit blocked contacts from both. See the contacts RFC (`docs/rfcs/contacts-api.md`). +The optional `contacts` group resolves handles through `contacts({ handleKey, handles })`: +one entry per handle, in order, the account or `undefined`. `pickContact` draws a single +picker and returns the chosen account. `pickContacts(product, { selected })` edits a +complete selection of at most 256 resolved accounts, returning `Picked { accounts }`, +`Dismissed`, or `NoContacts`. A confirmed empty array is `Picked`, not dismissal. +Missing picker callbacks answer `Unsupported`. +A contact's handle is BLAKE2b-256 keyed with `handleKey` over its 32-byte +account (`blake2b(account, { key: handleKey, dkLen: 32 })` in `@noble/hashes`). +The core re-checks every account returned. It caches what it resolves, so call +`notifyContactsChanged()` whenever a contact is removed or blocked. Omit blocked +contacts from both. See the contacts RFC (`docs/rfcs/contacts-api.md`). + +`placeContactLabels(product, placed)` receives surface dimensions and +`labels: [{ slot, account, rect, clip }]`. Draw names from the host's contact directory, +using an account fallback when no username exists. Profile-photo absence must not +hide a name. Keep this UI host-owned: return no label or per-slot availability. +Return `true` when the host supports label placement, even when no contact resolves. +Return `false` when that UI is unsupported; the adapter supplies this answer when +the callback is omitted. This capability acknowledgment never reports individual +contact availability. Background Workers are denied label placement. +Empty placements clear the previous names and cancel queued refreshes. Clear names +and cancel pending work on frame load, navigation or disconnect. On same-wallet +directory invalidation, clear stale names and refresh the latest live placement +without waiting for the product to resend it. The core serializes placements per +connection and rejects selections from changed sessions. Browser signing hosts can back this UI with `runtime.getNativeChatContacts()`. It returns `{ walletPublicKey, genesisHash, contacts: [{ peerIdentity, username? }] }` to trusted host code only. diff --git a/js/packages/truapi-host/src/adapter-support.ts b/js/packages/truapi-host/src/adapter-support.ts index a538d3115..07cae4ea6 100644 --- a/js/packages/truapi-host/src/adapter-support.ts +++ b/js/packages/truapi-host/src/adapter-support.ts @@ -12,12 +12,28 @@ import type { ChainConnect, ChainConnection, HopConnect } from "./runtime.js"; import type { ChainProvider, CoinageWalletHost, + ContactsPlatform, HopProvider, JsonRpcConnection, NativeChatFilesHost, ProfilePlatform, } from "./generated/host-callbacks.js"; +/** Optional Contacts UI stays unsupported rather than confirming an empty selection. */ +export function contactsHostAdapter( + host: ContactsPlatform | undefined, +): Required | undefined { + if (host === undefined) return undefined; + return { + contacts: (lookup) => host.contacts(lookup), + pickContact: (product) => host.pickContact?.(product) ?? Promise.resolve({ tag: "Unsupported" }), + pickContacts: (product, selection) => + host.pickContacts?.(product, selection) ?? Promise.resolve({ tag: "Unsupported" }), + placeContactLabels: (product, placed) => + host.placeContactLabels?.(product, placed) ?? Promise.resolve(false), + }; +} + type WireResult = | { success: true; value: T } | { success: false; value: E }; diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 481014e30..0dd76697e 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "0bd782cc4aca62dc"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "53d14bc59149b8ba"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -1114,6 +1114,60 @@ impl RequestMethod for ContactsPick { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } +/// `contacts_pick_many` method marker. +pub struct ContactsPickMany; +impl ContactsPickMany { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Contacts", + method: "pick_many", + wire_name: "contacts_pick_many", + request_type: "truapi::versioned::contacts::HostContactsPickManyRequest", + response_type: "truapi::versioned::contacts::HostContactsPickManyResponse", + error_type: Some("truapi::versioned::contacts::HostContactsPickManyError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 20, + method_id: 1, + }), + }; +} +impl RequestMethod for ContactsPickMany { + type Request = truapi::versioned::contacts::HostContactsPickManyRequest; + type Response = truapi::versioned::contacts::HostContactsPickManyResponse; + type Error = truapi::versioned::contacts::HostContactsPickManyError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `contacts_place_labels` method marker. +pub struct ContactsPlaceLabels; +impl ContactsPlaceLabels { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Contacts", + method: "place_labels", + wire_name: "contacts_place_labels", + request_type: "truapi::versioned::contacts::HostContactsPlaceLabelsRequest", + response_type: "truapi::versioned::contacts::HostContactsPlaceLabelsResponse", + error_type: Some("truapi::versioned::contacts::HostContactsPlaceLabelsError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 20, + method_id: 2, + }), + }; +} +impl RequestMethod for ContactsPlaceLabels { + type Request = truapi::versioned::contacts::HostContactsPlaceLabelsRequest; + type Response = truapi::versioned::contacts::HostContactsPlaceLabelsResponse; + type Error = truapi::versioned::contacts::HostContactsPlaceLabelsError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + /// `entropy_derive` method marker. pub struct EntropyDerive; impl EntropyDerive { @@ -2510,6 +2564,8 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ CoinPaymentRefund::DESCRIPTOR, CoinPaymentListenForPayment::DESCRIPTOR, ContactsPick::DESCRIPTOR, + ContactsPickMany::DESCRIPTOR, + ContactsPlaceLabels::DESCRIPTOR, EntropyDerive::DESCRIPTOR, LocalStorageRead::DESCRIPTOR, LocalStorageWrite::DESCRIPTOR, @@ -2594,6 +2650,8 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ CoinPaymentRefund::DESCRIPTOR, CoinPaymentListenForPayment::DESCRIPTOR, ContactsPick::DESCRIPTOR, + ContactsPickMany::DESCRIPTOR, + ContactsPlaceLabels::DESCRIPTOR, EntropyDerive::DESCRIPTOR, LocalStorageRead::DESCRIPTOR, LocalStorageWrite::DESCRIPTOR, @@ -2683,6 +2741,8 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ CoinPaymentRefund::DESCRIPTOR, CoinPaymentListenForPayment::DESCRIPTOR, ContactsPick::DESCRIPTOR, + ContactsPickMany::DESCRIPTOR, + ContactsPlaceLabels::DESCRIPTOR, EntropyDerive::DESCRIPTOR, LocalStorageRead::DESCRIPTOR, LocalStorageWrite::DESCRIPTOR, diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index ffafc82f4..959cf5330 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -398,6 +398,7 @@ fn emit_wasm_adapter( fn optional_host_adapter(trait_name: &str) -> Option<&'static str> { match trait_name { "CoinageWalletHost" => Some("coinageWalletHostAdapter"), + "ContactsPlatform" => Some("contactsHostAdapter"), "ProfilePlatform" => Some("profileHostAdapter"), _ => None, } @@ -1722,6 +1723,9 @@ fn emit_host_callback_composites( let required_members = composes .iter() .map(|trait_name| { + if trait_name == "ContactsPlatform" { + return format!(" {}?: ContactsPlatform;", callback_namespace(trait_name)); + } format!( " {}{}: Required<{}>;", callback_namespace(trait_name), diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 8166e72ab..605d3e32e 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -409,13 +409,18 @@ AutoSigning without approval. Legacy-account signing still asks the user. - `PocketPlatform`: stream the product's Pocket card collection and remove a card from it. The host owns the collection and decides which cards are privileged. -- `ContactsPlatform`: resolve the handles a transaction names to contacts, and - render the picker that selects one. `contacts` is the only required method; `pick_contact` - defaults to `Unsupported`, so a host serving no picker says so rather than - looking like a user who declined. The host owns the UI, so the list never - reaches the product — only a handle for the selection does. The core caches - resolved handles; a host calls `notify_contacts_changed` on its runtime when - a contact is removed or blocked. +- `ContactsPlatform`: resolve opaque handles to contacts, render single or multiple + selection pickers, and place host-owned contact names over product surfaces. + `contacts` is the only required method; `pick_contact` and `pick_contacts` + default to `Unsupported`, never a fake selection. The multi-picker receives a + host-private `ContactSelection` record of resolved accounts. Confirmed empty + selection is distinct from dismissal, and unresolved initial handles fail closed. + Session and directory generations are checked across host calls. + `place_contact_labels` is independent of Profile grants; products receive neither + names nor availability. Placements are serialized and cleared at connection + teardown and session change. Hosts call `notify_contacts_changed` when a contact + is removed or blocked, invalidating cached handles. Their label layers clear stale + names and refresh the live placement from the current directory. - `ProfilePlatform`: show a product-referenced profile in host-owned UI, show a contact's shared profile naming the contact who sent it, and draw the avatars of contacts who shared one over a chat product. The host resolves, diff --git a/rust/crates/truapi/src/api/contacts.rs b/rust/crates/truapi/src/api/contacts.rs index 239cbfa0c..89b7c6fe9 100644 --- a/rust/crates/truapi/src/api/contacts.rs +++ b/rust/crates/truapi/src/api/contacts.rs @@ -1,7 +1,9 @@ //! Unified [`Contacts`] trait. use crate::versioned::contacts::{ - HostContactsPickError, HostContactsPickRequest, HostContactsPickResponse, + HostContactsPickError, HostContactsPickManyError, HostContactsPickManyRequest, + HostContactsPickManyResponse, HostContactsPickRequest, HostContactsPickResponse, + HostContactsPlaceLabelsError, HostContactsPlaceLabelsRequest, HostContactsPlaceLabelsResponse, }; use crate::{CallContext, CallError}; use crate::{wire, wire_trait}; @@ -10,8 +12,8 @@ use crate::{wire, wire_trait}; /// /// A product never reads the contact list. It opens the host's picker; the host /// renders an overlay from the chat lists its chat extensions hold, and -/// returns only the person the user selected. Names, accounts, and every other -/// contact the user did not pick stay host-side. +/// returns only handles for the people the user selected. Names, accounts, and +/// every other contact the user did not pick stay host-side. /// /// That is also why there is no permission to request: the user choosing a /// contact in host UI is the consent, and a product that is never handed the @@ -55,4 +57,50 @@ pub trait Contacts: Send + Sync { ) -> Result> { Err(CallError::unavailable()) } + + /// Edit a complete selection in the host's multi-select contact picker. + /// + /// `selected` preselects existing handles. Confirming none returns `Picked` + /// with an empty `handles` list; dismissing never changes the selection. + /// Unresolvable initial handles reject the entire request. + /// + /// ```ts + /// const result = await truapi.contacts.pickMany({ selected: [] }); + /// assert(result.isOk(), "contacts.pickMany failed:", result); + /// if (result.value.outcome.tag === "Picked") { + /// console.log("confirmed handles:", result.value.outcome.value.handles); + /// } + /// ``` + #[wire(id = 1)] + async fn pick_many( + &self, + _cx: &CallContext, + _request: HostContactsPickManyRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Draw contact names in host-owned rectangles over the product surface. + /// + /// Labels do not require a shared Profile photo or disclosure. The response + /// reveals no name, identity or per-slot availability. Each call replaces + /// the previous placement; empty `slots` clears it. + /// + /// ```ts + /// // Empty placement clears this product's host-owned labels. + /// const result = await truapi.contacts.placeLabels({ + /// surfaceWidth: 640, + /// surfaceHeight: 480, + /// slots: [], + /// }); + /// assert(result.isOk(), "contacts.placeLabels failed:", result); + /// ``` + #[wire(id = 2)] + async fn place_labels( + &self, + _cx: &CallContext, + _request: HostContactsPlaceLabelsRequest, + ) -> Result> { + Err(CallError::unavailable()) + } } diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 063bae5d6..632c2dcd6 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -294,7 +294,7 @@ impl PairingHostRuntime { /// Call whenever a contact is removed or blocked. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.notify_contacts_changed"))] pub fn notify_contacts_changed(&self) { - self.services.contact_handles.clear(); + self.services.invalidate_contacts(); self.services .contact_avatars .contacts_changed(&self.services.spawner); @@ -742,7 +742,7 @@ impl SigningHostRuntime { /// Call whenever a contact is removed or blocked. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.notify_contacts_changed"))] pub fn notify_contacts_changed(&self) { - self.services.contact_handles.clear(); + self.services.invalidate_contacts(); self.services .contact_avatars .contacts_changed(&self.services.spawner); @@ -1640,7 +1640,7 @@ impl ProductRuntime { /// Embedders holding the host runtime may notify it instead. pub fn notify_contacts_changed(&self) { let services = self.admin.product_runtime.services(); - services.contact_handles.clear(); + services.invalidate_contacts(); services.contact_avatars.contacts_changed(&services.spawner); } @@ -1912,6 +1912,7 @@ impl ProductRuntime { self.admin.product_runtime.detach_renderer(); self.admin.product_runtime.release_open_operations(); self.admin.product_runtime.release_contact_avatars(); + self.admin.product_runtime.release_contact_labels(); self.host_subscriptions.close(); self.core.cancel_subscriptions(); } diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 23d9cabfb..165ad4af7 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -165,6 +165,24 @@ pub mod latest { pub type HostContactsPickResponse = LatestOf; /// Contact picker failure. pub type HostContactsPickError = LatestOf; + /// Multi-contact picker request. + pub type HostContactsPickManyRequest = + LatestOf; + /// Multi-contact picker result. + pub type HostContactsPickManyResponse = + LatestOf; + /// Multi-contact picker failure. + pub type HostContactsPickManyError = LatestOf; + /// Host-owned contact name placement. + pub type HostContactsPlaceLabelsRequest = + LatestOf; + /// Contact label placement acknowledgment. + pub type HostContactsPlaceLabelsResponse = + LatestOf; + /// Contact label placement failure. + pub type HostContactsPlaceLabelsError = + LatestOf; + pub use crate::v01::{ContactLabelSlot, ContactPickManyOutcome}; /// Contextual alias derivation result. pub type HostAccountGetAliasResponse = LatestOf; diff --git a/rust/crates/truapi/src/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs index 6899e8212..f99ae3fe1 100644 --- a/rust/crates/truapi/src/native/callbacks.rs +++ b/rust/crates/truapi/src/native/callbacks.rs @@ -388,4 +388,18 @@ pub trait NativeContactsCallbacks: Send + Sync { &self, product_id: String, ) -> Result; + + /// Edit the complete selected audience in host-owned UI. + async fn pick_contacts( + &self, + product_id: String, + selection: crate::platform::ContactSelection, + ) -> Result; + + /// Replace the names drawn over a product surface without returning names. + async fn place_contact_labels( + &self, + product_id: String, + placed: crate::platform::PlacedContactLabels, + ) -> Result<(), crate::latest::HostContactsPlaceLabelsError>; } diff --git a/rust/crates/truapi/src/native/errors.rs b/rust/crates/truapi/src/native/errors.rs index 3d95108e6..3c4ff2344 100644 --- a/rust/crates/truapi/src/native/errors.rs +++ b/rust/crates/truapi/src/native/errors.rs @@ -60,6 +60,12 @@ impl From for v01::HostNavigateToError { } } +impl From for crate::latest::HostContactsPlaceLabelsError { + fn from(_: uniffi::UnexpectedUniFFICallbackError) -> Self { + Self::Unknown { reason: "contact label callback failed".into() } + } +} + impl From for HostRejection { fn from(err: v01::GenericError) -> Self { HostRejection::Rejected { reason: err.reason } diff --git a/rust/crates/truapi/src/native/platform.rs b/rust/crates/truapi/src/native/platform.rs index a3d74bf64..9c38a19a4 100644 --- a/rust/crates/truapi/src/native/platform.rs +++ b/rust/crates/truapi/src/native/platform.rs @@ -152,6 +152,23 @@ impl crate::platform::ContactsPlatform for ContactsCallbackPlatform { reason: error.to_string(), }) } + + async fn pick_contacts( + &self, + product: &ProductContext, + selection: crate::platform::ContactSelection, + ) -> Result { + self.contacts.pick_contacts(product.product_id.clone(), selection).await + .map_err(|error| v01::GenericError { reason: error.to_string() }) + } + + async fn place_contact_labels( + &self, + product: &ProductContext, + placed: crate::platform::PlacedContactLabels, + ) -> Result { + self.contacts.place_contact_labels(product.product_id.clone(), placed).await.map(|()| true) + } } /// Every [`crate::platform::Platform`] trait served by one execution's diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index a837b65da..0eb6195fc 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -4211,6 +4211,57 @@ pub enum HostContactPick { Unsupported, } +/// Host-private initial selection for a multi-contact picker. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))] +pub struct ContactSelection { + /// Resolved accounts to preselect, deduplicated and bounded to 256. + pub selected: Vec, +} + +/// The user's complete selection in a host-owned multi-contact picker. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Enum))] +pub enum HostContactsPick { + /// Confirmed accounts, including an empty selection. Never sent to products. + Picked { + /// Chosen contact accounts, at most 256. + accounts: Vec, + }, + /// The user cancelled without changing the selection. + Dismissed, + /// There are no contacts to show. + NoContacts, + /// This host cannot present a multi-contact picker. + Unsupported, +} + +/// One contact name to render in host-owned UI, without any Profile grant. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))] +pub struct PlacedContactLabel { + /// Stable, product-chosen placement id. + pub slot: u32, + /// Resolved contact account, never sent to the product. + pub account: Bytes32, + /// Name bounds in surface units. + pub rect: AvatarRect, + /// Visible region in surface units. + pub clip: AvatarRect, +} + +/// Complete replacement of names drawn over one product connection. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))] +pub struct PlacedContactLabels { + /// Width of the product surface. + pub surface_width: u32, + /// Height of the product surface. + pub surface_height: u32, + /// Host-resolved names to draw. Empty clears the placement. + pub labels: Vec, +} + /// Host-owned contact picker, drawn from the chat lists the host's chat /// extensions hold. /// @@ -4241,11 +4292,7 @@ pub trait ContactsPlatform: Send + Sync { /// implements [`Self::contacts`] alone still compiles and its products get /// a truthful answer rather than a dismissal they would retry forever. /// - /// A JS host reaches the same answer by another route: the generated - /// surface types this method optional, but a capability group counts as - /// served only when every callback in it is present, so omitting this one - /// makes the whole group absent and `contacts.pick` answers `Unsupported` - /// before any of it is reached. + /// JS adapters apply the same unsupported default when the host omits UI. /// /// The core cannot draw UI, so a selection has to come from the host; the /// whole point is that the host renders the names rather than shipping @@ -4258,6 +4305,32 @@ pub trait ContactsPlatform: Send + Sync { ) -> Result { Ok(HostContactPick::Unsupported) } + + /// Edit the complete selection in host-owned UI. Cancellation is not an + /// empty confirmed selection. Accounts and names stay host-side. + async fn pick_contacts( + &self, + _product: &ProductContext, + _selection: ContactSelection, + ) -> Result { + Ok(HostContactsPick::Unsupported) + } + + /// Draw names from the host's contact directory, with an account fallback + /// when no username exists. Profile sharing must not affect labels. + /// + /// Replace the connection's previous placement, and clear it on navigation + /// or disconnect. On directory invalidation, clear stale names and refresh + /// the live placement from current contacts. No per-contact result is returned. + /// Returns whether this host supports label placement, never whether any + /// individual contact resolved. JS adapters return false for omitted UI. + async fn place_contact_labels( + &self, + _product: &ProductContext, + _placed: PlacedContactLabels, + ) -> Result { + Ok(false) + } } /// Combined platform interface. A host must provide every capability trait diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 9fd0f0128..f02b2490a 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -107,6 +107,8 @@ use truapi::versioned::chat::{ }; use truapi::versioned::contacts::{ HostContactsPickError, HostContactsPickRequest, HostContactsPickResponse, + HostContactsPickManyError, HostContactsPickManyRequest, HostContactsPickManyResponse, + HostContactsPlaceLabelsError, HostContactsPlaceLabelsRequest, HostContactsPlaceLabelsResponse, }; use truapi::versioned::pocket::{ HostPocketListSubscribeError, HostPocketListSubscribeItem, HostPocketListSubscribeRequest, @@ -353,6 +355,7 @@ impl Drop for ProductRuntimeHost { fn drop(&mut self) { self.release_open_operations(); self.release_contact_avatars(); + self.release_contact_labels(); } } @@ -756,11 +759,11 @@ impl ProductRuntimeHost { let service = self.permissions_service(); let contacts_changed = matches!(request, PermissionAuthorizationRequest::ChatAuthority); if contacts_changed { - self.services.contact_handles.clear(); + self.services.invalidate_contacts(); } let result = service.set_authorization_status(&request, status).await; if contacts_changed { - self.services.contact_handles.clear(); + self.services.invalidate_contacts(); } result } @@ -1262,6 +1265,11 @@ impl ProductRuntimeHost { .release(self.core_instance, &self.services.spawner); } + /// Clear this connection's host-owned contact names and prevent late draws. + pub fn release_contact_labels(&self) { + self.services.contact_labels.release(self.core_instance, &self.services.spawner); + } + /// Drop the worker reference a pending operation held. An id that is not /// open releases nothing, which is what keeps `end_operation` idempotent. pub fn release_worker_for_operation(&self, id: u32) { @@ -1495,10 +1503,11 @@ impl Contacts for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "contacts.pick"))] async fn pick( &self, - _cx: &CallContext, + cx: &CallContext, _request: HostContactsPickRequest, ) -> Result> { let wrap = HostContactsPickError::V1; + let session = self.authority.current_session(); let (platform, handles) = self .contacts_picker() .map_err(|error| contacts_error(error, wrap))?; @@ -1512,11 +1521,19 @@ impl Contacts for ProductRuntimeHost { // Read before the picker opens: a removal signalled while the user is // choosing must not be undone by caching their choice. let generation = self.services.contact_handles.generation(); - let outcome = match platform - .pick_contact(&self.product) + if self.authority.current_session() != session { + return Err(CallError::Domain(wrap(v01::HostContactsPickError::NotConnected))); + } + let picked = until_cancelled(cx, platform.pick_contact(&self.product)) .await - .map_err(unknown)? - { + .map_err(|_| CallError::Cancelled)?; + if self.authority.current_session() != session { + return Err(CallError::Domain(wrap(v01::HostContactsPickError::NotConnected))); + } + if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { + return Err(CallError::Cancelled); + } + let outcome = match picked.map_err(unknown)? { crate::platform::HostContactPick::Picked { account } => { let handle = handles.mint(&account); self.services @@ -1536,6 +1553,153 @@ impl Contacts for ProductRuntimeHost { v01::HostContactsPickResponse { outcome }, )) } + + #[instrument(skip_all, fields(runtime.method = "contacts.pick_many"))] + async fn pick_many( + &self, + cx: &CallContext, + request: HostContactsPickManyRequest, + ) -> Result> { + use crate::latest::{ContactHandle, ContactPickManyOutcome, HostContactsPickManyError as Error}; + let error = |error| CallError::Domain(HostContactsPickManyError::V1(error)); + let HostContactsPickManyRequest::V1(request) = request; + let selected = contacts::selected_handles(request.selected) + .ok_or_else(|| error(Error::InvalidSelection))?; + let session = self.authority.current_session(); + let (platform, handles) = self.contacts_picker().map_err(|failure| match failure { + CallError::Unsupported => CallError::Unsupported, + CallError::Domain(v01::HostContactsPickError::NotConnected) => error(Error::NotConnected), + _ => error(Error::Unknown { reason: "contact picker unavailable".into() }), + })?; + let generation = self.services.contact_handles.generation(); + if self.authority.current_session() != session { + return Err(error(Error::NotConnected)); + } + let resolved = until_cancelled( + cx, + resolve_contact_accounts(&self.services, platform.as_ref(), &handles, &selected), + ).await.map_err(|_| CallError::Cancelled)?; + if self.authority.current_session() != session { + return Err(error(Error::NotConnected)); + } + if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { + return Err(CallError::Cancelled); + } + let accounts = resolved + .map_err(|_| error(Error::Unknown { reason: "contact lookup failed".into() }))? + .into_iter() + .map(|(_, account)| account) + .collect::>>() + .ok_or_else(|| error(Error::InvalidSelection))?; + let picked = until_cancelled(cx, platform.pick_contacts(&self.product, crate::platform::ContactSelection { selected: accounts })) + .await + .map_err(|_| CallError::Cancelled)?; + if self.authority.current_session() != session { + return Err(error(Error::NotConnected)); + } + if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { + return Err(CallError::Cancelled); + } + let outcome = match picked.map_err(|_| error(Error::Unknown { + reason: "contact picker failed".into(), + }))? { + crate::platform::HostContactsPick::Picked { accounts } => { + let accounts = contacts::selected_accounts(accounts).ok_or_else(|| error(Error::Unknown { + reason: "contact selection exceeds the limit".into(), + }))?; + let selected = accounts.into_iter().map(|account| { + let bytes = handles.mint(&account); + self.services.contact_handles.insert(bytes, account, generation); + ContactHandle { bytes } + }).collect(); + ContactPickManyOutcome::Picked { handles: selected } + } + crate::platform::HostContactsPick::Dismissed => ContactPickManyOutcome::Dismissed, + crate::platform::HostContactsPick::NoContacts => ContactPickManyOutcome::NoContacts, + crate::platform::HostContactsPick::Unsupported => return Err(CallError::Unsupported), + }; + Ok(HostContactsPickManyResponse::V1(crate::latest::HostContactsPickManyResponse { outcome })) + } + + #[instrument(skip_all, fields(runtime.method = "contacts.place_labels"))] + async fn place_labels( + &self, + cx: &CallContext, + request: HostContactsPlaceLabelsRequest, + ) -> Result> { + use crate::latest::HostContactsPlaceLabelsError as Error; + if self.product.execution_kind != crate::platform::ProductExecutionKind::App { + return Err(CallError::Denied); + } + let error = |error| CallError::Domain(HostContactsPlaceLabelsError::V1(error)); + let HostContactsPlaceLabelsRequest::V1(request) = request; + if !contacts::valid_label_placement(&request) { + return Err(error(Error::InvalidPlacement)); + } + let session = self.authority.current_session(); + let (platform, handles) = self.contacts_picker().map_err(|failure| match failure { + CallError::Unsupported => CallError::Unsupported, + CallError::Domain(v01::HostContactsPickError::NotConnected) => error(Error::NotConnected), + _ => error(Error::Unknown { reason: "contact labels unavailable".into() }), + })?; + let placement = self.services.contact_labels.for_runtime(self.core_instance, platform.clone(), &self.product); + let mut surface = placement.surface.lock().await; + if placement.is_closed() || cx.cancel().is_cancelled() { + return Err(CallError::Cancelled); + } + let generation = self.services.contact_handles.generation(); + if self.authority.current_session() != session { + return Err(error(Error::NotConnected)); + } + let requested: Vec<_> = request.slots.iter().map(|slot| slot.handle.bytes).collect(); + let resolved = until_cancelled( + cx, + resolve_contact_accounts(&self.services, platform.as_ref(), &handles, &requested), + ).await.map_err(|_| CallError::Cancelled)?; + if self.authority.current_session() != session { + return Err(error(Error::NotConnected)); + } + if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { + return Err(CallError::Cancelled); + } + let resolved = resolved.unwrap_or_else(|_| requested.into_iter().map(|handle| (handle, None)).collect()); + let labels = request.slots.into_iter().zip(resolved).filter_map(|(slot, (_, account))| { + account.map(|account| crate::platform::PlacedContactLabel { + slot: slot.slot, + account, + rect: slot.rect, + clip: slot.clip, + }) + }).collect(); + if placement.is_closed() { + return Err(CallError::Cancelled); + } + *surface = Some((request.surface_width, request.surface_height, generation)); + let result = platform.place_contact_labels(&self.product, crate::platform::PlacedContactLabels { + surface_width: request.surface_width, + surface_height: request.surface_height, + labels, + }).await; + if self.authority.current_session() != session + || cx.cancel().is_cancelled() + || placement.is_closed() + { + let _ = platform.place_contact_labels(&self.product, crate::platform::PlacedContactLabels { + surface_width: request.surface_width, + surface_height: request.surface_height, + labels: Vec::new(), + }).await; + return Err(if self.authority.current_session() != session { + error(Error::NotConnected) + } else { + CallError::Cancelled + }); + } + if matches!(result, Ok(false) | Err(Error::Unsupported)) { + return Err(CallError::Unsupported); + } + Ok(HostContactsPlaceLabelsResponse::V1(crate::latest::HostContactsPlaceLabelsResponse {})) + } } /// Re-wrap a latest-payload picker error into its versioned envelope. diff --git a/rust/crates/truapi/src/runtime/contacts.rs b/rust/crates/truapi/src/runtime/contacts.rs index 42f67d3d2..65e7f5765 100644 --- a/rust/crates/truapi/src/runtime/contacts.rs +++ b/rust/crates/truapi/src/runtime/contacts.rs @@ -1,8 +1,8 @@ //! The contact picker and the handles it hands out. //! //! A product never reads the contact list. It opens the host's picker, the host -//! draws an overlay from its own chat contacts, and the core turns the one -//! person the user selected into a handle. +//! draws an overlay from its own chat contacts, and the core turns the +//! confirmed selection into handles. //! //! The handle is deliberately **not** per-product: the same contact yields the //! same value in every product and on every host of this user. Per-product @@ -14,14 +14,135 @@ //! Keyed on the session's root entropy source, which no product can reach, so //! the mapping cannot be recovered by hashing candidate accounts. -use std::collections::HashMap; -use std::sync::Mutex; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}}; use parity_scale_codec::Encode; /// Upper bound on cached handles. The cache holds contacts the user picked, so /// it stays small; reaching the bound empties it rather than evicting in order. const HANDLE_CACHE_MAX_ENTRIES: usize = 256; +const MAX_CONTACTS: usize = 256; + +/// Bound and deduplicate product-supplied selections without changing order. +pub fn selected_handles( + selected: Vec, +) -> Option> { + if selected.len() > MAX_CONTACTS { + return None; + } + let mut seen = HashSet::with_capacity(selected.len()); + Some(selected.into_iter().map(|handle| handle.bytes).filter(|handle| seen.insert(*handle)).collect()) +} + +/// Bound and deduplicate accounts a host picker confirmed. +pub fn selected_accounts(mut accounts: Vec<[u8; 32]>) -> Option> { + if accounts.len() > MAX_CONTACTS { + return None; + } + let mut seen = HashSet::with_capacity(accounts.len()); + accounts.retain(|account| seen.insert(*account)); + Some(accounts) +} + +/// Validate only product-controlled geometry, never contact availability. +pub fn valid_label_placement(request: &crate::latest::HostContactsPlaceLabelsRequest) -> bool { + const MAX_SIDE: u32 = 16384; + if !(1..=MAX_SIDE).contains(&request.surface_width) + || !(1..=MAX_SIDE).contains(&request.surface_height) + || request.slots.len() > MAX_CONTACTS + { + return false; + } + let mut seen = HashSet::with_capacity(request.slots.len()); + request.slots.iter().all(|slot| { + seen.insert(slot.slot) + && (1..=MAX_SIDE).contains(&slot.rect.width) + && (1..=MAX_SIDE).contains(&slot.rect.height) + && slot.clip.width <= MAX_SIDE + && slot.clip.height <= MAX_SIDE + }) +} + +/// Connection-owned label layer, serialized so delayed draws cannot overtake clears. +pub struct ContactLabelPlacement { + platform: Arc, + product: crate::platform::ProductContext, + /// Last submitted width, height and directory generation, held across lookup and draw. + pub surface: futures::lock::Mutex>, + closed: AtomicBool, +} + +impl ContactLabelPlacement { + /// Whether teardown has started for this connection. + pub fn is_closed(&self) -> bool { + self.closed.load(Ordering::Acquire) + } + + async fn clear(&self, invalidated_before: Option) { + let mut surface = self.surface.lock().await; + if let (Some((_, _, generation)), Some(invalidated_before)) = (*surface, invalidated_before) + && generation >= invalidated_before + { + return; + } + if let Some((surface_width, surface_height, _)) = surface.take() { + let _ = self.platform.place_contact_labels( + &self.product, + crate::platform::PlacedContactLabels { + surface_width, + surface_height, + labels: Vec::new(), + }, + ).await; + } + } +} + +/// Host-owned labels belonging to live product connections. +#[derive(Default)] +pub struct ContactLabelPlacements { + by_runtime: parking_lot::Mutex>>, +} + +impl ContactLabelPlacements { + /// Acquire the connection's serial label layer. + pub fn for_runtime( + &self, + runtime: u64, + platform: Arc, + product: &crate::platform::ProductContext, + ) -> Arc { + self.by_runtime.lock() + .entry(runtime).or_insert_with(|| Arc::new(ContactLabelPlacement { + platform, + product: product.clone(), + surface: Default::default(), + closed: AtomicBool::new(false), + })).clone() + } + + /// Prevent late draws and clear a connection's labels during teardown. + pub fn release(&self, runtime: u64, spawner: &crate::subscription::Spawner) { + let Some(placement) = self.by_runtime.lock() + .remove(&runtime) else { + return; + }; + placement.closed.store(true, Ordering::Release); + spawner(Box::pin(async move { placement.clear(None).await })); + } + + /// Clear labels from the preceding wallet session, without erasing newer draws. + pub fn session_changed(&self, generation: u64, spawner: &crate::subscription::Spawner) { + let placements: Vec<_> = self.by_runtime.lock() + .values().cloned().collect(); + spawner(Box::pin(async move { + for placement in placements { + placement.clear(Some(generation)).await; + } + })); + } +} /// Domain separator for the contact-handle key. pub const CONTACT_HANDLE_CONTEXT: &[u8] = b"truapi-contact-handle"; @@ -363,19 +484,6 @@ mod tests { assert!(!cache.has_undeclared_handle(&[0x04, 0x00], &[])); } - #[test] - fn the_product_wire_surface_is_the_picker_and_nothing_else() { - // The contact list must not be reachable from a product. This asserts - // the dispatch table itself, so adding a list or subscribe method to the - // `Contacts` trait fails here rather than shipping. - let contacts: Vec<&str> = crate::generated::wire_table::WIRE_TABLE - .iter() - .map(|entry| entry.method) - .filter(|method| method.starts_with("contacts_")) - .collect(); - assert_eq!(contacts, vec!["contacts_pick"]); - } - #[test] fn a_picked_outcome_carries_nothing_but_a_handle() { // Encoded width pins the payload: one discriminant plus 32 bytes leaves diff --git a/rust/crates/truapi/src/runtime/native_chat.rs b/rust/crates/truapi/src/runtime/native_chat.rs index 6f1af1967..e34319caa 100644 --- a/rust/crates/truapi/src/runtime/native_chat.rs +++ b/rust/crates/truapi/src/runtime/native_chat.rs @@ -248,7 +248,7 @@ impl NativeChatRegistry { product: &str, ) -> Result<(), ChatError> { let mut uncertain = self.state.products.lock().await; - context.services.contact_handles.clear(); + context.services.invalidate_contacts(); let cache = self.state.cache.lock().clone(); let key = ( (context.session.public_key, context.genesis_hash), diff --git a/rust/crates/truapi/src/runtime/pairing_host.rs b/rust/crates/truapi/src/runtime/pairing_host.rs index 1dec9058b..504fd628b 100644 --- a/rust/crates/truapi/src/runtime/pairing_host.rs +++ b/rust/crates/truapi/src/runtime/pairing_host.rs @@ -871,6 +871,7 @@ impl PairingHost { lifecycle.advance(); let previous = self.session_state.current(); self.session_state.clear_session(); + self.services.contacts_session_changed(); previous }; self.stop_session_channel(previous.as_ref()); @@ -948,6 +949,7 @@ impl PairingHost { } let previous = self.session_state.current(); self.session_state.set_session(session.clone()); + self.services.contacts_session_changed(); lifecycle.external_session_active = external_session; previous }; diff --git a/rust/crates/truapi/src/runtime/services.rs b/rust/crates/truapi/src/runtime/services.rs index 686126998..1ef5c5bfd 100644 --- a/rust/crates/truapi/src/runtime/services.rs +++ b/rust/crates/truapi/src/runtime/services.rs @@ -65,6 +65,8 @@ pub struct RuntimeServices { /// Contact handles already resolved, shared by every product runtime of /// this host and emptied when the host says its contacts changed. pub contact_handles: Arc, + /// Connection-owned label layers, cleared on session change and teardown. + pub contact_labels: crate::runtime::contacts::ContactLabelPlacements, /// Host observer told when a device finishes pairing with this signing /// host. Unset leaves a paired device unannounced. device_pairing_observer: OnceLock>, @@ -164,6 +166,7 @@ impl RuntimeServices { identity_backend: OnceLock::new(), contacts_platform: OnceLock::new(), contact_handles: Default::default(), + contact_labels: Default::default(), device_pairing_observer: OnceLock::new(), #[cfg(not(target_arch = "wasm32"))] core_db: OnceLock::new(), @@ -271,6 +274,17 @@ impl RuntimeServices { self.contacts_platform.get().cloned() } + /// Invalidate handle resolutions; host label layers refresh their live directory view. + pub fn invalidate_contacts(&self) { + self.contact_handles.clear(); + } + + /// Forget handles and labels belonging to the preceding wallet session. + pub fn contacts_session_changed(&self) { + self.invalidate_contacts(); + self.contact_labels.session_changed(self.contact_handles.generation(), &self.spawner); + } + /// Install the host's device-pairing observer. /// /// Set-once, like every optional capability, so the surface that announces diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index 75871dd74..5cdd9f718 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -441,7 +441,7 @@ impl SigningHost { .lock() .expect("local AutoSigning grant mutex poisoned"); state.advance_activation(); - self.services.contact_handles.clear(); + self.services.contacts_session_changed(); *self .root_entropy .lock() @@ -462,7 +462,7 @@ impl SigningHost { .lock() .expect("local AutoSigning grant mutex poisoned"); state.advance_activation(); - self.services.contact_handles.clear(); + self.services.contacts_session_changed(); self.root_entropy .lock() .expect("signing host entropy mutex poisoned") diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 799ebd795..f1ffa4ca2 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -771,7 +771,7 @@ impl crate::platform::ContactsPlatform for StubContactsPlatform { fn contacts_host( product_id: &str, platform: Arc, - contacts: Option>, + contacts: Option>, connected: bool, ) -> ProductRuntimeHost { let (host_config, product) = runtime_config(product_id); @@ -807,6 +807,224 @@ fn pick( )) } +struct AudienceContactsPlatform { + directory: Arc, + outcome: parking_lot::Mutex, + selected: parking_lot::Mutex>>, + labels: parking_lot::Mutex>, + after_lookup: parking_lot::Mutex>>, + after_pick: parking_lot::Mutex>>, + after_labels: parking_lot::Mutex>>, +} + +impl AudienceContactsPlatform { + fn new(accounts: Vec<[u8; 32]>, outcome: crate::platform::HostContactsPick) -> Arc { + Arc::new(Self { + directory: StubContactsPlatform::new(accounts, crate::platform::HostContactPick::Dismissed), + outcome: parking_lot::Mutex::new(outcome), + selected: Default::default(), + labels: Default::default(), + after_lookup: Default::default(), + after_pick: Default::default(), + after_labels: Default::default(), + }) + } +} + +#[truapi::async_trait] +impl crate::platform::ContactsPlatform for AudienceContactsPlatform { + async fn contacts( + &self, + lookup: &crate::platform::HostContactLookup, + ) -> Result { + let answer = crate::platform::ContactsPlatform::contacts(self.directory.as_ref(), lookup).await; + if let Some(changed) = self.after_lookup.lock().take() { + changed(); + } + answer + } + + async fn pick_contacts( + &self, + _product: &ProductContext, + selection: crate::platform::ContactSelection, + ) -> Result { + self.selected.lock().push(selection.selected); + if let Some(changed) = self.after_pick.lock().take() { + changed(); + } + Ok(self.outcome.lock().clone()) + } + + async fn place_contact_labels( + &self, + _product: &ProductContext, + placed: crate::platform::PlacedContactLabels, + ) -> Result { + self.labels.lock().push(placed); + if let Some(changed) = self.after_labels.lock().take() { + changed(); + } + Ok(true) + } +} + +fn pick_many( + host: &ProductRuntimeHost, + selected: Vec, +) -> Result> { + futures::executor::block_on(Contacts::pick_many( + host, + &CallContext::default(), + HostContactsPickManyRequest::V1(truapi::latest::HostContactsPickManyRequest { selected }), + )) +} + +#[test] +fn multi_picker_preserves_confirmed_empty_and_dismissed_outcomes() { + use crate::platform::HostContactsPick; + use truapi::latest::ContactPickManyOutcome; + let contacts = AudienceContactsPlatform::new(vec![[10; 32]], HostContactsPick::Picked { accounts: vec![] }); + let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); + for (answer, expected) in [ + (HostContactsPick::Picked { accounts: vec![] }, ContactPickManyOutcome::Picked { handles: vec![] }), + (HostContactsPick::Dismissed, ContactPickManyOutcome::Dismissed), + (HostContactsPick::NoContacts, ContactPickManyOutcome::NoContacts), + ] { + *contacts.outcome.lock() = answer; + assert_eq!(pick_many(&host, vec![]), Ok(HostContactsPickManyResponse::V1( + truapi::latest::HostContactsPickManyResponse { outcome: expected }, + ))); + } +} + +#[test] +fn multi_picker_rejects_unresolved_or_oversized_initial_audiences_without_opening() { + let account = [10; 32]; + let contacts = AudienceContactsPlatform::new(vec![account], crate::platform::HostContactsPick::Dismissed); + let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); + let (_, handles) = host.contacts_picker().unwrap(); + let known = truapi::latest::ContactHandle { bytes: handles.mint(&account) }; + let missing = truapi::latest::ContactHandle { bytes: handles.mint(&[11; 32]) }; + for selected in [vec![known, missing], vec![known; 257]] { + assert_eq!(pick_many(&host, selected), Err(CallError::Domain(HostContactsPickManyError::V1( + truapi::latest::HostContactsPickManyError::InvalidSelection, + )))); + } + assert!(contacts.selected.lock().is_empty()); +} + +#[test] +fn multi_picker_deduplicates_and_returns_only_wallet_scoped_handles() { + let account = [10; 32]; + let contacts = AudienceContactsPlatform::new(vec![account], crate::platform::HostContactsPick::Picked { + accounts: vec![account, account], + }); + let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); + let (_, handles) = host.contacts_picker().unwrap(); + let handle = truapi::latest::ContactHandle { bytes: handles.mint(&account) }; + assert_eq!(pick_many(&host, vec![handle, handle]), Ok(HostContactsPickManyResponse::V1( + truapi::latest::HostContactsPickManyResponse { + outcome: truapi::latest::ContactPickManyOutcome::Picked { handles: vec![handle] }, + }, + ))); + assert_eq!(*contacts.selected.lock(), vec![vec![account]]); + assert_ne!(handle.bytes, account); +} + +#[test] +fn multi_picker_rejects_lookup_invalidation_and_session_change_during_confirmation() { + let account = [10; 32]; + let contacts = AudienceContactsPlatform::new(vec![account], crate::platform::HostContactsPick::Picked { + accounts: vec![account], + }); + let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); + let (_, handles) = host.contacts_picker().unwrap(); + let handle = truapi::latest::ContactHandle { bytes: handles.mint(&account) }; + let cache = host.services.contact_handles.clone(); + *contacts.after_lookup.lock() = Some(Box::new(move || cache.clear())); + assert_eq!(pick_many(&host, vec![handle]), Err(CallError::Cancelled)); + assert!(contacts.selected.lock().is_empty()); + let session = host.test_session_state(); + *contacts.after_pick.lock() = Some(Box::new(move || session.clear_session())); + assert_eq!(pick_many(&host, vec![]), Err(CallError::Domain(HostContactsPickManyError::V1( + truapi::latest::HostContactsPickManyError::NotConnected, + )))); + assert_eq!(host.services.contact_handles.get(&handle.bytes, &handles), None); +} + +#[test] +fn multi_picker_cancellation_cannot_confirm_a_late_selection() { + let account = [10; 32]; + let contacts = AudienceContactsPlatform::new(vec![account], crate::platform::HostContactsPick::Picked { + accounts: vec![account], + }); + let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); + let cx = CallContext::default(); + let cancel = cx.cancel().clone(); + *contacts.after_pick.lock() = Some(Box::new(move || cancel.cancel())); + assert_eq!(futures::executor::block_on(Contacts::pick_many( + &host, &cx, + HostContactsPickManyRequest::V1(truapi::latest::HostContactsPickManyRequest { selected: vec![] }), + )), Err(CallError::Cancelled)); +} + +#[test] +fn contact_labels_need_no_profile_grant_and_hide_missing_contact_availability() { + let account = [10; 32]; + let contacts = AudienceContactsPlatform::new(vec![account], crate::platform::HostContactsPick::Dismissed); + let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); + let (_, handles) = host.contacts_picker().unwrap(); + let rect = truapi::latest::AvatarRect { x: 0, y: 0, width: 180, height: 24 }; + let request = |account| HostContactsPlaceLabelsRequest::V1(truapi::latest::HostContactsPlaceLabelsRequest { + surface_width: 300, + surface_height: 200, + slots: vec![truapi::latest::ContactLabelSlot { + slot: 0, handle: truapi::latest::ContactHandle { bytes: handles.mint(&account) }, + rect, clip: rect, + }], + }); + let place = |request| futures::executor::block_on(Contacts::place_labels(&host, &CallContext::default(), request)); + let known = place(request(account)); + let missing = place(request([11; 32])); + assert_eq!(known, Ok(HostContactsPlaceLabelsResponse::V1(truapi::latest::HostContactsPlaceLabelsResponse {}))); + assert_eq!(known, missing); + assert_eq!(*contacts.labels.lock(), vec![ + crate::platform::PlacedContactLabels { + surface_width: 300, surface_height: 200, + labels: vec![crate::platform::PlacedContactLabel { slot: 0, account, rect, clip: rect }], + }, + crate::platform::PlacedContactLabels { surface_width: 300, surface_height: 200, labels: vec![] }, + ]); +} + +#[test] +fn contact_labels_are_cleared_if_the_session_changes_while_drawing() { + let account = [10; 32]; + let contacts = AudienceContactsPlatform::new(vec![account], crate::platform::HostContactsPick::Dismissed); + let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); + let (_, handles) = host.contacts_picker().unwrap(); + let session = host.test_session_state(); + *contacts.after_labels.lock() = Some(Box::new(move || session.clear_session())); + let rect = truapi::latest::AvatarRect { x: 0, y: 0, width: 180, height: 24 }; + let result = futures::executor::block_on(Contacts::place_labels( + &host, &CallContext::default(), + HostContactsPlaceLabelsRequest::V1(truapi::latest::HostContactsPlaceLabelsRequest { + surface_width: 300, surface_height: 200, + slots: vec![truapi::latest::ContactLabelSlot { + slot: 0, handle: truapi::latest::ContactHandle { bytes: handles.mint(&account) }, + rect, clip: rect, + }], + }), + )); + assert_eq!(result, Err(CallError::Domain(HostContactsPlaceLabelsError::V1( + truapi::latest::HostContactsPlaceLabelsError::NotConnected, + )))); + assert_eq!(contacts.labels.lock().last(), Some(&crate::platform::PlacedContactLabels { + surface_width: 300, surface_height: 200, labels: vec![], + })); +} + /// A host that implements only the required `contacts` method. struct LookupOnlyContactsPlatform; @@ -861,6 +1079,38 @@ fn a_host_that_only_resolves_contacts_reports_unsupported() { install_pairing_session(&host, session_info()); assert_eq!(pick(&host).unwrap_err(), CallError::Unsupported); + assert_eq!( + futures::executor::block_on(Contacts::place_labels( + &host, + &CallContext::default(), + HostContactsPlaceLabelsRequest::V1(truapi::latest::HostContactsPlaceLabelsRequest { + surface_width: 300, + surface_height: 200, + slots: vec![], + }), + )), + Err(CallError::Unsupported), + ); +} + +#[test] +fn workers_cannot_place_contact_labels_even_when_the_host_supports_them() { + let contacts = AudienceContactsPlatform::new(vec![], crate::platform::HostContactsPick::Dismissed); + let mut host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); + host.product.execution_kind = crate::platform::ProductExecutionKind::Worker; + assert_eq!( + futures::executor::block_on(Contacts::place_labels( + &host, + &CallContext::default(), + HostContactsPlaceLabelsRequest::V1(truapi::latest::HostContactsPlaceLabelsRequest { + surface_width: 300, + surface_height: 200, + slots: vec![], + }), + )), + Err(CallError::Denied), + ); + assert!(contacts.labels.lock().is_empty()); } #[test] diff --git a/rust/crates/truapi/src/v01/contacts.rs b/rust/crates/truapi/src/v01/contacts.rs index dd0cdde42..70f49be38 100644 --- a/rust/crates/truapi/src/v01/contacts.rs +++ b/rust/crates/truapi/src/v01/contacts.rs @@ -1,4 +1,5 @@ use alloc::string::String; +use alloc::vec::Vec; use parity_scale_codec::{Decode, Encode}; use crate::Bytes32; @@ -70,3 +71,98 @@ pub enum HostContactsPickError { reason: String, }, } + +/// Selection confirmed in the host's multi-contact picker. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum ContactPickManyOutcome { + /// The user confirmed this complete selection, including an empty one. + Picked { + /// Wallet-scoped handles, with duplicates removed. + handles: Vec, + }, + /// The user closed the picker without confirming a change. + Dismissed, + /// There are no contacts to show. + NoContacts, +} + +/// Open the host's picker with the product's current selection. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostContactsPickManyRequest { + /// At most 256 handles. Duplicates are ignored; an unresolved handle + /// rejects the entire request rather than changing the selected audience. + pub selected: Vec, +} + +/// The user's confirmed selection or reason no selection was made. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostContactsPickManyResponse { + /// How the picker ended. + pub outcome: ContactPickManyOutcome, +} + +/// Failure before a complete selection can be confirmed. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostContactsPickManyError { + /// No active session, or the session changed while choosing. + NotConnected, + /// The selection exceeds the bound or contains an unresolved handle. + InvalidSelection, + /// The host could not complete the picker. + Unknown { + /// Reason without contact identities or names. + reason: String, + }, +} + +/// Geometry for a host-owned contact name, independent of profile sharing. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ContactLabelSlot { + /// Product-chosen id, unique within this placement. + pub slot: u32, + /// Opaque handle for the contact whose name the host draws. + pub handle: ContactHandle, + /// Name bounds in surface units, with sides from 1 to 16384. + pub rect: super::AvatarRect, + /// Visible region in surface units; a zero side hides the label. + pub clip: super::AvatarRect, +} + +/// Replace the contact names drawn over a product's surface. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostContactsPlaceLabelsRequest { + /// Surface width in framebuffer pixels or web viewport CSS pixels, 1 to 16384. + pub surface_width: u32, + /// Surface height in the same units, 1 to 16384. + pub surface_height: u32, + /// At most 256 slots. Empty clears the previous placement. + pub slots: Vec, +} + +/// Acknowledges placement without revealing any contact's name or availability. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct HostContactsPlaceLabelsResponse {} + +/// Placement failure, never the availability of any individual contact. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Error) +)] +pub enum HostContactsPlaceLabelsError { + /// The host cannot draw labels over the product surface. + #[display("contact labels are unsupported")] + Unsupported, + /// No active session, or it changed while placing labels. + #[display("not connected")] + NotConnected, + /// The surface, slot count, slot ids or rectangles are invalid. + #[display("invalid label placement")] + InvalidPlacement, + /// The host could not place the labels. + #[display("{reason}")] + Unknown { + /// Reason without contact identities or names. + reason: String, + }, +} diff --git a/rust/crates/truapi/src/versioned/contacts.rs b/rust/crates/truapi/src/versioned/contacts.rs index 1c6f9632b..9825adca2 100644 --- a/rust/crates/truapi/src/versioned/contacts.rs +++ b/rust/crates/truapi/src/versioned/contacts.rs @@ -6,4 +6,10 @@ truapi_macros::versioned_type! { pub enum HostContactsPickRequest { V1 => v01::HostContactsPickRequest } pub enum HostContactsPickResponse { V1 => v01::HostContactsPickResponse } pub enum HostContactsPickError { V1 => v01::HostContactsPickError } + pub enum HostContactsPickManyRequest { V1 => v01::HostContactsPickManyRequest } + pub enum HostContactsPickManyResponse { V1 => v01::HostContactsPickManyResponse } + pub enum HostContactsPickManyError { V1 => v01::HostContactsPickManyError } + pub enum HostContactsPlaceLabelsRequest { V1 => v01::HostContactsPlaceLabelsRequest } + pub enum HostContactsPlaceLabelsResponse { V1 => v01::HostContactsPlaceLabelsResponse } + pub enum HostContactsPlaceLabelsError { V1 => v01::HostContactsPlaceLabelsError } } From bda45fd3cffab202bd192a214bc951ca04b74ca0 Mon Sep 17 00:00:00 2001 From: w Date: Fri, 2 Oct 2026 09:07:21 -0400 Subject: [PATCH 25/30] docs(contacts): describe selection and private labels --- .changeset/profile-disclose.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.changeset/profile-disclose.md b/.changeset/profile-disclose.md index 1d78e8952..7994a3ab7 100644 --- a/.changeset/profile-disclose.md +++ b/.changeset/profile-disclose.md @@ -59,3 +59,9 @@ and refresh remembered avatars. App-specific references take precedence over per all affected wallet placements. Personal revisions also advance the host-rendered freshness timestamp when a newer share arrives through an actor whose clock is older, preventing a same-reference update from leaving stale cached profile contents. + +Add `contacts.pickMany` with preselected opaque handles and explicit picked, dismissed, and no-contacts outcomes. +Add `contacts.placeLabels` so Apps can reserve host-rendered contact names without receiving those names or profile +availability. The core validates bounded placements and wallet-scoped handles, refreshes labels after Contacts changes, +and releases them when the connection closes. Hosts without a label surface return `Unsupported`; Worker products +cannot place labels. Clearing a session serializes removal of its remembered contact labels with pending refreshes. From 84e163ac30ea4ab07875d8cdb084eaaea747d198 Mon Sep 17 00:00:00 2001 From: w Date: Fri, 2 Oct 2026 09:15:53 -0400 Subject: [PATCH 26/30] fix(contacts): preserve domain errors on host interruption --- .changeset/profile-disclose.md | 3 ++ rust/crates/truapi/src/runtime.rs | 28 ++++++++----------- rust/crates/truapi/src/runtime/contacts.rs | 3 ++ rust/crates/truapi/src/runtime/tests.rs | 32 ++++++++++++++++++++-- 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/.changeset/profile-disclose.md b/.changeset/profile-disclose.md index 7994a3ab7..ed9ee0531 100644 --- a/.changeset/profile-disclose.md +++ b/.changeset/profile-disclose.md @@ -65,3 +65,6 @@ Add `contacts.placeLabels` so Apps can reserve host-rendered contact names witho availability. The core validates bounded placements and wallet-scoped handles, refreshes labels after Contacts changes, and releases them when the connection closes. Hosts without a label surface return `Unsupported`; Worker products cannot place labels. Clearing a session serializes removal of its remembered contact labels with pending refreshes. +Host-side interruption returns a Contacts domain error, reserving wire `Cancelled` for a peer's explicit cancellation. +Failed directory lookups preserve the prior label surface and report a retryable error instead of clearing it as if +the contacts were missing. diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index f02b2490a..52a637044 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -1526,12 +1526,12 @@ impl Contacts for ProductRuntimeHost { } let picked = until_cancelled(cx, platform.pick_contact(&self.product)) .await - .map_err(|_| CallError::Cancelled)?; + .map_err(|_| unknown(v01::GenericError { reason: "contact picker interrupted".into() }))?; if self.authority.current_session() != session { return Err(CallError::Domain(wrap(v01::HostContactsPickError::NotConnected))); } if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { - return Err(CallError::Cancelled); + return Err(unknown(v01::GenericError { reason: "contact picker interrupted".into() })); } let outcome = match picked.map_err(unknown)? { crate::platform::HostContactPick::Picked { account } => { @@ -1578,12 +1578,12 @@ impl Contacts for ProductRuntimeHost { let resolved = until_cancelled( cx, resolve_contact_accounts(&self.services, platform.as_ref(), &handles, &selected), - ).await.map_err(|_| CallError::Cancelled)?; + ).await.map_err(|_| error(Error::Unknown { reason: "contact lookup interrupted".into() }))?; if self.authority.current_session() != session { return Err(error(Error::NotConnected)); } if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { - return Err(CallError::Cancelled); + return Err(error(Error::Unknown { reason: "contact selection interrupted".into() })); } let accounts = resolved .map_err(|_| error(Error::Unknown { reason: "contact lookup failed".into() }))? @@ -1593,12 +1593,12 @@ impl Contacts for ProductRuntimeHost { .ok_or_else(|| error(Error::InvalidSelection))?; let picked = until_cancelled(cx, platform.pick_contacts(&self.product, crate::platform::ContactSelection { selected: accounts })) .await - .map_err(|_| CallError::Cancelled)?; + .map_err(|_| error(Error::Unknown { reason: "contact picker interrupted".into() }))?; if self.authority.current_session() != session { return Err(error(Error::NotConnected)); } if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { - return Err(CallError::Cancelled); + return Err(error(Error::Unknown { reason: "contact selection interrupted".into() })); } let outcome = match picked.map_err(|_| error(Error::Unknown { reason: "contact picker failed".into(), @@ -1645,7 +1645,7 @@ impl Contacts for ProductRuntimeHost { let placement = self.services.contact_labels.for_runtime(self.core_instance, platform.clone(), &self.product); let mut surface = placement.surface.lock().await; if placement.is_closed() || cx.cancel().is_cancelled() { - return Err(CallError::Cancelled); + return Err(error(Error::NotConnected)); } let generation = self.services.contact_handles.generation(); if self.authority.current_session() != session { @@ -1655,14 +1655,14 @@ impl Contacts for ProductRuntimeHost { let resolved = until_cancelled( cx, resolve_contact_accounts(&self.services, platform.as_ref(), &handles, &requested), - ).await.map_err(|_| CallError::Cancelled)?; + ).await.map_err(|_| error(Error::Unknown { reason: "contact lookup interrupted".into() }))?; if self.authority.current_session() != session { return Err(error(Error::NotConnected)); } if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { - return Err(CallError::Cancelled); + return Err(error(Error::Unknown { reason: "contact labels interrupted".into() })); } - let resolved = resolved.unwrap_or_else(|_| requested.into_iter().map(|handle| (handle, None)).collect()); + let resolved = resolved.map_err(|_| error(Error::Unknown { reason: "contact lookup failed".into() }))?; let labels = request.slots.into_iter().zip(resolved).filter_map(|(slot, (_, account))| { account.map(|account| crate::platform::PlacedContactLabel { slot: slot.slot, @@ -1672,7 +1672,7 @@ impl Contacts for ProductRuntimeHost { }) }).collect(); if placement.is_closed() { - return Err(CallError::Cancelled); + return Err(error(Error::NotConnected)); } *surface = Some((request.surface_width, request.surface_height, generation)); let result = platform.place_contact_labels(&self.product, crate::platform::PlacedContactLabels { @@ -1689,11 +1689,7 @@ impl Contacts for ProductRuntimeHost { surface_height: request.surface_height, labels: Vec::new(), }).await; - return Err(if self.authority.current_session() != session { - error(Error::NotConnected) - } else { - CallError::Cancelled - }); + return Err(error(Error::NotConnected)); } if matches!(result, Ok(false) | Err(Error::Unsupported)) { return Err(CallError::Unsupported); diff --git a/rust/crates/truapi/src/runtime/contacts.rs b/rust/crates/truapi/src/runtime/contacts.rs index 65e7f5765..9c2ad5c14 100644 --- a/rust/crates/truapi/src/runtime/contacts.rs +++ b/rust/crates/truapi/src/runtime/contacts.rs @@ -136,6 +136,9 @@ impl ContactLabelPlacements { pub fn session_changed(&self, generation: u64, spawner: &crate::subscription::Spawner) { let placements: Vec<_> = self.by_runtime.lock() .values().cloned().collect(); + if placements.is_empty() { + return; + } spawner(Box::pin(async move { for placement in placements { placement.clear(Some(generation)).await; diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index f1ffa4ca2..aef863151 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -943,7 +943,9 @@ fn multi_picker_rejects_lookup_invalidation_and_session_change_during_confirmati let handle = truapi::latest::ContactHandle { bytes: handles.mint(&account) }; let cache = host.services.contact_handles.clone(); *contacts.after_lookup.lock() = Some(Box::new(move || cache.clear())); - assert_eq!(pick_many(&host, vec![handle]), Err(CallError::Cancelled)); + assert!(matches!(pick_many(&host, vec![handle]), Err(CallError::Domain( + HostContactsPickManyError::V1(truapi::latest::HostContactsPickManyError::Unknown { .. }) + )))); assert!(contacts.selected.lock().is_empty()); let session = host.test_session_state(); *contacts.after_pick.lock() = Some(Box::new(move || session.clear_session())); @@ -963,10 +965,12 @@ fn multi_picker_cancellation_cannot_confirm_a_late_selection() { let cx = CallContext::default(); let cancel = cx.cancel().clone(); *contacts.after_pick.lock() = Some(Box::new(move || cancel.cancel())); - assert_eq!(futures::executor::block_on(Contacts::pick_many( + assert!(matches!(futures::executor::block_on(Contacts::pick_many( &host, &cx, HostContactsPickManyRequest::V1(truapi::latest::HostContactsPickManyRequest { selected: vec![] }), - )), Err(CallError::Cancelled)); + )), Err(CallError::Domain(HostContactsPickManyError::V1( + truapi::latest::HostContactsPickManyError::Unknown { .. } + ))))); } #[test] @@ -998,6 +1002,28 @@ fn contact_labels_need_no_profile_grant_and_hide_missing_contact_availability() ]); } +#[test] +fn contact_label_lookup_failure_does_not_clear_the_surface() { + let mut contacts = AudienceContactsPlatform::new(vec![], crate::platform::HostContactsPick::Dismissed); + Arc::get_mut(&mut contacts).unwrap().directory = StubContactsPlatform::failing("store offline"); + let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); + let rect = truapi::latest::AvatarRect { x: 0, y: 0, width: 180, height: 24 }; + let result = futures::executor::block_on(Contacts::place_labels( + &host, &CallContext::default(), + HostContactsPlaceLabelsRequest::V1(truapi::latest::HostContactsPlaceLabelsRequest { + surface_width: 300, surface_height: 200, + slots: vec![truapi::latest::ContactLabelSlot { + slot: 0, handle: truapi::latest::ContactHandle { bytes: [0x42; 32] }, + rect, clip: rect, + }], + }), + )); + assert!(matches!(result, Err(CallError::Domain(HostContactsPlaceLabelsError::V1( + truapi::latest::HostContactsPlaceLabelsError::Unknown { .. } + ))))); + assert!(contacts.labels.lock().is_empty()); +} + #[test] fn contact_labels_are_cleared_if_the_session_changes_while_drawing() { let account = [10; 32]; From b4fa7eb0360b3056befe6624731bfd3337f17bb3 Mon Sep 17 00:00:00 2001 From: w Date: Fri, 2 Oct 2026 09:17:34 -0400 Subject: [PATCH 27/30] style(contacts): format native feature changes --- .changeset/profile-disclose.md | 77 +++-- README.md | 314 ++++++++---------- docs/rfcs/contacts-api.md | 103 +++--- js/packages/truapi-host/README.md | 109 +++--- .../truapi-host/src/adapter-support.ts | 6 +- rust/crates/truapi/RUNTIME.md | 183 +++++----- rust/crates/truapi/src/host_core.rs | 7 +- rust/crates/truapi/src/native/callbacks.rs | 4 +- rust/crates/truapi/src/native/errors.rs | 6 +- rust/crates/truapi/src/native/platform.rs | 69 +++- rust/crates/truapi/src/runtime.rs | 198 ++++++++--- rust/crates/truapi/src/runtime/contacts.rs | 65 ++-- rust/crates/truapi/src/runtime/services.rs | 3 +- .../crates/truapi/src/runtime/signing_host.rs | 8 +- rust/crates/truapi/src/runtime/tests.rs | 300 ++++++++++++----- 15 files changed, 821 insertions(+), 631 deletions(-) diff --git a/.changeset/profile-disclose.md b/.changeset/profile-disclose.md index ed9ee0531..09be47347 100644 --- a/.changeset/profile-disclose.md +++ b/.changeset/profile-disclose.md @@ -12,13 +12,13 @@ once through `userConfirmation.confirmPermission` with a new `ProfileDisclosure` This change includes the Chat relay. In legacy `ChatApps` mode the host sends the disclosure to every ready Chat v2 contact as a host-private app-scoped message and keeps, per contact, the newest frame their host sent back, withdrawals included, whatever order the chat product opens them in. Both live in wallet- and network-scoped core storage -(`ProfileDisclosure`, `ProfileReferencesReceived`). The host queues the disclosure when the chat product initializes or reconciles, in the -response to a Chat request in which a contact became ready, and, without delaying the call, as soon as `disclose` or -`retract` changes it while a Chat of the same wallet is open; the chat product still has to run to submit it. Delivery -is best effort: relayed references never take outbox room from other Chat traffic, and one that lapses unacknowledged -after a statement lifetime is signed again for a ready contact, at most three frames per contact and disclosure. Every -`disclose` call is a new disclosure, even with the reference already held: a profile whose record changed behind the -same reference is sent to the selected ready recipients again, with a fresh attempt count. +(`ProfileDisclosure`, `ProfileReferencesReceived`). The host queues the disclosure when the chat product initializes or +reconciles, in the response to a Chat request in which a contact became ready, and, without delaying the call, as soon +as `disclose` or `retract` changes it while a Chat of the same wallet is open; the chat product still has to run to +submit it. Delivery is best effort: relayed references never take outbox room from other Chat traffic, and one that +lapses unacknowledged after a statement lifetime is signed again for a ready contact, at most three frames per contact +and disclosure. Every `disclose` call is a new disclosure, even with the reference already held: a profile whose record +changed behind the same reference is sent to the selected ready recipients again, with a fresh attempt count. Add `profile.placeContactAvatars`. A chat App tells the host where it draws contacts' avatars (surface size and, per avatar, a slot id, peer identity, square rect and clip), and the host draws the photo and mood ring of each contact who @@ -26,45 +26,44 @@ shared a profile with it on its own layer. The core filters the placement to con them with their references and `sharedAt` (Unix ms of the contact's share) to the new `ProfilePlatform.placeContactAvatars(product, placed)` callback, and redraws the remembered placement when a reference arrives, is re-shared or is withdrawn; a larger `sharedAt` for the same reference tells the host its cached profile is -stale; it clears it when the connection goes away. -The product is answered `Ok` whoever shared; only a malformed placement (more than 64 slots, a surface side outside 1 to -16384, a non-square avatar or one outside 1 to 1024 a side, a repeated slot) is refused, and a host that cannot draw -answers `Unsupported`. A JS host that supplies a `profile` group must implement the callback; the Rust trait's default -draws nothing. +stale; it clears it when the connection goes away. The product is answered `Ok` whoever shared; only a malformed +placement (more than 64 slots, a surface side outside 1 to 16384, a non-square avatar or one outside 1 to 1024 a side, a +repeated slot) is refused, and a host that cannot draw answers `Unsupported`. A JS host that supplies a `profile` group +must implement the callback; the Rust trait's default draws nothing. Add `profile.ownStatus` and `profile.presentOwn`, and an optional `own` slot in version 2 of -`profile.placeContactAvatars`. A chat product can report whether its signed-in user has configured a profile and ask -the host to present it without receiving the bearer reference. The core fills the own slot from the user's disclosure -and hands it to the existing callback in the same replacement set as the contact avatars, redraws it when the user -discloses or retracts, and still reveals nothing per slot. Version 1 placements keep working unchanged. -The avatar regression suite also exercises version-1 response downgrading alongside the version-2 own-profile slot. +`profile.placeContactAvatars`. A chat product can report whether its signed-in user has configured a profile and ask the +host to present it without receiving the bearer reference. The core fills the own slot from the user's disclosure and +hands it to the existing callback in the same replacement set as the contact avatars, redraws it when the user discloses +or retracts, and still reveals nothing per slot. Version 1 placements keep working unchanged. The avatar regression +suite also exercises version-1 response downgrading alongside the version-2 own-profile slot. -Version 2 of `profile.disclose` adds explicit `ChatApps`, `App { productId }`, and -`Contacts { handles }` audiences. App-scoped and selected-contact personal grants coexist: personal grants are -host-renderable across products, never returned to them. All handles are verified against the host Contacts lookup -before committing the replacement; empty audiences configure only the user's own profile. Existing V1 calls retain -their app-scoped all-Chat behavior. Groups remain product-owned sets of opaque handles, not a new host group API. +Version 2 of `profile.disclose` adds explicit `ChatApps`, `App { productId }`, and `Contacts { handles }` audiences. +App-scoped and selected-contact personal grants coexist: personal grants are host-renderable across products, never +returned to them. All handles are verified against the host Contacts lookup before committing the replacement; empty +audiences configure only the user's own profile. Existing V1 calls retain their app-scoped all-Chat behavior. Groups +remain product-owned sets of opaque handles, not a new host group API. -Personal relay uses distinct Chat content 22 (scope 1) and wallet/network-scoped -`ProfilePersonalReferencesReceived` storage. App content 21 is unchanged. Durable revisions, separate scoped -watermarks and withdrawal tombstones prevent an older personal share delivered through another app from reviving a -withdrawn grant. Removing one audience does not revoke an overlapping grant in another scope. Delivery still requires -a ready authenticated Chat channel and a running transport product; Contacts membership alone creates neither. +Personal relay uses distinct Chat content 22 (scope 1) and wallet/network-scoped `ProfilePersonalReferencesReceived` +storage. App content 21 is unchanged. Durable revisions, separate scoped watermarks and withdrawal tombstones prevent an +older personal share delivered through another app from reviving a withdrawn grant. Removing one audience does not +revoke an overlapping grant in another scope. Delivery still requires a ready authenticated Chat channel and a running +transport product; Contacts membership alone creates neither. Version 2 of `profile.presentContact` accepts either a peer identity or a Contacts handle and hides profile availability, including host rendering failures. V1 retains its app-only lookup and errors, so it cannot probe new cross-app personal grants. Version 3 of `profile.placeContactAvatars` accepts the same selectors alongside the own slot; -V1/V2 placement bytes and replies remain compatible. Contacts-change notifications invalidate cached handle lookups -and refresh remembered avatars. App-specific references take precedence over personal ones; personal updates redraw -all affected wallet placements. -Personal revisions also advance the host-rendered freshness timestamp when a newer share arrives through an actor -whose clock is older, preventing a same-reference update from leaving stale cached profile contents. +V1/V2 placement bytes and replies remain compatible. Contacts-change notifications invalidate cached handle lookups and +refresh remembered avatars. App-specific references take precedence over personal ones; personal updates redraw all +affected wallet placements. Personal revisions also advance the host-rendered freshness timestamp when a newer share +arrives through an actor whose clock is older, preventing a same-reference update from leaving stale cached profile +contents. -Add `contacts.pickMany` with preselected opaque handles and explicit picked, dismissed, and no-contacts outcomes. -Add `contacts.placeLabels` so Apps can reserve host-rendered contact names without receiving those names or profile +Add `contacts.pickMany` with preselected opaque handles and explicit picked, dismissed, and no-contacts outcomes. Add +`contacts.placeLabels` so Apps can reserve host-rendered contact names without receiving those names or profile availability. The core validates bounded placements and wallet-scoped handles, refreshes labels after Contacts changes, -and releases them when the connection closes. Hosts without a label surface return `Unsupported`; Worker products -cannot place labels. Clearing a session serializes removal of its remembered contact labels with pending refreshes. -Host-side interruption returns a Contacts domain error, reserving wire `Cancelled` for a peer's explicit cancellation. -Failed directory lookups preserve the prior label surface and report a retryable error instead of clearing it as if -the contacts were missing. +and releases them when the connection closes. Hosts without a label surface return `Unsupported`; Worker products cannot +place labels. Clearing a session serializes removal of its remembered contact labels with pending refreshes. Host-side +interruption returns a Contacts domain error, reserving wire `Cancelled` for a peer's explicit cancellation. Failed +directory lookups preserve the prior label surface and report a retryable error instead of clearing it as if the +contacts were missing. diff --git a/README.md b/README.md index 06e1d735c..60b25ee6e 100644 --- a/README.md +++ b/README.md @@ -51,27 +51,21 @@ dependencies, and editor types. Use `/script --run` to rerun it or `/script --ed survive session cleanup. See the [`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) for setup and existing project scripts. Release checks install and typecheck the default SDK template against the public registry. -`truapi-host signing-host --session ` opens an interactive session and -restores or creates its signer. `/session ` switches to the saved account -or resumes an unfinished setup. In `exec` mode, `--session` selects the command's -session; inspection and clearing commands do not create accounts. A username -base such as `workbench` selects the most recently created local session with -that base; `workbench.42` selects that exact session. Creating an account from a -session name requires at least six lowercase ASCII letters after digits and -separators are omitted. A new `/session foo` fails immediately as too short; -existing saved accounts and aliases still restore normally. - -The signing host registers its built-in full and lite personhood keys when an -authorized product first lists `peopl.` (for example, -`peopl.paseo`). The first listing reads People-chain metadata; later listings -reuse the saved registrations, including after restart. Registration makes the -handles discoverable; proof creation still checks permission and ring membership. -The `listRingVrfKeys` example checks that both built-in keys are discoverable -under `peopl.paseo` on Paseo. - -Preimage lookups that miss the core's cache read the selected network's Bulletin -node through `bitswap_v1_get`. The CLI verifies the returned bytes against the -requested key and keeps missing lookups subscribed until the blob arrives. +`truapi-host signing-host --session ` opens an interactive session and restores or creates its signer. +`/session ` switches to the saved account or resumes an unfinished setup. In `exec` mode, `--session` selects the +command's session; inspection and clearing commands do not create accounts. A username base such as `workbench` selects +the most recently created local session with that base; `workbench.42` selects that exact session. Creating an account +from a session name requires at least six lowercase ASCII letters after digits and separators are omitted. A new +`/session foo` fails immediately as too short; existing saved accounts and aliases still restore normally. + +The signing host registers its built-in full and lite personhood keys when an authorized product first lists +`peopl.` (for example, `peopl.paseo`). The first listing reads People-chain metadata; later listings +reuse the saved registrations, including after restart. Registration makes the handles discoverable; proof creation +still checks permission and ring membership. The `listRingVrfKeys` example checks that both built-in keys are +discoverable under `peopl.paseo` on Paseo. + +Preimage lookups that miss the core's cache read the selected network's Bulletin node through `bitswap_v1_get`. The CLI +verifies the returned bytes against the requested key and keeps missing lookups subscribed until the blob arrives. Bulletin submissions read the nonce and runtime metadata from current best-block state, but bind the 64-block mortal signature to a finalized checkpoint. A best @@ -84,12 +78,12 @@ Product scripts and `truapi-host dev` use the same web API permission checks fro container through a blocking script tag in your existing browser. Scripts run in Bun and retain filesystem, environment and process access. -To build from source, run `make headless install` with stable Rust, the nightly pinned in `nightly-toolchain` with rustfmt, Node.js 22 or newer, and -Bun installed. The target installs missing workspace build tools and regenerates the Rust and TypeScript sources before -compiling. CI tests this command in both a fresh checkout and one with stale generated files, then runs a product script -through the installed CLI. Code generation and the workspace documentation check reject rustdoc warnings. These checks -are part of the required `CI Status` gate. CLI packaging tests also build an isolated runner and verify it outside the -source checkout. +To build from source, run `make headless install` with stable Rust, the nightly pinned in `nightly-toolchain` with +rustfmt, Node.js 22 or newer, and Bun installed. The target installs missing workspace build tools and regenerates the +Rust and TypeScript sources before compiling. CI tests this command in both a fresh checkout and one with stale +generated files, then runs a product script through the installed CLI. Code generation and the workspace documentation +check reject rustdoc warnings. These checks are part of the required `CI Status` gate. CLI packaging tests also build an +isolated runner and verify it outside the source checkout. ## Usage @@ -128,17 +122,17 @@ retries resume the same recipient/amount operation; incoming batches require dur before acknowledgment. Delivery acknowledgment and finalized clearing are separate states. Signing hosts can use the host-private `getNativeChatContacts()` directory for trusted Contacts UI. It restores -authenticated, ready peers from authorized native Chat products, scopes them to the active wallet and People chain, -and never exposes a product or SSO directory method. Actors opened on this version are durably indexed; historical -unindexed Chat products must be opened once before their peers can appear. Pairing hosts do not supply this directory. +authenticated, ready peers from authorized native Chat products, scopes them to the active wallet and People chain, and +never exposes a product or SSO directory method. Actors opened on this version are durably indexed; historical unindexed +Chat products must be opened once before their peers can appear. Pairing hosts do not supply this directory. Contacts trait 20 retains the single picker at method 0, adds `pickMany({ selected })` at method 1, and -`placeLabels({ surfaceWidth, surfaceHeight, slots })` at method 2. Multi-select confirmation returns only -wallet-scoped handles, including a confirmed empty selection; dismissal never edits the audience. Host-owned -labels show directory usernames or account fallbacks independently of Profile photos, without returning names, -accounts or per-slot availability. Selections and placements are bounded to 256 entries; unresolved initial -selections fail closed. Hosts implement `pickContacts(product, ContactSelection)` and -`placeContactLabels(product, PlacedContactLabels)` through the canonical native/WASM/worker callbacks. +`placeLabels({ surfaceWidth, surfaceHeight, slots })` at method 2. Multi-select confirmation returns only wallet-scoped +handles, including a confirmed empty selection; dismissal never edits the audience. Host-owned labels show directory +usernames or account fallbacks independently of Profile photos, without returning names, accounts or per-slot +availability. Selections and placements are bounded to 256 entries; unresolved initial selections fail closed. Hosts +implement `pickContacts(product, ContactSelection)` and `placeContactLabels(product, PlacedContactLabels)` through the +canonical native/WASM/worker callbacks. The [native Chat/main-purse RFC](docs/rfcs/native-chat-main-purse.md) specifies the method 12 request/response and compatibility contract, device eligibility, custody-before-ACK rule, and delivery versus clearing semantics. It is a @@ -198,9 +192,9 @@ navigation and requires `Notifications` for push delivery. Hosts preserve the us `Deny` choice; Rust owns one-use grants for Rust-backed executions. Android permission prompts belong to one request and close when it finishes or is cancelled, including cancellation while the app is backgrounded. -The shared Rust core asks blessed products (`peopl`, `dim2` and `stash`, -on every supported network) only for device permissions and legacy-account signing. -All other operations it handles bypass permission prompts and recorded decisions. +The shared Rust core asks blessed products (`peopl`, `dim2` and `stash`, on every supported network) only for device +permissions and legacy-account signing. All other operations it handles bypass permission prompts and recorded +decisions. ## Repository layout @@ -247,13 +241,10 @@ scripts/battery.sh Run the generated battery against both headless CLI h scripts/bundle-size.mjs Measure the JS and WASM the truapi-* packages ship, against a baseline ``` -The PolkaVM application runtime, GPU/UI wire contracts, and browser runtime -live in -[`paritytech/polkavm-host-runtime`](https://github.com/paritytech/polkavm-host-runtime). -Native hosts that need both runtimes link the optional `truapi-polkavm-host` -composition crate; the base `truapi` remains PolkaVM-free. Browser hosts -consume `@parity/polkavm-browser-runtime` directly; browser assets are not -shipped from this repository. +The PolkaVM application runtime, GPU/UI wire contracts, and browser runtime live in +[`paritytech/polkavm-host-runtime`](https://github.com/paritytech/polkavm-host-runtime). Native hosts that need both +runtimes link the optional `truapi-polkavm-host` composition crate; the base `truapi` remains PolkaVM-free. Browser +hosts consume `@parity/polkavm-browser-runtime` directly; browser assets are not shipped from this repository. Taking a screenshot opens **Report app issue** wherever the shake-opened Debug menu is, which is every build except the store submission: `DEBUG_TOOLS_ENABLED` on Android, false only for the `release` build type, and `TESTNET_FEATURE` on @@ -276,36 +267,27 @@ logs** remain available. See the [proc-macro guide](rust/crates/truapi-macros/README.md) for typed SSO handlers, their shared response envelope, and the macro implementation modules. -The Swift host adapter (the `TrUAPIHost` SPM package over the truapi -UniFFI core) lives under [`ios/truapi-host/`](ios/truapi-host), with its SPM -manifest at the repo root (`Package.swift`) so apps can consume it as a git-URL -dependency. The UniFFI bindings and the container bundle are gitignored build -outputs; `scripts/rebuild.sh` regenerates them along with the xcframework -(`make xcframework` + `make uniffi`); see -[`ios/truapi-host/README.md`](ios/truapi-host/README.md). -The container publishes the shared client and a temporary MessagePort adapter for -older SDKs. The adapter's removal is tracked in [#881](https://github.com/paritytech/host-rust-core/issues/881); -CLI and iframe MessagePort transports remain supported. -The [container permission boundary](js/container/README.md) documents the protected -operations and the built-ins that remain mutable for product compatibility. -Native bindings expose the canonical Rust domain and protocol value types; -native-only adapter types are limited to lifecycle and callback behavior. -Native hosts must create an app-private directory excluded from device backups -and pass its path as `HostRuntimeConfig.database_directory` (`databaseDirectory` -in Swift and Kotlin). The shared core opens SQLite once; product executions keep -their own callbacks and consent scope while sharing that database. The store is -not compiled into the browser WASM bundles. See the -[core database contract](rust/crates/truapi/RUNTIME.md#core-database). -On iOS, a wallet host that manages its own statement-store SSO session can call -`handleSsoRequest` (routes one decrypted remote message through the core, -returning a typed outcome: response bytes to post back, a disconnect marker, or -ignored; a `Cancel` returns at once, so the wallet passes it on without queueing -it behind the request it withdraws) and `prepareDisconnectRequest` (builds the SCALE-encoded wire message -for a wallet-initiated disconnect) on `TrUAPIHostRuntime`. Response posting and -session-record cleanup remain on the wallet side. -See the core's [inter-host SSO design](rust/crates/truapi/RUNTIME.md#inter-host-sso) -for typed handlers, canonical resource types, and consent bound to the signing session. -Product and SSO signing share canonical payloads and the one-byte `OptionBool` +The Swift host adapter (the `TrUAPIHost` SPM package over the truapi UniFFI core) lives under +[`ios/truapi-host/`](ios/truapi-host), with its SPM manifest at the repo root (`Package.swift`) so apps can consume it +as a git-URL dependency. The UniFFI bindings and the container bundle are gitignored build outputs; `scripts/rebuild.sh` +regenerates them along with the xcframework (`make xcframework` + `make uniffi`); see +[`ios/truapi-host/README.md`](ios/truapi-host/README.md). The container publishes the shared client and a temporary +MessagePort adapter for older SDKs. The adapter's removal is tracked in +[#881](https://github.com/paritytech/host-rust-core/issues/881); CLI and iframe MessagePort transports remain supported. +The [container permission boundary](js/container/README.md) documents the protected operations and the built-ins that +remain mutable for product compatibility. Native bindings expose the canonical Rust domain and protocol value types; +native-only adapter types are limited to lifecycle and callback behavior. Native hosts must create an app-private +directory excluded from device backups and pass its path as `HostRuntimeConfig.database_directory` (`databaseDirectory` +in Swift and Kotlin). The shared core opens SQLite once; product executions keep their own callbacks and consent scope +while sharing that database. The store is not compiled into the browser WASM bundles. See the +[core database contract](rust/crates/truapi/RUNTIME.md#core-database). On iOS, a wallet host that manages its own +statement-store SSO session can call `handleSsoRequest` (routes one decrypted remote message through the core, returning +a typed outcome: response bytes to post back, a disconnect marker, or ignored; a `Cancel` returns at once, so the wallet +passes it on without queueing it behind the request it withdraws) and `prepareDisconnectRequest` (builds the +SCALE-encoded wire message for a wallet-initiated disconnect) on `TrUAPIHostRuntime`. Response posting and +session-record cleanup remain on the wallet side. See the core's +[inter-host SSO design](rust/crates/truapi/RUNTIME.md#inter-host-sso) for typed handlers, canonical resource types, and +consent bound to the signing session. Product and SSO signing share canonical payloads and the one-byte `OptionBool` encoding for `with_signed_transaction`. ### JS Host SDKs @@ -320,26 +302,20 @@ tree-shakeable subpath entries: `createWorkerHostRuntime` shares the native core while `createProvider(product, callbacks)` binds platform callbacks to one product execution. The host UI disposes that execution's pending consent when its provider closes; wallet -authentication and storage remain core-owned. See the [host SDK](js/packages/truapi-host/README.md) for lifecycle details. +authentication and storage remain core-owned. See the [host SDK](js/packages/truapi-host/README.md) for lifecycle +details. ### Chain transport -A host that serves chain traffic itself embeds the `truapi-provider` crate: an -embedded smoldot light client plus a bundled chain-spec catalog, addressed by -genesis hash, so the host ships no chain specs and never refreshes them. The light -client holds at most 32 connections at once and refuses a `connect` past that, so a -consumer that leaks them fails instead of growing; closing one hands its slot back. -Connections to a remote node, which only the WASM build compiles, are not counted -against it. A light-client connection holds its requests until the chain first -syncs and then forwards them in order; chain-spec queries, statement-store and -Bitswap calls, and the `lifecycle_unstable_*` subscription that reports the sync are forwarded at -once. -Every artifact exposes the sync progress of a running chain (phase, peer count, -stall verdict) as a watch. -The crate -compiles to one binary artifact per platform, each exposing the same -`ChainProvider` contract, so a consumer needs neither a Rust toolchain nor a -dependency on the crate: +A host that serves chain traffic itself embeds the `truapi-provider` crate: an embedded smoldot light client plus a +bundled chain-spec catalog, addressed by genesis hash, so the host ships no chain specs and never refreshes them. The +light client holds at most 32 connections at once and refuses a `connect` past that, so a consumer that leaks them fails +instead of growing; closing one hands its slot back. Connections to a remote node, which only the WASM build compiles, +are not counted against it. A light-client connection holds its requests until the chain first syncs and then forwards +them in order; chain-spec queries, statement-store and Bitswap calls, and the `lifecycle_unstable_*` subscription that +reports the sync are forwarded at once. Every artifact exposes the sync progress of a running chain (phase, peer count, +stall verdict) as a watch. The crate compiles to one binary artifact per platform, each exposing the same +`ChainProvider` contract, so a consumer needs neither a Rust toolchain nor a dependency on the crate: - [`@parity/truapi-provider`](js/packages/truapi-provider) is the WASM build for browser and webview hosts, rebuilt by `make wasm` alongside the host bundle. @@ -356,25 +332,20 @@ encrypted. ### Wire debugger -[`@parity/truapi-debugger`](js/packages/truapi-debugger) is the consumer for the -payload-blind frame tap in `truapi`. The core streams raw SCALE frames out -of two choke points; the debugger correlates them into per-operation traces, -decodes envelopes and values behind a `TRUAPI_WIRE_SCHEMA_HASH` match, and renders -them through one of two mounts: +[`@parity/truapi-debugger`](js/packages/truapi-debugger) is the consumer for the payload-blind frame tap in `truapi`. +The core streams raw SCALE frames out of two choke points; the debugger correlates them into per-operation traces, +decodes envelopes and values behind a `TRUAPI_WIRE_SCHEMA_HASH` match, and renders them through one of two mounts: -- `startDebugServer(...)` is a standalone Bun WS+HTTP server on `127.0.0.1:9231` - that hosts dial into, so frames from any host reach one inspector. -- `createInAppDebugger(...)` mounts the same engine inside the host page, with no - server and no dial. +- `startDebugServer(...)` is a standalone Bun WS+HTTP server on `127.0.0.1:9231` that hosts dial into, so frames from + any host reach one inspector. +- `createInAppDebugger(...)` mounts the same engine inside the host page, with no server and no dial. All decoding lives in this package; `@parity/truapi` has no debug seam. Its -[README](js/packages/truapi-debugger/README.md) carries the endpoint list and the -per-host enablement recipe. +[README](js/packages/truapi-debugger/README.md) carries the endpoint list and the per-host enablement recipe. -`make debugger` brings up the inspector on `:9231` alongside a dot.li host and the -playground. It builds the host with `NODE_ENV=development` on purpose: the dial -sits behind `import.meta.env.DEV`, which a production bundle replaces with `false`, -so `make dev` leaves the board empty with no error. +`make debugger` brings up the inspector on `:9231` alongside a dot.li host and the playground. It builds the host with +`NODE_ENV=development` on purpose: the dial sits behind `import.meta.env.DEV`, which a production bundle replaces with +`false`, so `make dev` leaves the board empty with no error. ## How it works @@ -411,14 +382,12 @@ make wasm # rebuild truapi WASM artifacts under js/packages/truapi-host/dist CI regenerates the shared bindings before building and testing both npm packages, so generated client and host callback changes are checked together. -CLI transcript tests share process-wide UI output. Match captured events by -request identity rather than queue position: other tests may emit unrelated -logs through the installed UI while the suite runs in parallel. +CLI transcript tests share process-wide UI output. Match captured events by request identity rather than queue position: +other tests may emit unrelated logs through the installed UI while the suite runs in parallel. -The native `truapi-host` utility runs pairing and signing hosts against the real -SSO transport for local end-to-end work. See [Install the CLI](#install-the-cli) -to get it, and the [`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) -for its commands and controls. +The native `truapi-host` utility runs pairing and signing hosts against the real SSO transport for local end-to-end +work. See [Install the CLI](#install-the-cli) to get it, and the +[`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) for its commands and controls. CLI reserved identities follow the selected network's dotNS suffix. Old account and pairing stores are left unused as the CLI starts fresh under its [versioned state directory](rust/crates/truapi-host-cli/README.md#state-directory). @@ -460,30 +429,22 @@ the host already live. The product reaches it through a development-only `.md`, which names the range, the -pull requests in it, and what has to be done to finish the work. Completing that -pull request means running the command above and deleting the file. +Drift is picked up on a schedule. `.github/workflows/backport-host.yml` opens a pull request carrying a single +`BACKPORT-.md`, which names the range, the pull requests in it, and what has to be done to finish the work. +Completing that pull request means running the command above and deleting the file. ### Working on the iOS host @@ -555,21 +510,17 @@ until they exist. Generate them once: make ios-bootstrap ``` -Then open `hosts/ios/polkadot-app.xcodeproj`. Rerun it after changing anything -the bindings are generated from, which is the `truapi` or `truapi-provider` -crates. `SIM_ONLY=1` halves it by skipping -the device slice, which is enough for Simulator but not for an archive. - -Because the app builds against the core in this tree, a core change that breaks -it fails here rather than at the next version bump. Every push to main runs the -jobs below, and so does a pull request touching the app, or the crates its -bindings come from, once it is labelled `ios-simulator-build`. They are macOS -jobs, so a pull request without the label runs none of them. CI's -`iOS package (Swift + WebKit)` job still compiles the TrUAPIHost package against -the core on a pull request touching `ios/` or the core crates, but the app itself -is compiled before merge only with the label. The first time a pull request -touches the iOS or Android app, `build-label-hint.yml` comments with the labels -that build it: `ios-simulator-build`, `ios-device-build` and `android-device-build`. +Then open `hosts/ios/polkadot-app.xcodeproj`. Rerun it after changing anything the bindings are generated from, which is +the `truapi` or `truapi-provider` crates. `SIM_ONLY=1` halves it by skipping the device slice, which is enough for +Simulator but not for an archive. + +Because the app builds against the core in this tree, a core change that breaks it fails here rather than at the next +version bump. Every push to main runs the jobs below, and so does a pull request touching the app, or the crates its +bindings come from, once it is labelled `ios-simulator-build`. They are macOS jobs, so a pull request without the label +runs none of them. CI's `iOS package (Swift + WebKit)` job still compiles the TrUAPIHost package against the core on a +pull request touching `ios/` or the core crates, but the app itself is compiled before merge only with the label. The +first time a pull request touches the iOS or Android app, `build-label-hint.yml` comments with the labels that build it: +`ios-simulator-build`, `ios-device-build` and `android-device-build`. - `build`, a DevCI compile, failing on any build warning the committed baseline does not already have - `test`, the unit test suite @@ -616,15 +567,12 @@ Two workflows deliver through Firebase App Distribution, which reaches a named t link. That matters beyond convenience: these builds carry configuration that should not be public, so attaching them to a release is not an option. -`android-nightly.yml` runs daily at 22:00 UTC, two hours after the iOS -nightly starts, so the two never overlap. Each announcement lists the pull -requests the build carries, with breaking changes, the titles carrying `!`, -listed first and marked `Breaking:`. Both nightlies skip a scheduled night -when `main` has not moved past what their last successful run built. `android-debug-distribution.yml` runs -when a pull request merges to `main`, and answers what `main` does right now. -It builds the merge commit rather than the pull request's merge preview, which -is computed while the request is open and would otherwise ship a tree missing -whatever landed first. +`android-nightly.yml` runs daily at 22:00 UTC, two hours after the iOS nightly starts, so the two never overlap. Each +announcement lists the pull requests the build carries, with breaking changes, the titles carrying `!`, listed first and +marked `Breaking:`. Both nightlies skip a scheduled night when `main` has not moved past what their last successful run +built. `android-debug-distribution.yml` runs when a pull request merges to `main`, and answers what `main` does right +now. It builds the merge commit rather than the pull request's merge preview, which is computed while the request is +open and would otherwise ship a tree missing whatever landed first. Both authenticate by federation. The run proves its identity with its OIDC token and receives a short lived credential, so no long lived key for that project is stored here. Both check the delivery target before building, since an hour is diff --git a/docs/rfcs/contacts-api.md b/docs/rfcs/contacts-api.md index 36fac6f24..b20ad3952 100644 --- a/docs/rfcs/contacts-api.md +++ b/docs/rfcs/contacts-api.md @@ -8,27 +8,25 @@ status: draft ## Summary -_How the implemented pieces fit together is in -[Contacts Pick, End to End](../design/contacts-pick-end-to-end.md)._ +_How the implemented pieces fit together is in [Contacts Pick, End to End](../design/contacts-pick-end-to-end.md)._ -A product asks the Host to let the user pick one or more contacts. The Host renders the -picker from its contact directory and returns opaque handles, never the list, names or -accounts. The handle is not an address: the core resolves it when building a transaction. -Host-owned name labels let users recognize selected handles without sharing the names -or requiring a Profile photo. +A product asks the Host to let the user pick one or more contacts. The Host renders the picker from its contact +directory and returns opaque handles, never the list, names or accounts. The handle is not an address: the core resolves +it when building a transaction. Host-owned name labels let users recognize selected handles without sharing the names or +requiring a Profile photo. ## Motivation -Each user has a different alias and account per context, so no handle identifies a person across -products, and a contact list is how a user keeps that private notebook. Products cannot use any of it -today, so users paste raw keys. But "send this NFT to a friend" needs one recipient the user chose, -not the address book — so this exposes the interaction rather than the list. +Each user has a different alias and account per context, so no handle identifies a person across products, and a contact +list is how a user keeps that private notebook. Products cannot use any of it today, so users paste raw keys. But "send +this NFT to a friend" needs one recipient the user chose, not the address book — so this exposes the interaction rather +than the list. ## Approach -Contacts come from the chat lists the Host's chat extensions hold; Hosts keep their own schema -and no new address book is imposed. A Host **renders the picker itself** and resolves handles on request, so names never cross to the product — which matters because a Host's only name for a contact -is often a globally correlatable People-chain username. +Contacts come from the chat lists the Host's chat extensions hold; Hosts keep their own schema and no new address book +is imposed. A Host **renders the picker itself** and resolves handles on request, so names never cross to the product — +which matters because a Host's only name for a contact is often a globally correlatable People-chain username. ```rust enum ContactPickOutcome { @@ -40,59 +38,62 @@ enum ContactPickOutcome { fn host_contacts_pick() -> Result; ``` -Three outcomes because the retry decision differs: `Dismissed` is worth offering again, `NoContacts` -is not, and a Host with no picker answers `Unsupported`. No permission is requested — the user -selecting a contact is the consent. +Three outcomes because the retry decision differs: `Dismissed` is worth offering again, `NoContacts` is not, and a Host +with no picker answers `Unsupported`. No permission is requested — the user selecting a contact is the consent. -The handle is one value per contact, the same in every product and on every Host of this user, keyed -on the user's entropy so no product can turn it back into an account. It is not an address: a product -names it as the recipient and the core substitutes the account when it builds the transaction. A -product-scoped address is not derivable at all, which is why the handle is resolvable rather than -directly usable. +The handle is one value per contact, the same in every product and on every Host of this user, keyed on the user's +entropy so no product can turn it back into an account. It is not an address: a product names it as the recipient and +the core substitutes the account when it builds the transaction. A product-scoped address is not derivable at all, which +is why the handle is resolvable rather than directly usable. ### Multi-select audiences -Trait 20 method 0 remains `pick`. Method 1, `pickMany({ selected })`, edits a complete -selection of at most 256 handles. The core deduplicates and resolves the initial -selection before opening the picker; any unresolved handle rejects the whole request. -The host callback `pickContacts(product, ContactSelection { selected })` receives -accounts only inside the trusted host boundary. Confirming an empty selection returns -`Picked { handles: [] }`; closing the picker returns `Dismissed`. Session or directory -invalidation during resolution or confirmation cancels the change. +Trait 20 method 0 remains `pick`. Method 1, `pickMany({ selected })`, edits a complete selection of at most 256 handles. +The core deduplicates and resolves the initial selection before opening the picker; any unresolved handle rejects the +whole request. The host callback `pickContacts(product, ContactSelection { selected })` receives accounts only inside +the trusted host boundary. Confirming an empty selection returns `Picked { handles: [] }`; closing the picker returns +`Dismissed`. Session or directory invalidation during resolution or confirmation cancels the change. ### Host-owned contact labels -Method 2, `placeLabels({ surfaceWidth, surfaceHeight, slots })`, replaces at most 256 -name rectangles. Each slot supplies `{ slot, handle, rect, clip }`, reusing `AvatarRect`. -The host resolves handles and draws directory usernames, or account fallbacks, on its -own layer. Names do not depend on Profile disclosure. Missing contacts leave no label -and produce the same success response; products never receive names or availability. -Surfaces and rectangle sides are bounded to 16384 units, clip sides may be zero, and -slot ids must be unique. Empty slots, connection teardown and session changes clear -the layer. On same-wallet directory invalidation, the host clears stale names and -refreshes the latest live placement without another product request. - +Method 2, `placeLabels({ surfaceWidth, surfaceHeight, slots })`, replaces at most 256 name rectangles. Each slot +supplies `{ slot, handle, rect, clip }`, reusing `AvatarRect`. The host resolves handles and draws directory usernames, +or account fallbacks, on its own layer. Names do not depend on Profile disclosure. Missing contacts leave no label and +produce the same success response; products never receive names or availability. Surfaces and rectangle sides are +bounded to 16384 units, clip sides may be zero, and slot ids must be unique. Empty slots, connection teardown and +session changes clear the layer. On same-wallet directory invalidation, the host clears stale names and refreshes the +latest live placement without another product request. ## Trade-offs - A host that serves no picker answers `Unsupported`, which a product cannot retry its way out of. - `NoContacts` reveals whether the user has any contacts — zero-or-not, never a count. - No product-rendered contact directory: every selection is a host-owned user interaction. -- Dropped: returning the list scoped per product (`display_name` was a correlator no scoping fixed, - and it needed a permission over the whole social graph); per-product handles (forfeit a durable - shared id, break under contact sync); returning the chat account (transactable, but a global - identifier any two products can join on); an unkeyed handle, or one keyed on the root account key - (recoverable by hashing enumerable accounts). +- Dropped: returning the list scoped per product (`display_name` was a correlator no scoping fixed, and it needed a + permission over the whole social graph); per-product handles (forfeit a durable shared id, break under contact sync); + returning the chat account (transactable, but a global identifier any two products can join on); an unkeyed handle, or + one keyed on the root account key (recoverable by hashing enumerable accounts). ## Substitution at signing -A product declares the handles its call names, on the transaction payload, and the Host replaces exactly those 32-byte runs with the accounts they resolve to. It declares them rather than passing an offset because an offset is a number the product computes about its own encoding and gets wrong silently, while a declared handle is either in the call or it is not: a Host that cannot find one refuses, rather than signing a call that names somebody else. A handle no contact matches refuses the same way, which is the only revocation this API has. The core sends the Host only the handles it has not cached, with the key they were minted under; the Host answers an account per handle and the core re-hashes each one, so a wrong answer refuses rather than pays. A Host empties the cache by signalling that its contacts changed. The signed call returns to the product with the real account in it, so a call naming contacts always asks the user, even under an auto-signing grant; a handle in the call that is not declared refuses rather than pays an address nobody holds. `contacts` never crosses to the signing host: the pairing Host relays the substituted call in the existing SSO shape, so host-papp and deployed wallets are unaffected. - -Substitution happens before the confirmation, so the signing overlay is drawn from a call that names an account the Host can put a name to. That is what closes the display gap for the flow that matters: a product renders a neutral chip, and the user sees who they are paying in trusted UI at the moment of consent. +A product declares the handles its call names, on the transaction payload, and the Host replaces exactly those 32-byte +runs with the accounts they resolve to. It declares them rather than passing an offset because an offset is a number the +product computes about its own encoding and gets wrong silently, while a declared handle is either in the call or it is +not: a Host that cannot find one refuses, rather than signing a call that names somebody else. A handle no contact +matches refuses the same way, which is the only revocation this API has. The core sends the Host only the handles it has +not cached, with the key they were minted under; the Host answers an account per handle and the core re-hashes each one, +so a wrong answer refuses rather than pays. A Host empties the cache by signalling that its contacts changed. The signed +call returns to the product with the real account in it, so a call naming contacts always asks the user, even under an +auto-signing grant; a handle in the call that is not declared refuses rather than pays an address nobody holds. +`contacts` never crosses to the signing host: the pairing Host relays the substituted call in the existing SSO shape, so +host-papp and deployed wallets are unaffected. + +Substitution happens before the confirmation, so the signing overlay is drawn from a call that names an account the Host +can put a name to. That is what closes the display gap for the flow that matters: a product renders a neutral chip, and +the user sees who they are paying in trusted UI at the moment of consent. ## Recognition outside signing -A product holds only handles and reserves rectangles for `placeLabels`. The host -draws names in those rectangles without returning a global correlator. Profile avatar -slots remain separate and photo-only, so users can recognize a contact even when that -contact has never shared a profile. +A product holds only handles and reserves rectangles for `placeLabels`. The host draws names in those rectangles without +returning a global correlator. Profile avatar slots remain separate and photo-only, so users can recognize a contact +even when that contact has never shared a profile. diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index d68ac1c4a..ae3ae069b 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -27,8 +27,8 @@ which is what lets the test host hold keys and answer resource allocation as gra browser wallet instead needs a web bundle built with `--no-default-features --features wasm-signing-host`, without `test-host`. That enables native signing and wallet administration without the testing-only allocation shortcuts. Build that wallet variant with `npm run build:wasm -- --web-only --signing-host`. The default web build remains pairing-only. -Run the package tests against the generated web/testing bundles, then exercise the consuming host's real worker with -the selected variant. `ProductRuntimeConfig` configures the pairing host and requires no network suffix. The signing +Run the package tests against the generated web/testing bundles, then exercise the consuming host's real worker with the +selected variant. `ProductRuntimeConfig` configures the pairing host and requires no network suffix. The signing constructor's configuration requires `runtimeConfig.networkSuffix` in addition: the bare TLD (`dot`, `paseo`, or `testnet`) matching the People chain and the wallet's onboarding configuration. @@ -82,11 +82,11 @@ The optional callback receives `LocalIdentityProgress` (exported from `@parity/t `authenticating`, `submitting`, `confirming`, or `retrying` with a chain-read `error`. Stages reflect actual work, not elapsed-time estimates. `confirming` means the backend accepted the request, not that the username is owned yet; only the resolved promise confirms ownership. A retry does not submit another registration. Observer exceptions do not -interrupt the operation, and settled or disposed requests receive no further progress. -Both methods return `LocalIdentity` (exported from `@parity/truapi-host/web`): the canonical lowercase `0x`-prefixed -`identityAccountId` and an optional verified `liteUsername`. The backend must allow the worker's origin, or the host -must provide an approved same-origin proxy. Secret material stays in the signing runtime. Disconnecting or replacing the -local activation invalidates an in-flight identity operation; concurrent identity operations are rejected. +interrupt the operation, and settled or disposed requests receive no further progress. Both methods return +`LocalIdentity` (exported from `@parity/truapi-host/web`): the canonical lowercase `0x`-prefixed `identityAccountId` and +an optional verified `liteUsername`. The backend must allow the worker's origin, or the host must provide an approved +same-origin proxy. Secret material stays in the signing runtime. Disconnecting or replacing the local activation +invalidates an in-flight identity operation; concurrent identity operations are rejected. ### Read-only wallet allowance inspection @@ -197,45 +197,44 @@ reading as usable. Omit it and a stored grant answers on its own. replacement, and `removePocketCard` takes one out. The host owns the collection: removing an absent card succeeds, and a card the host pins is refused with `Privileged`. -`profile.presentProfile` shows the profile a product references in host-owned UI and resolves once it is shown, not -when the user dismisses it. The reference is a bearer capability: the host fetches, decrypts and renders it, and the +`profile.presentProfile` shows the profile a product references in host-owned UI and resolves once it is shown, not when +the user dismisses it. The reference is a bearer capability: the host fetches, decrypts and renders it, and the profile's bytes never return to the product. The core forwards only references that are non-empty, at most 2048 bytes and printable ASCII without whitespace; parsing the format is the host's. `profile.presentContactProfile(product, presented)` shows the profile a Chat contact shared when a chat product calls `profile.presentContact`. `presented` carries the `reference`, the `peerIdentity` of the contact whose authenticated Chat device delivered it, the `sharedAt` freshness timestamp (`bigint`) and, when the core knows it, the contact's -`username`, so the drawer can say who shared it rather than which product asked. The username is the one the core's -Chat roster verified for that contact, else the contact's verified dotNS name, looked up for at most 2 seconds; never a -name from the product. Without one, name the contact generically, never by address. It names who sent the reference, -not whose profile it is: the record is not signed by its owner, and a contact can forward someone else's. Same contract -as `presentProfile` otherwise. A `profile` group without it, from a host built before it, has contacts' profiles -presented through `presentProfile`. +`username`, so the drawer can say who shared it rather than which product asked. The username is the one the core's Chat +roster verified for that contact, else the contact's verified dotNS name, looked up for at most 2 seconds; never a name +from the product. Without one, name the contact generically, never by address. It names who sent the reference, not +whose profile it is: the record is not signed by its owner, and a contact can forward someone else's. Same contract as +`presentProfile` otherwise. A `profile` group without it, from a host built before it, has contacts' profiles presented +through `presentProfile`. `profile.placeContactAvatars(product, placed)` draws contacts' avatars over a chat product. `placed` carries the product's surface size and, per avatar, the product's `slot` id, a square `rect`, the `clip` region it is cut to, all in surface units (framebuffer pixels for a PolkaVM product, CSS pixels of the viewport for a web product), and the `reference` that contact disclosed, so the host can draw their photo and mood ring, with a `sharedAt` freshness token (`bigint`). Contact tokens use Unix ms, advanced monotonically for personal revisions across relay actors; the own -avatar uses the disclosure revision, not a date. A changed token invalidates cached contents. Each call replaces what was -drawn for the product; an empty `avatars` clears it. The core calls it again with the same geometry when a contact +avatar uses the disclosure revision, not a date. A changed token invalidates cached contents. Each call replaces what +was drawn for the product; an empty `avatars` clears it. The core calls it again with the same geometry when a contact shares, re-shares or withdraws a profile, and with no avatars when the product's connection goes away. Draw on a layer -the product cannot -read that lets pointer input through, and never tell the product what was drawn. The host runtimes take -`RequiredHostCallbacks`, so a `profile` group implements it and `presentContactProfile` alongside `presentProfile`. +the product cannot read that lets pointer input through, and never tell the product what was drawn. The host runtimes +take `RequiredHostCallbacks`, so a `profile` group implements it and `presentContactProfile` alongside `presentProfile`. `profile.disclose` needs no `profile` group, but the first call from a product asks the user through `userConfirmation.confirmPermission` with a `ProfileDisclosure` review naming that product. V1 shares app-scoped references with every ready Chat contact; V2 can select apps or opaque Contacts handles. Personal grants are -host-renderable across recipient apps. The answer is kept like any other permission, as `ProfileDisclosure`. -Audience mutations currently reuse that product-level consent. A host that cannot render the -review should reject the call rather than answer `Deny`: the product is refused, but no refusal is remembered. +host-renderable across recipient apps. The answer is kept like any other permission, as `ProfileDisclosure`. Audience +mutations currently reuse that product-level consent. A host that cannot render the review should reject the call rather +than answer `Deny`: the product is refused, but no refusal is remembered. `presentContact` V2 accepts peer or Contacts-handle selectors and hides sharing availability; V1 remains app-only. `placeContactAvatars` V3 accepts those selectors alongside the V2 own slot. V1/V2 placement bytes remain compatible. -Hosts must call `notifyContactsChanged()` after directory changes so stale handle resolution and overlays clear. -These APIs do not create a Chat channel or a group editor. See the -[Profile RFC](../../../docs/rfcs/profile-disclosure.md) for audience, transport and withdrawal semantics. +Hosts must call `notifyContactsChanged()` after directory changes so stale handle resolution and overlays clear. These +APIs do not create a Chat channel or a group editor. See the [Profile RFC](../../../docs/rfcs/profile-disclosure.md) for +audience, transport and withdrawal semantics. Under `createWebWorkerPairingHostRuntime` the presence of each optional group is reported to the worker in its `init` message, so the core sees the same capability set on both sides of the boundary. @@ -312,44 +311,38 @@ The index crosses as a SCALE-encoded `DerivationIndex`, the same value a review code behind it stays core-owned and a host never reconstructs it. `productAccountAddress` applies the prefix host-spec C.6 fixes, rather than leaving each host to choose one. -The optional `contacts` group resolves handles through `contacts({ handleKey, handles })`: -one entry per handle, in order, the account or `undefined`. `pickContact` draws a single -picker and returns the chosen account. `pickContacts(product, { selected })` edits a -complete selection of at most 256 resolved accounts, returning `Picked { accounts }`, -`Dismissed`, or `NoContacts`. A confirmed empty array is `Picked`, not dismissal. -Missing picker callbacks answer `Unsupported`. -A contact's handle is BLAKE2b-256 keyed with `handleKey` over its 32-byte -account (`blake2b(account, { key: handleKey, dkLen: 32 })` in `@noble/hashes`). -The core re-checks every account returned. It caches what it resolves, so call -`notifyContactsChanged()` whenever a contact is removed or blocked. Omit blocked +The optional `contacts` group resolves handles through `contacts({ handleKey, handles })`: one entry per handle, in +order, the account or `undefined`. `pickContact` draws a single picker and returns the chosen account. +`pickContacts(product, { selected })` edits a complete selection of at most 256 resolved accounts, returning +`Picked { accounts }`, `Dismissed`, or `NoContacts`. A confirmed empty array is `Picked`, not dismissal. Missing picker +callbacks answer `Unsupported`. A contact's handle is BLAKE2b-256 keyed with `handleKey` over its 32-byte account +(`blake2b(account, { key: handleKey, dkLen: 32 })` in `@noble/hashes`). The core re-checks every account returned. It +caches what it resolves, so call `notifyContactsChanged()` whenever a contact is removed or blocked. Omit blocked contacts from both. See the contacts RFC (`docs/rfcs/contacts-api.md`). -`placeContactLabels(product, placed)` receives surface dimensions and -`labels: [{ slot, account, rect, clip }]`. Draw names from the host's contact directory, -using an account fallback when no username exists. Profile-photo absence must not -hide a name. Keep this UI host-owned: return no label or per-slot availability. -Return `true` when the host supports label placement, even when no contact resolves. -Return `false` when that UI is unsupported; the adapter supplies this answer when -the callback is omitted. This capability acknowledgment never reports individual -contact availability. Background Workers are denied label placement. -Empty placements clear the previous names and cancel queued refreshes. Clear names -and cancel pending work on frame load, navigation or disconnect. On same-wallet -directory invalidation, clear stale names and refresh the latest live placement -without waiting for the product to resend it. The core serializes placements per -connection and rejects selections from changed sessions. +`placeContactLabels(product, placed)` receives surface dimensions and `labels: [{ slot, account, rect, clip }]`. Draw +names from the host's contact directory, using an account fallback when no username exists. Profile-photo absence must +not hide a name. Keep this UI host-owned: return no label or per-slot availability. Return `true` when the host supports +label placement, even when no contact resolves. Return `false` when that UI is unsupported; the adapter supplies this +answer when the callback is omitted. This capability acknowledgment never reports individual contact availability. +Background Workers are denied label placement. Empty placements clear the previous names and cancel queued refreshes. +Clear names and cancel pending work on frame load, navigation or disconnect. On same-wallet directory invalidation, +clear stale names and refresh the latest live placement without waiting for the product to resend it. The core +serializes placements per connection and rejects selections from changed sessions. Browser signing hosts can back this UI with `runtime.getNativeChatContacts()`. It returns -`{ walletPublicKey, genesisHash, contacts: [{ peerIdentity, username? }] }` to trusted host code only. -The directory restores encrypted native Chat actors, checks their current authorization, includes only authenticated -ready peers, and deduplicates identities. Conflicting verified names are omitted. It is not a product or SSO API; -pairing hosts reject it. Bind the result to the active signing public key and People genesis, never to a username. -Native departures/revocations remove readiness; product-private block lists are not a separate directory source. +`{ walletPublicKey, genesisHash, contacts: [{ peerIdentity, username? }] }` to trusted host code only. The directory +restores encrypted native Chat actors, checks their current authorization, includes only authenticated ready peers, and +deduplicates identities. Conflicting verified names are omitted. It is not a product or SSO API; pairing hosts reject +it. Bind the result to the active signing public key and People genesis, never to a username. Native +departures/revocations remove readiness; product-private block lists are not a separate directory source. Actors are indexed when opened. Historical unindexed products must be opened once on the upgraded host; private storage -has no enumeration API. Directory reads share native commit gates, and native state/session/permission changes invalidate -cached handles. A browser adapter must also cancel pending picker/lookup work when its wallet, network, provider or -directory generation changes. Provider-scoped `contacts` callbacks control that provider's UI; absent overrides inherit -the runtime-wide Contacts adapter. Keep the runtime-wide source alive for host-owned rendering until the owner closes. +has no enumeration API. Directory reads share native commit gates, and native state/session/permission changes +invalidate cached handles. A browser adapter must also cancel pending picker/lookup work when its wallet, network, +provider or directory generation changes. Provider-scoped `contacts` callbacks control that provider's UI; absent +overrides inherit the runtime-wide Contacts adapter. Keep the runtime-wide source alive for host-owned rendering until +the owner closes. ## Generated WASM artefacts diff --git a/js/packages/truapi-host/src/adapter-support.ts b/js/packages/truapi-host/src/adapter-support.ts index 07cae4ea6..e067fe0d5 100644 --- a/js/packages/truapi-host/src/adapter-support.ts +++ b/js/packages/truapi-host/src/adapter-support.ts @@ -26,9 +26,11 @@ export function contactsHostAdapter( if (host === undefined) return undefined; return { contacts: (lookup) => host.contacts(lookup), - pickContact: (product) => host.pickContact?.(product) ?? Promise.resolve({ tag: "Unsupported" }), + pickContact: (product) => + host.pickContact?.(product) ?? Promise.resolve({ tag: "Unsupported" }), pickContacts: (product, selection) => - host.pickContacts?.(product, selection) ?? Promise.resolve({ tag: "Unsupported" }), + host.pickContacts?.(product, selection) ?? + Promise.resolve({ tag: "Unsupported" }), placeContactLabels: (product, placed) => host.placeContactLabels?.(product, placed) ?? Promise.resolve(false), }; diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 605d3e32e..f5cca70ff 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -4,8 +4,7 @@ _Runtime core for TrUAPI: dispatcher, protocol frames, SCALE-coded wire envelope ## What the runtime is for -The `runtime` feature of `truapi` turns trait implementations of the -protocol API into a working host. It owns: +The `runtime` feature of `truapi` turns trait implementations of the protocol API into a working host. It owns: - the [`ProtocolMessage`] wire envelope and SCALE codec - the [`Dispatcher`] that routes incoming frames to per-method handlers @@ -206,16 +205,13 @@ resolve it through the host's `PermissionStatusHost`, so an OS refusal reads as stored product decision is never overwritten by it. Remote, identity-disclosure and account-access decisions have no OS gate. -The embedder builds a role handle, `PairingHostRuntime::new(...)` or -`SigningHostRuntime::new(...)`, then calls `product_runtime(product, sink)` for -each product connection. Role-specific operations live only on the matching handle: -`cancel_pairing`, `notify_session_store_changed`, `activate_stored_session`, -`activate_external_session`, and `reset_session_state` on the pairing handle, -`activate_local_session` on the signing handle. Both handles expose -`clear_product_state` to revoke one product's capability material without -touching the session or other products, and `notify_contacts_changed` to drop -the contact handles the core cached once a contact is removed or blocked. Calling the wrong operation is -a compile error, not a runtime `Unavailable`. +The embedder builds a role handle, `PairingHostRuntime::new(...)` or `SigningHostRuntime::new(...)`, then calls +`product_runtime(product, sink)` for each product connection. Role-specific operations live only on the matching handle: +`cancel_pairing`, `notify_session_store_changed`, `activate_stored_session`, `activate_external_session`, and +`reset_session_state` on the pairing handle, `activate_local_session` on the signing handle. Both handles expose +`clear_product_state` to revoke one product's capability material without touching the session or other products, and +`notify_contacts_changed` to drop the contact handles the core cached once a contact is removed or blocked. Calling the +wrong operation is a compile error, not a runtime `Unavailable`. `SigningHostConfig.network_suffix` is the network's bare dotNS TLD (`dot`, `paseo`, or `testnet`). The shell supplies it alongside the chain genesis hashes from the same network configuration used by wallet onboarding. It must match the @@ -254,15 +250,12 @@ responsibility for secure unlock. ### Core database -Native signing hosts (iOS, Android, the CLI) give the core a directory for its -own SQLite database (`store` module, bundled SQLite through `rusqlite` and -`async-sqlite`). The runtime opens `core.sqlite3` there at startup, so a -missing or unwritable directory stops it from starting. Keep the directory out -of device backups: it holds durable-transaction state that must not be restored -onto another device. The required `HostRuntimeConfig.database_directory` sets -it on iOS and Android, `SigningHostRuntime::set_core_db` on any other embedder, -and `core_database_status()` reports the SQLite version, schema version and -path. Web hosts do not compile the store. +Native signing hosts (iOS, Android, the CLI) give the core a directory for its own SQLite database (`store` module, +bundled SQLite through `rusqlite` and `async-sqlite`). The runtime opens `core.sqlite3` there at startup, so a missing +or unwritable directory stops it from starting. Keep the directory out of device backups: it holds durable-transaction +state that must not be restored onto another device. The required `HostRuntimeConfig.database_directory` sets it on iOS +and Android, `SigningHostRuntime::set_core_db` on any other embedder, and `core_database_status()` reports the SQLite +version, schema version and path. Web hosts do not compile the store. ### The two roles @@ -304,12 +297,10 @@ requests use the canonical `truapi::latest::AllocatableResource` type. Signing u Product-scoped VRF requests use `ProductRequest

` to attach the caller to a canonical payload. Both product and SSO signing encode `with_signed_transaction` with the one-byte `OptionBool` codec. -A pairing host that stops waiting because its caller withdrew the request sends -a `Cancel` naming it, when that request is still the newest on the session's -request channel. The responder reads statements while it serves a request, so a -`Cancel` fires the running request's token or stops a queued one from starting; -a withdrawn request posts no response. See the -[SSO request cancellation RFC](../../../docs/rfcs/sso-request-cancellation.md). +A pairing host that stops waiting because its caller withdrew the request sends a `Cancel` naming it, when that request +is still the newest on the session's request channel. The responder reads statements while it serves a request, so a +`Cancel` fires the running request's token or stops a queued one from starting; a withdrawn request posts no response. +See the [SSO request cancellation RFC](../../../docs/rfcs/sso-request-cancellation.md). When a device finishes pairing, the signing host reports it to the embedder's [`DevicePairingObserver`](src/runtime/signing_host/sso_responder.rs), installed once through @@ -351,107 +342,89 @@ preserves message order; match variants directly or use the request's `SsoReques ## Host platform interface -The `platform` module holds the capability traits a TrUAPI host implements. -Each host (web/WASM, desktop, iOS/UniFFI, Android/UniFFI) implements these -traits to provide the native capabilities the shared Rust runtime cannot reach -directly. The dispatcher calls this surface while the Rust -runtime owns product account management, SSO signing, statement-store protocol -flows, permission state, and auth state transitions. +The `platform` module holds the capability traits a TrUAPI host implements. Each host (web/WASM, desktop, iOS/UniFFI, +Android/UniFFI) implements these traits to provide the native capabilities the shared Rust runtime cannot reach +directly. The dispatcher calls this surface while the Rust runtime owns product account management, SSO signing, +statement-store protocol flows, permission state, and auth state transitions. ### Type Imports -Most host-facing wire types are imported from `truapi::latest` by this module and -are exposed through the trait signatures below. `ProductContext` and -`ProductExecutionKind` are defined here instead, and codegen emits their host -codecs from these definitions. Both are SCALE-encodable so they can cross the -wasm callback boundary, where every parameter is encoded with -`parity-scale-codec`; `ProductContext` decodes through its validating -constructor, so a context off the wire carries a normalized product id. +Most host-facing wire types are imported from `truapi::latest` by this module and are exposed through the trait +signatures below. `ProductContext` and `ProductExecutionKind` are defined here instead, and codegen emits their host +codecs from these definitions. Both are SCALE-encodable so they can cross the wasm callback boundary, where every +parameter is encoded with `parity-scale-codec`; `ProductContext` decodes through its validating constructor, so a +context off the wire carries a normalized product id. ### Product Identity -`normalize_product_identifier` is the single chokepoint that turns a host- or -wire-supplied product id into the canonical form derivation, product storage and -permission scopes are keyed by; `is_product_identifier` is its boolean form. +`normalize_product_identifier` is the single chokepoint that turns a host- or wire-supplied product id into the +canonical form derivation, product storage and permission scopes are keyed by; `is_product_identifier` is its boolean +form. -`DOTNS_TLDS` (`dot`, `paseo`, `test`) backs it: the TLDs dotNS deployments -register product names under, one entry per network a host can be pointed at. A -name ending in one of them is also what navigation resolves back into the host's -own product surface, so it bypasses the outbound domain grant. +`DOTNS_TLDS` (`dot`, `paseo`, `test`) backs it: the TLDs dotNS deployments register product names under, one entry per +network a host can be pointed at. A name ending in one of them is also what navigation resolves back into the host's own +product surface, so it bypasses the outbound domain grant. -`REMOTE_PERMISSION_TRUSTED_LABELS` lists the blessed product labels across -all networks in `DOTNS_TLDS`. These products bypass recorded permissions and -prompt only for device permissions. The runtime grants -account access, username disclosure, signing with their own product accounts and -AutoSigning without approval. Legacy-account signing still asks the user. +`REMOTE_PERMISSION_TRUSTED_LABELS` lists the blessed product labels across all networks in `DOTNS_TLDS`. These products +bypass recorded permissions and prompt only for device permissions. The runtime grants account access, username +disclosure, signing with their own product accounts and AutoSigning without approval. Legacy-account signing still asks +the user. ### Host Callback Traits - `ProductStorage`: product-scoped key-value storage. -- `CoreStorage`: typed core-owned storage slots such as auth session, pairing - identity, and permission authorization state. +- `CoreStorage`: typed core-owned storage slots such as auth session, pairing identity, and permission authorization + state. - `Navigation`: open URLs in the system browser. - `Notifications`: deliver and cancel push notifications. - `Permissions`: prompt for device and remote authorizations. - `Features`: report host feature support. -- `ChainProvider` / `JsonRpcConnection`: open JSON-RPC connections to chains. - Defined in `truapi_provider::platform` so the provider implements them - without linking the runtime, and re-exported here. +- `ChainProvider` / `JsonRpcConnection`: open JSON-RPC connections to chains. Defined in `truapi_provider::platform` so + the provider implements them without linking the runtime, and re-exported here. - `AuthPresenter`: render core-owned auth state transitions. -- `UserConfirmation`: confirm signing, transaction, resource, alias, and - preimage actions before the core asks the paired wallet. +- `UserConfirmation`: confirm signing, transaction, resource, alias, and preimage actions before the core asks the + paired wallet. - `ThemeHost`: stream the host theme into the runtime. - `PreimageHost`: submit and look up preimages through the host-selected backend. -- `ChatPlatform`: create product-scoped native chat rooms, register product - chat bots, post messages into rooms, and stream the product's room list. -- `PermissionStatusHost`: report the OS status of a device capability without - prompting, so a stored grant can be revalidated before it is acted on. -- `PocketPlatform`: stream the product's Pocket card collection and remove a - card from it. The host owns the collection and decides which cards are - privileged. -- `ContactsPlatform`: resolve opaque handles to contacts, render single or multiple - selection pickers, and place host-owned contact names over product surfaces. - `contacts` is the only required method; `pick_contact` and `pick_contacts` - default to `Unsupported`, never a fake selection. The multi-picker receives a - host-private `ContactSelection` record of resolved accounts. Confirmed empty - selection is distinct from dismissal, and unresolved initial handles fail closed. - Session and directory generations are checked across host calls. - `place_contact_labels` is independent of Profile grants; products receive neither - names nor availability. Placements are serialized and cleared at connection - teardown and session change. Hosts call `notify_contacts_changed` when a contact - is removed or blocked, invalidating cached handles. Their label layers clear stale - names and refresh the live placement from the current directory. -- `ProfilePlatform`: show a product-referenced profile in host-owned UI, show - a contact's shared profile naming the contact who sent it, and draw the - avatars of contacts who shared one over a chat product. The host resolves, - decrypts and renders each reference; nothing returns to the product but - acceptance. Naming the contact is optional and presents the reference alone - by default; drawing avatars is optional and draws nothing by default. - -`Platform` is a blanket-implemented supertrait that combines the capability -traits above except `ChatPlatform`, `ContactsPlatform`, `PermissionStatusHost`, -`PocketPlatform` and `ProfilePlatform`, which `OptionalPlatform` lists instead: -a host supplies each only when it can serve it. Codegen reads `OptionalPlatform` to emit each listed -capability as an optional group on the host-callback surface. - -Omitting `ChatPlatform` makes the core answer Chat calls `Unsupported`, and -omitting `ContactsPlatform`, `PocketPlatform` or `ProfilePlatform` does the same -for Contacts, Pocket or Profile calls. -Omitting `PermissionStatusHost` leaves device grants resolving from stored -state alone, which is what a host with no OS permission model does anyway. -Serving it gates both halves of the surface: a device permission request and a -status read through `CoreAdmin` resolve the same two gates, so a settings -screen never reports a capability as usable when the OS refuses it. +- `ChatPlatform`: create product-scoped native chat rooms, register product chat bots, post messages into rooms, and + stream the product's room list. +- `PermissionStatusHost`: report the OS status of a device capability without prompting, so a stored grant can be + revalidated before it is acted on. +- `PocketPlatform`: stream the product's Pocket card collection and remove a card from it. The host owns the collection + and decides which cards are privileged. +- `ContactsPlatform`: resolve opaque handles to contacts, render single or multiple selection pickers, and place + host-owned contact names over product surfaces. `contacts` is the only required method; `pick_contact` and + `pick_contacts` default to `Unsupported`, never a fake selection. The multi-picker receives a host-private + `ContactSelection` record of resolved accounts. Confirmed empty selection is distinct from dismissal, and unresolved + initial handles fail closed. Session and directory generations are checked across host calls. `place_contact_labels` + is independent of Profile grants; products receive neither names nor availability. Placements are serialized and + cleared at connection teardown and session change. Hosts call `notify_contacts_changed` when a contact is removed or + blocked, invalidating cached handles. Their label layers clear stale names and refresh the live placement from the + current directory. +- `ProfilePlatform`: show a product-referenced profile in host-owned UI, show a contact's shared profile naming the + contact who sent it, and draw the avatars of contacts who shared one over a chat product. The host resolves, decrypts + and renders each reference; nothing returns to the product but acceptance. Naming the contact is optional and presents + the reference alone by default; drawing avatars is optional and draws nothing by default. + +`Platform` is a blanket-implemented supertrait that combines the capability traits above except `ChatPlatform`, +`ContactsPlatform`, `PermissionStatusHost`, `PocketPlatform` and `ProfilePlatform`, which `OptionalPlatform` lists +instead: a host supplies each only when it can serve it. Codegen reads `OptionalPlatform` to emit each listed capability +as an optional group on the host-callback surface. + +Omitting `ChatPlatform` makes the core answer Chat calls `Unsupported`, and omitting `ContactsPlatform`, +`PocketPlatform` or `ProfilePlatform` does the same for Contacts, Pocket or Profile calls. Omitting +`PermissionStatusHost` leaves device grants resolving from stored state alone, which is what a host with no OS +permission model does anyway. Serving it gates both halves of the surface: a device permission request and a status read +through `CoreAdmin` resolve the same two gates, so a settings screen never reports a capability as usable when the OS +refuses it. ### Core-Owned Admin API -`CoreAdmin` is not part of the host-provided `Platform` callback surface. It is -the core-owned control API exposed to host UI for logout, pairing cancellation, -session-store refresh, and permission administration. +`CoreAdmin` is not part of the host-provided `Platform` callback surface. It is the core-owned control API exposed to +host UI for logout, pairing cancellation, session-store refresh, and permission administration. -It also serves the session's X25519 chat identity private key. Public session -material a host needs to address the identity or the paired device travels on -`SessionUiInfo` instead; only the secret requires this deliberate call. +It also serves the session's X25519 chat identity private key. Public session material a host needs to address the +identity or the paired device travels on `SessionUiInfo` instead; only the secret requires this deliberate call. ## Wire envelope diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 632c2dcd6..f1749108b 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -3676,9 +3676,9 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn the_core_database_is_installed_once_and_reports_when_missing() { + use crate::platform::{HostInfo, PlatformInfo, SigningHostConfig}; use crate::store::{Db, DbConfig, DbError, DbLocation}; use futures::executor::block_on; - use crate::platform::{HostInfo, PlatformInfo, SigningHostConfig}; let config = SigningHostConfig::new( HostInfo { @@ -3711,7 +3711,10 @@ mod tests { assert!(runtime.set_core_db(installed)); assert!(!runtime.set_core_db(other)); - let db = runtime.services.core_db().expect("installed database is served"); + let db = runtime + .services + .core_db() + .expect("installed database is served"); let answer: i64 = block_on(db.write(|tx| Ok(tx.query_row("SELECT 42", [], |row| row.get(0))?))) .expect("installed database serves writes"); diff --git a/rust/crates/truapi/src/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs index f99ae3fe1..c22e25a9f 100644 --- a/rust/crates/truapi/src/native/callbacks.rs +++ b/rust/crates/truapi/src/native/callbacks.rs @@ -8,12 +8,12 @@ use truapi::v01; use crate::PairedSsoPeer; use crate::host_logic::worker::WorkerTransition; +#[cfg(doc)] +use super::NativeTrUApiHostRuntime; use super::config::ProductExecutionConfig; use super::errors::HostRejection; #[cfg(doc)] use crate::platform::CoreStorageKey; -#[cfg(doc)] -use super::NativeTrUApiHostRuntime; /// Host-private native Coinage response. It may contain bearer memo material. #[derive(Clone, uniffi::Record)] diff --git a/rust/crates/truapi/src/native/errors.rs b/rust/crates/truapi/src/native/errors.rs index 3c4ff2344..bef449625 100644 --- a/rust/crates/truapi/src/native/errors.rs +++ b/rust/crates/truapi/src/native/errors.rs @@ -1,7 +1,5 @@ use truapi::v01; - - /// Native-friendly rejection error returned by callback methods that map onto /// [`truapi::v01::GenericError`]. /// @@ -62,7 +60,9 @@ impl From for v01::HostNavigateToError { impl From for crate::latest::HostContactsPlaceLabelsError { fn from(_: uniffi::UnexpectedUniFFICallbackError) -> Self { - Self::Unknown { reason: "contact label callback failed".into() } + Self::Unknown { + reason: "contact label callback failed".into(), + } } } diff --git a/rust/crates/truapi/src/native/platform.rs b/rust/crates/truapi/src/native/platform.rs index 9c38a19a4..a5cdbe676 100644 --- a/rust/crates/truapi/src/native/platform.rs +++ b/rust/crates/truapi/src/native/platform.rs @@ -7,7 +7,8 @@ use crate::platform::{ NativeChatFileExportRequest, NativeChatFilePickRequest, NativeChatFilesHost, NativeChatPickedFile, NativeCoinageRequest, NativeCoinageResponse, Navigation, Notifications, PermissionDecision, Permissions, PreimageHost, ProductContext, ProductOperations, - ProductStorage, ProviderError, ThemeHost, UserConfirmation, UserConfirmationReview, async_trait, + ProductStorage, ProviderError, ThemeHost, UserConfirmation, UserConfirmationReview, + async_trait, }; use futures::channel::mpsc; use futures::stream::{self, BoxStream, StreamExt}; @@ -52,7 +53,10 @@ impl NativeChatFilesHost for CallbackPlatform { &self, request: NativeChatFilePickRequest, ) -> Result, v01::GenericError> { - self.callbacks.pick_chat_files(request).await.map_err(Into::into) + self.callbacks + .pick_chat_files(request) + .await + .map_err(Into::into) } async fn read_chat_file( @@ -61,18 +65,27 @@ impl NativeChatFilesHost for CallbackPlatform { offset: u64, length: u32, ) -> Result, v01::GenericError> { - self.callbacks.read_chat_file(source_id, offset, length).await.map_err(Into::into) + self.callbacks + .read_chat_file(source_id, offset, length) + .await + .map_err(Into::into) } async fn release_chat_file(&self, source_id: String) -> Result<(), v01::GenericError> { - self.callbacks.release_chat_file(source_id).await.map_err(Into::into) + self.callbacks + .release_chat_file(source_id) + .await + .map_err(Into::into) } async fn begin_chat_file_export( &self, request: NativeChatFileExportRequest, ) -> Result, v01::GenericError> { - self.callbacks.begin_chat_file_export(request).await.map_err(Into::into) + self.callbacks + .begin_chat_file_export(request) + .await + .map_err(Into::into) } async fn write_chat_file_export( @@ -81,15 +94,24 @@ impl NativeChatFilesHost for CallbackPlatform { offset: u64, data: Vec, ) -> Result<(), v01::GenericError> { - self.callbacks.write_chat_file_export(export_id, offset, data).await.map_err(Into::into) + self.callbacks + .write_chat_file_export(export_id, offset, data) + .await + .map_err(Into::into) } async fn finish_chat_file_export(&self, export_id: String) -> Result<(), v01::GenericError> { - self.callbacks.finish_chat_file_export(export_id).await.map_err(Into::into) + self.callbacks + .finish_chat_file_export(export_id) + .await + .map_err(Into::into) } async fn cancel_chat_file_export(&self, export_id: String) -> Result<(), v01::GenericError> { - self.callbacks.cancel_chat_file_export(export_id).await.map_err(Into::into) + self.callbacks + .cancel_chat_file_export(export_id) + .await + .map_err(Into::into) } } @@ -100,7 +122,8 @@ impl crate::platform::IdentityBackendHost for CallbackPlatform { username: String, people_chain_genesis_hash: [u8; 32], ) -> Result, v01::GenericError> { - let candidates = self.callbacks + let candidates = self + .callbacks .identity_username_candidates(username, people_chain_genesis_hash.to_vec()) .await .map_err(v01::GenericError::from)?; @@ -108,17 +131,22 @@ impl crate::platform::IdentityBackendHost for CallbackPlatform { } } -fn decode_identity_candidates(candidates: Vec>) -> Result, v01::GenericError> { +fn decode_identity_candidates( + candidates: Vec>, +) -> Result, v01::GenericError> { if candidates.len() > 32 { return Err(v01::GenericError { reason: "too many username candidates".into(), }); } - candidates.into_iter().map(|candidate| { - candidate.try_into().map_err(|_| v01::GenericError { - reason: "username candidate is not AccountId32".into(), + candidates + .into_iter() + .map(|candidate| { + candidate.try_into().map_err(|_| v01::GenericError { + reason: "username candidate is not AccountId32".into(), + }) }) - }).collect() + .collect() } /// [`crate::platform::ContactsPlatform`] served by host-provided @@ -158,8 +186,12 @@ impl crate::platform::ContactsPlatform for ContactsCallbackPlatform { product: &ProductContext, selection: crate::platform::ContactSelection, ) -> Result { - self.contacts.pick_contacts(product.product_id.clone(), selection).await - .map_err(|error| v01::GenericError { reason: error.to_string() }) + self.contacts + .pick_contacts(product.product_id.clone(), selection) + .await + .map_err(|error| v01::GenericError { + reason: error.to_string(), + }) } async fn place_contact_labels( @@ -167,7 +199,10 @@ impl crate::platform::ContactsPlatform for ContactsCallbackPlatform { product: &ProductContext, placed: crate::platform::PlacedContactLabels, ) -> Result { - self.contacts.place_contact_labels(product.product_id.clone(), placed).await.map(|()| true) + self.contacts + .place_contact_labels(product.product_id.clone(), placed) + .await + .map(|()| true) } } diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 52a637044..98a0b2203 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -106,8 +106,8 @@ use truapi::versioned::chat::{ HostChatRegisterBotError, HostChatRegisterBotRequest, HostChatRegisterBotResponse, }; use truapi::versioned::contacts::{ - HostContactsPickError, HostContactsPickRequest, HostContactsPickResponse, - HostContactsPickManyError, HostContactsPickManyRequest, HostContactsPickManyResponse, + HostContactsPickError, HostContactsPickManyError, HostContactsPickManyRequest, + HostContactsPickManyResponse, HostContactsPickRequest, HostContactsPickResponse, HostContactsPlaceLabelsError, HostContactsPlaceLabelsRequest, HostContactsPlaceLabelsResponse, }; use truapi::versioned::pocket::{ @@ -1267,7 +1267,9 @@ impl ProductRuntimeHost { /// Clear this connection's host-owned contact names and prevent late draws. pub fn release_contact_labels(&self) { - self.services.contact_labels.release(self.core_instance, &self.services.spawner); + self.services + .contact_labels + .release(self.core_instance, &self.services.spawner); } /// Drop the worker reference a pending operation held. An id that is not @@ -1522,16 +1524,26 @@ impl Contacts for ProductRuntimeHost { // choosing must not be undone by caching their choice. let generation = self.services.contact_handles.generation(); if self.authority.current_session() != session { - return Err(CallError::Domain(wrap(v01::HostContactsPickError::NotConnected))); + return Err(CallError::Domain(wrap( + v01::HostContactsPickError::NotConnected, + ))); } let picked = until_cancelled(cx, platform.pick_contact(&self.product)) .await - .map_err(|_| unknown(v01::GenericError { reason: "contact picker interrupted".into() }))?; + .map_err(|_| { + unknown(v01::GenericError { + reason: "contact picker interrupted".into(), + }) + })?; if self.authority.current_session() != session { - return Err(CallError::Domain(wrap(v01::HostContactsPickError::NotConnected))); + return Err(CallError::Domain(wrap( + v01::HostContactsPickError::NotConnected, + ))); } if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { - return Err(unknown(v01::GenericError { reason: "contact picker interrupted".into() })); + return Err(unknown(v01::GenericError { + reason: "contact picker interrupted".into(), + })); } let outcome = match picked.map_err(unknown)? { crate::platform::HostContactPick::Picked { account } => { @@ -1560,7 +1572,9 @@ impl Contacts for ProductRuntimeHost { cx: &CallContext, request: HostContactsPickManyRequest, ) -> Result> { - use crate::latest::{ContactHandle, ContactPickManyOutcome, HostContactsPickManyError as Error}; + use crate::latest::{ + ContactHandle, ContactPickManyOutcome, HostContactsPickManyError as Error, + }; let error = |error| CallError::Domain(HostContactsPickManyError::V1(error)); let HostContactsPickManyRequest::V1(request) = request; let selected = contacts::selected_handles(request.selected) @@ -1568,8 +1582,12 @@ impl Contacts for ProductRuntimeHost { let session = self.authority.current_session(); let (platform, handles) = self.contacts_picker().map_err(|failure| match failure { CallError::Unsupported => CallError::Unsupported, - CallError::Domain(v01::HostContactsPickError::NotConnected) => error(Error::NotConnected), - _ => error(Error::Unknown { reason: "contact picker unavailable".into() }), + CallError::Domain(v01::HostContactsPickError::NotConnected) => { + error(Error::NotConnected) + } + _ => error(Error::Unknown { + reason: "contact picker unavailable".into(), + }), })?; let generation = self.services.contact_handles.generation(); if self.authority.current_session() != session { @@ -1578,47 +1596,82 @@ impl Contacts for ProductRuntimeHost { let resolved = until_cancelled( cx, resolve_contact_accounts(&self.services, platform.as_ref(), &handles, &selected), - ).await.map_err(|_| error(Error::Unknown { reason: "contact lookup interrupted".into() }))?; + ) + .await + .map_err(|_| { + error(Error::Unknown { + reason: "contact lookup interrupted".into(), + }) + })?; if self.authority.current_session() != session { return Err(error(Error::NotConnected)); } if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { - return Err(error(Error::Unknown { reason: "contact selection interrupted".into() })); + return Err(error(Error::Unknown { + reason: "contact selection interrupted".into(), + })); } let accounts = resolved - .map_err(|_| error(Error::Unknown { reason: "contact lookup failed".into() }))? + .map_err(|_| { + error(Error::Unknown { + reason: "contact lookup failed".into(), + }) + })? .into_iter() .map(|(_, account)| account) .collect::>>() .ok_or_else(|| error(Error::InvalidSelection))?; - let picked = until_cancelled(cx, platform.pick_contacts(&self.product, crate::platform::ContactSelection { selected: accounts })) - .await - .map_err(|_| error(Error::Unknown { reason: "contact picker interrupted".into() }))?; + let picked = until_cancelled( + cx, + platform.pick_contacts( + &self.product, + crate::platform::ContactSelection { selected: accounts }, + ), + ) + .await + .map_err(|_| { + error(Error::Unknown { + reason: "contact picker interrupted".into(), + }) + })?; if self.authority.current_session() != session { return Err(error(Error::NotConnected)); } if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { - return Err(error(Error::Unknown { reason: "contact selection interrupted".into() })); + return Err(error(Error::Unknown { + reason: "contact selection interrupted".into(), + })); } - let outcome = match picked.map_err(|_| error(Error::Unknown { - reason: "contact picker failed".into(), - }))? { + let outcome = match picked.map_err(|_| { + error(Error::Unknown { + reason: "contact picker failed".into(), + }) + })? { crate::platform::HostContactsPick::Picked { accounts } => { - let accounts = contacts::selected_accounts(accounts).ok_or_else(|| error(Error::Unknown { - reason: "contact selection exceeds the limit".into(), - }))?; - let selected = accounts.into_iter().map(|account| { - let bytes = handles.mint(&account); - self.services.contact_handles.insert(bytes, account, generation); - ContactHandle { bytes } - }).collect(); + let accounts = contacts::selected_accounts(accounts).ok_or_else(|| { + error(Error::Unknown { + reason: "contact selection exceeds the limit".into(), + }) + })?; + let selected = accounts + .into_iter() + .map(|account| { + let bytes = handles.mint(&account); + self.services + .contact_handles + .insert(bytes, account, generation); + ContactHandle { bytes } + }) + .collect(); ContactPickManyOutcome::Picked { handles: selected } } crate::platform::HostContactsPick::Dismissed => ContactPickManyOutcome::Dismissed, crate::platform::HostContactsPick::NoContacts => ContactPickManyOutcome::NoContacts, crate::platform::HostContactsPick::Unsupported => return Err(CallError::Unsupported), }; - Ok(HostContactsPickManyResponse::V1(crate::latest::HostContactsPickManyResponse { outcome })) + Ok(HostContactsPickManyResponse::V1( + crate::latest::HostContactsPickManyResponse { outcome }, + )) } #[instrument(skip_all, fields(runtime.method = "contacts.place_labels"))] @@ -1639,10 +1692,18 @@ impl Contacts for ProductRuntimeHost { let session = self.authority.current_session(); let (platform, handles) = self.contacts_picker().map_err(|failure| match failure { CallError::Unsupported => CallError::Unsupported, - CallError::Domain(v01::HostContactsPickError::NotConnected) => error(Error::NotConnected), - _ => error(Error::Unknown { reason: "contact labels unavailable".into() }), + CallError::Domain(v01::HostContactsPickError::NotConnected) => { + error(Error::NotConnected) + } + _ => error(Error::Unknown { + reason: "contact labels unavailable".into(), + }), })?; - let placement = self.services.contact_labels.for_runtime(self.core_instance, platform.clone(), &self.product); + let placement = self.services.contact_labels.for_runtime( + self.core_instance, + platform.clone(), + &self.product, + ); let mut surface = placement.surface.lock().await; if placement.is_closed() || cx.cancel().is_cancelled() { return Err(error(Error::NotConnected)); @@ -1655,46 +1716,75 @@ impl Contacts for ProductRuntimeHost { let resolved = until_cancelled( cx, resolve_contact_accounts(&self.services, platform.as_ref(), &handles, &requested), - ).await.map_err(|_| error(Error::Unknown { reason: "contact lookup interrupted".into() }))?; + ) + .await + .map_err(|_| { + error(Error::Unknown { + reason: "contact lookup interrupted".into(), + }) + })?; if self.authority.current_session() != session { return Err(error(Error::NotConnected)); } if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { - return Err(error(Error::Unknown { reason: "contact labels interrupted".into() })); - } - let resolved = resolved.map_err(|_| error(Error::Unknown { reason: "contact lookup failed".into() }))?; - let labels = request.slots.into_iter().zip(resolved).filter_map(|(slot, (_, account))| { - account.map(|account| crate::platform::PlacedContactLabel { - slot: slot.slot, - account, - rect: slot.rect, - clip: slot.clip, + return Err(error(Error::Unknown { + reason: "contact labels interrupted".into(), + })); + } + let resolved = resolved.map_err(|_| { + error(Error::Unknown { + reason: "contact lookup failed".into(), }) - }).collect(); + })?; + let labels = request + .slots + .into_iter() + .zip(resolved) + .filter_map(|(slot, (_, account))| { + account.map(|account| crate::platform::PlacedContactLabel { + slot: slot.slot, + account, + rect: slot.rect, + clip: slot.clip, + }) + }) + .collect(); if placement.is_closed() { return Err(error(Error::NotConnected)); } *surface = Some((request.surface_width, request.surface_height, generation)); - let result = platform.place_contact_labels(&self.product, crate::platform::PlacedContactLabels { - surface_width: request.surface_width, - surface_height: request.surface_height, - labels, - }).await; + let result = platform + .place_contact_labels( + &self.product, + crate::platform::PlacedContactLabels { + surface_width: request.surface_width, + surface_height: request.surface_height, + labels, + }, + ) + .await; if self.authority.current_session() != session || cx.cancel().is_cancelled() || placement.is_closed() { - let _ = platform.place_contact_labels(&self.product, crate::platform::PlacedContactLabels { - surface_width: request.surface_width, - surface_height: request.surface_height, - labels: Vec::new(), - }).await; + let _ = platform + .place_contact_labels( + &self.product, + crate::platform::PlacedContactLabels { + surface_width: request.surface_width, + surface_height: request.surface_height, + labels: Vec::new(), + }, + ) + .await; return Err(error(Error::NotConnected)); } if matches!(result, Ok(false) | Err(Error::Unsupported)) { return Err(CallError::Unsupported); } - Ok(HostContactsPlaceLabelsResponse::V1(crate::latest::HostContactsPlaceLabelsResponse {})) + Ok(HostContactsPlaceLabelsResponse::V1( + crate::latest::HostContactsPlaceLabelsResponse {}, + )) } } diff --git a/rust/crates/truapi/src/runtime/contacts.rs b/rust/crates/truapi/src/runtime/contacts.rs index 9c2ad5c14..cabe0bc75 100644 --- a/rust/crates/truapi/src/runtime/contacts.rs +++ b/rust/crates/truapi/src/runtime/contacts.rs @@ -15,7 +15,10 @@ //! the mapping cannot be recovered by hashing candidate accounts. use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; use parity_scale_codec::Encode; @@ -25,14 +28,18 @@ const HANDLE_CACHE_MAX_ENTRIES: usize = 256; const MAX_CONTACTS: usize = 256; /// Bound and deduplicate product-supplied selections without changing order. -pub fn selected_handles( - selected: Vec, -) -> Option> { +pub fn selected_handles(selected: Vec) -> Option> { if selected.len() > MAX_CONTACTS { return None; } let mut seen = HashSet::with_capacity(selected.len()); - Some(selected.into_iter().map(|handle| handle.bytes).filter(|handle| seen.insert(*handle)).collect()) + Some( + selected + .into_iter() + .map(|handle| handle.bytes) + .filter(|handle| seen.insert(*handle)) + .collect(), + ) } /// Bound and deduplicate accounts a host picker confirmed. @@ -87,14 +94,17 @@ impl ContactLabelPlacement { return; } if let Some((surface_width, surface_height, _)) = surface.take() { - let _ = self.platform.place_contact_labels( - &self.product, - crate::platform::PlacedContactLabels { - surface_width, - surface_height, - labels: Vec::new(), - }, - ).await; + let _ = self + .platform + .place_contact_labels( + &self.product, + crate::platform::PlacedContactLabels { + surface_width, + surface_height, + labels: Vec::new(), + }, + ) + .await; } } } @@ -113,29 +123,32 @@ impl ContactLabelPlacements { platform: Arc, product: &crate::platform::ProductContext, ) -> Arc { - self.by_runtime.lock() - .entry(runtime).or_insert_with(|| Arc::new(ContactLabelPlacement { - platform, - product: product.clone(), - surface: Default::default(), - closed: AtomicBool::new(false), - })).clone() + self.by_runtime + .lock() + .entry(runtime) + .or_insert_with(|| { + Arc::new(ContactLabelPlacement { + platform, + product: product.clone(), + surface: Default::default(), + closed: AtomicBool::new(false), + }) + }) + .clone() } /// Prevent late draws and clear a connection's labels during teardown. pub fn release(&self, runtime: u64, spawner: &crate::subscription::Spawner) { - let Some(placement) = self.by_runtime.lock() - .remove(&runtime) else { - return; - }; + let Some(placement) = self.by_runtime.lock().remove(&runtime) else { + return; + }; placement.closed.store(true, Ordering::Release); spawner(Box::pin(async move { placement.clear(None).await })); } /// Clear labels from the preceding wallet session, without erasing newer draws. pub fn session_changed(&self, generation: u64, spawner: &crate::subscription::Spawner) { - let placements: Vec<_> = self.by_runtime.lock() - .values().cloned().collect(); + let placements: Vec<_> = self.by_runtime.lock().values().cloned().collect(); if placements.is_empty() { return; } diff --git a/rust/crates/truapi/src/runtime/services.rs b/rust/crates/truapi/src/runtime/services.rs index 1ef5c5bfd..918042cbc 100644 --- a/rust/crates/truapi/src/runtime/services.rs +++ b/rust/crates/truapi/src/runtime/services.rs @@ -282,7 +282,8 @@ impl RuntimeServices { /// Forget handles and labels belonging to the preceding wallet session. pub fn contacts_session_changed(&self) { self.invalidate_contacts(); - self.contact_labels.session_changed(self.contact_handles.generation(), &self.spawner); + self.contact_labels + .session_changed(self.contact_handles.generation(), &self.spawner); } /// Install the host's device-pairing observer. diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index 5cdd9f718..078ba49ac 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -4729,8 +4729,9 @@ mod tests { .unwrap(); let runtime = product_runtime(services.clone(), activation.clone()); let cx = CallContext::default(); - let chat = - |request| runtime.product_device_chat(&cx, HostProductDeviceChatRequest::V2(request)); + let chat = |request| { + runtime.product_device_chat(&cx, HostProductDeviceChatRequest::V2(request)) + }; chat(truapi::latest::HostProductDeviceChatRequest::Initialize) .await .unwrap(); @@ -5013,7 +5014,8 @@ mod tests { crate::platform::ProductExecutionKind::Worker, ) .expect("test product id is valid"); - let permissions = PermissionsService::new(platform.as_ref(), platform.as_ref(), &product); + let permissions = + PermissionsService::new(platform.as_ref(), platform.as_ref(), &product); assert_eq!( permissions .authorization_status(&PermissionAuthorizationRequest::ChatAuthority) diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index aef863151..8874d16d6 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -820,7 +820,10 @@ struct AudienceContactsPlatform { impl AudienceContactsPlatform { fn new(accounts: Vec<[u8; 32]>, outcome: crate::platform::HostContactsPick) -> Arc { Arc::new(Self { - directory: StubContactsPlatform::new(accounts, crate::platform::HostContactPick::Dismissed), + directory: StubContactsPlatform::new( + accounts, + crate::platform::HostContactPick::Dismissed, + ), outcome: parking_lot::Mutex::new(outcome), selected: Default::default(), labels: Default::default(), @@ -837,7 +840,8 @@ impl crate::platform::ContactsPlatform for AudienceContactsPlatform { &self, lookup: &crate::platform::HostContactLookup, ) -> Result { - let answer = crate::platform::ContactsPlatform::contacts(self.directory.as_ref(), lookup).await; + let answer = + crate::platform::ContactsPlatform::contacts(self.directory.as_ref(), lookup).await; if let Some(changed) = self.after_lookup.lock().take() { changed(); } @@ -884,32 +888,55 @@ fn pick_many( fn multi_picker_preserves_confirmed_empty_and_dismissed_outcomes() { use crate::platform::HostContactsPick; use truapi::latest::ContactPickManyOutcome; - let contacts = AudienceContactsPlatform::new(vec![[10; 32]], HostContactsPick::Picked { accounts: vec![] }); + let contacts = AudienceContactsPlatform::new( + vec![[10; 32]], + HostContactsPick::Picked { accounts: vec![] }, + ); let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); for (answer, expected) in [ - (HostContactsPick::Picked { accounts: vec![] }, ContactPickManyOutcome::Picked { handles: vec![] }), - (HostContactsPick::Dismissed, ContactPickManyOutcome::Dismissed), - (HostContactsPick::NoContacts, ContactPickManyOutcome::NoContacts), + ( + HostContactsPick::Picked { accounts: vec![] }, + ContactPickManyOutcome::Picked { handles: vec![] }, + ), + ( + HostContactsPick::Dismissed, + ContactPickManyOutcome::Dismissed, + ), + ( + HostContactsPick::NoContacts, + ContactPickManyOutcome::NoContacts, + ), ] { *contacts.outcome.lock() = answer; - assert_eq!(pick_many(&host, vec![]), Ok(HostContactsPickManyResponse::V1( - truapi::latest::HostContactsPickManyResponse { outcome: expected }, - ))); + assert_eq!( + pick_many(&host, vec![]), + Ok(HostContactsPickManyResponse::V1( + truapi::latest::HostContactsPickManyResponse { outcome: expected }, + )) + ); } } #[test] fn multi_picker_rejects_unresolved_or_oversized_initial_audiences_without_opening() { let account = [10; 32]; - let contacts = AudienceContactsPlatform::new(vec![account], crate::platform::HostContactsPick::Dismissed); + let contacts = + AudienceContactsPlatform::new(vec![account], crate::platform::HostContactsPick::Dismissed); let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); let (_, handles) = host.contacts_picker().unwrap(); - let known = truapi::latest::ContactHandle { bytes: handles.mint(&account) }; - let missing = truapi::latest::ContactHandle { bytes: handles.mint(&[11; 32]) }; + let known = truapi::latest::ContactHandle { + bytes: handles.mint(&account), + }; + let missing = truapi::latest::ContactHandle { + bytes: handles.mint(&[11; 32]), + }; for selected in [vec![known, missing], vec![known; 257]] { - assert_eq!(pick_many(&host, selected), Err(CallError::Domain(HostContactsPickManyError::V1( - truapi::latest::HostContactsPickManyError::InvalidSelection, - )))); + assert_eq!( + pick_many(&host, selected), + Err(CallError::Domain(HostContactsPickManyError::V1( + truapi::latest::HostContactsPickManyError::InvalidSelection, + ))) + ); } assert!(contacts.selected.lock().is_empty()); } @@ -917,17 +944,27 @@ fn multi_picker_rejects_unresolved_or_oversized_initial_audiences_without_openin #[test] fn multi_picker_deduplicates_and_returns_only_wallet_scoped_handles() { let account = [10; 32]; - let contacts = AudienceContactsPlatform::new(vec![account], crate::platform::HostContactsPick::Picked { - accounts: vec![account, account], - }); + let contacts = AudienceContactsPlatform::new( + vec![account], + crate::platform::HostContactsPick::Picked { + accounts: vec![account, account], + }, + ); let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); let (_, handles) = host.contacts_picker().unwrap(); - let handle = truapi::latest::ContactHandle { bytes: handles.mint(&account) }; - assert_eq!(pick_many(&host, vec![handle, handle]), Ok(HostContactsPickManyResponse::V1( - truapi::latest::HostContactsPickManyResponse { - outcome: truapi::latest::ContactPickManyOutcome::Picked { handles: vec![handle] }, - }, - ))); + let handle = truapi::latest::ContactHandle { + bytes: handles.mint(&account), + }; + assert_eq!( + pick_many(&host, vec![handle, handle]), + Ok(HostContactsPickManyResponse::V1( + truapi::latest::HostContactsPickManyResponse { + outcome: truapi::latest::ContactPickManyOutcome::Picked { + handles: vec![handle] + }, + }, + )) + ); assert_eq!(*contacts.selected.lock(), vec![vec![account]]); assert_ne!(handle.bytes, account); } @@ -935,120 +972,212 @@ fn multi_picker_deduplicates_and_returns_only_wallet_scoped_handles() { #[test] fn multi_picker_rejects_lookup_invalidation_and_session_change_during_confirmation() { let account = [10; 32]; - let contacts = AudienceContactsPlatform::new(vec![account], crate::platform::HostContactsPick::Picked { - accounts: vec![account], - }); + let contacts = AudienceContactsPlatform::new( + vec![account], + crate::platform::HostContactsPick::Picked { + accounts: vec![account], + }, + ); let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); let (_, handles) = host.contacts_picker().unwrap(); - let handle = truapi::latest::ContactHandle { bytes: handles.mint(&account) }; + let handle = truapi::latest::ContactHandle { + bytes: handles.mint(&account), + }; let cache = host.services.contact_handles.clone(); *contacts.after_lookup.lock() = Some(Box::new(move || cache.clear())); - assert!(matches!(pick_many(&host, vec![handle]), Err(CallError::Domain( - HostContactsPickManyError::V1(truapi::latest::HostContactsPickManyError::Unknown { .. }) - )))); + assert!(matches!( + pick_many(&host, vec![handle]), + Err(CallError::Domain(HostContactsPickManyError::V1( + truapi::latest::HostContactsPickManyError::Unknown { .. } + ))) + )); assert!(contacts.selected.lock().is_empty()); let session = host.test_session_state(); *contacts.after_pick.lock() = Some(Box::new(move || session.clear_session())); - assert_eq!(pick_many(&host, vec![]), Err(CallError::Domain(HostContactsPickManyError::V1( - truapi::latest::HostContactsPickManyError::NotConnected, - )))); - assert_eq!(host.services.contact_handles.get(&handle.bytes, &handles), None); + assert_eq!( + pick_many(&host, vec![]), + Err(CallError::Domain(HostContactsPickManyError::V1( + truapi::latest::HostContactsPickManyError::NotConnected, + ))) + ); + assert_eq!( + host.services.contact_handles.get(&handle.bytes, &handles), + None + ); } #[test] fn multi_picker_cancellation_cannot_confirm_a_late_selection() { let account = [10; 32]; - let contacts = AudienceContactsPlatform::new(vec![account], crate::platform::HostContactsPick::Picked { - accounts: vec![account], - }); + let contacts = AudienceContactsPlatform::new( + vec![account], + crate::platform::HostContactsPick::Picked { + accounts: vec![account], + }, + ); let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); let cx = CallContext::default(); let cancel = cx.cancel().clone(); *contacts.after_pick.lock() = Some(Box::new(move || cancel.cancel())); - assert!(matches!(futures::executor::block_on(Contacts::pick_many( - &host, &cx, - HostContactsPickManyRequest::V1(truapi::latest::HostContactsPickManyRequest { selected: vec![] }), - )), Err(CallError::Domain(HostContactsPickManyError::V1( - truapi::latest::HostContactsPickManyError::Unknown { .. } - ))))); + assert!(matches!( + futures::executor::block_on(Contacts::pick_many( + &host, + &cx, + HostContactsPickManyRequest::V1(truapi::latest::HostContactsPickManyRequest { + selected: vec![] + }), + )), + Err(CallError::Domain(HostContactsPickManyError::V1( + truapi::latest::HostContactsPickManyError::Unknown { .. } + ))) + )); } #[test] fn contact_labels_need_no_profile_grant_and_hide_missing_contact_availability() { let account = [10; 32]; - let contacts = AudienceContactsPlatform::new(vec![account], crate::platform::HostContactsPick::Dismissed); + let contacts = + AudienceContactsPlatform::new(vec![account], crate::platform::HostContactsPick::Dismissed); let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); let (_, handles) = host.contacts_picker().unwrap(); - let rect = truapi::latest::AvatarRect { x: 0, y: 0, width: 180, height: 24 }; - let request = |account| HostContactsPlaceLabelsRequest::V1(truapi::latest::HostContactsPlaceLabelsRequest { - surface_width: 300, - surface_height: 200, - slots: vec![truapi::latest::ContactLabelSlot { - slot: 0, handle: truapi::latest::ContactHandle { bytes: handles.mint(&account) }, - rect, clip: rect, - }], - }); - let place = |request| futures::executor::block_on(Contacts::place_labels(&host, &CallContext::default(), request)); + let rect = truapi::latest::AvatarRect { + x: 0, + y: 0, + width: 180, + height: 24, + }; + let request = |account| { + HostContactsPlaceLabelsRequest::V1(truapi::latest::HostContactsPlaceLabelsRequest { + surface_width: 300, + surface_height: 200, + slots: vec![truapi::latest::ContactLabelSlot { + slot: 0, + handle: truapi::latest::ContactHandle { + bytes: handles.mint(&account), + }, + rect, + clip: rect, + }], + }) + }; + let place = |request| { + futures::executor::block_on(Contacts::place_labels( + &host, + &CallContext::default(), + request, + )) + }; let known = place(request(account)); let missing = place(request([11; 32])); - assert_eq!(known, Ok(HostContactsPlaceLabelsResponse::V1(truapi::latest::HostContactsPlaceLabelsResponse {}))); + assert_eq!( + known, + Ok(HostContactsPlaceLabelsResponse::V1( + truapi::latest::HostContactsPlaceLabelsResponse {} + )) + ); assert_eq!(known, missing); - assert_eq!(*contacts.labels.lock(), vec![ - crate::platform::PlacedContactLabels { - surface_width: 300, surface_height: 200, - labels: vec![crate::platform::PlacedContactLabel { slot: 0, account, rect, clip: rect }], - }, - crate::platform::PlacedContactLabels { surface_width: 300, surface_height: 200, labels: vec![] }, - ]); + assert_eq!( + *contacts.labels.lock(), + vec![ + crate::platform::PlacedContactLabels { + surface_width: 300, + surface_height: 200, + labels: vec![crate::platform::PlacedContactLabel { + slot: 0, + account, + rect, + clip: rect + }], + }, + crate::platform::PlacedContactLabels { + surface_width: 300, + surface_height: 200, + labels: vec![] + }, + ] + ); } #[test] fn contact_label_lookup_failure_does_not_clear_the_surface() { - let mut contacts = AudienceContactsPlatform::new(vec![], crate::platform::HostContactsPick::Dismissed); + let mut contacts = + AudienceContactsPlatform::new(vec![], crate::platform::HostContactsPick::Dismissed); Arc::get_mut(&mut contacts).unwrap().directory = StubContactsPlatform::failing("store offline"); let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); - let rect = truapi::latest::AvatarRect { x: 0, y: 0, width: 180, height: 24 }; + let rect = truapi::latest::AvatarRect { + x: 0, + y: 0, + width: 180, + height: 24, + }; let result = futures::executor::block_on(Contacts::place_labels( - &host, &CallContext::default(), + &host, + &CallContext::default(), HostContactsPlaceLabelsRequest::V1(truapi::latest::HostContactsPlaceLabelsRequest { - surface_width: 300, surface_height: 200, + surface_width: 300, + surface_height: 200, slots: vec![truapi::latest::ContactLabelSlot { - slot: 0, handle: truapi::latest::ContactHandle { bytes: [0x42; 32] }, - rect, clip: rect, + slot: 0, + handle: truapi::latest::ContactHandle { bytes: [0x42; 32] }, + rect, + clip: rect, }], }), )); - assert!(matches!(result, Err(CallError::Domain(HostContactsPlaceLabelsError::V1( - truapi::latest::HostContactsPlaceLabelsError::Unknown { .. } - ))))); + assert!(matches!( + result, + Err(CallError::Domain(HostContactsPlaceLabelsError::V1( + truapi::latest::HostContactsPlaceLabelsError::Unknown { .. } + ))) + )); assert!(contacts.labels.lock().is_empty()); } #[test] fn contact_labels_are_cleared_if_the_session_changes_while_drawing() { let account = [10; 32]; - let contacts = AudienceContactsPlatform::new(vec![account], crate::platform::HostContactsPick::Dismissed); + let contacts = + AudienceContactsPlatform::new(vec![account], crate::platform::HostContactsPick::Dismissed); let host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); let (_, handles) = host.contacts_picker().unwrap(); let session = host.test_session_state(); *contacts.after_labels.lock() = Some(Box::new(move || session.clear_session())); - let rect = truapi::latest::AvatarRect { x: 0, y: 0, width: 180, height: 24 }; + let rect = truapi::latest::AvatarRect { + x: 0, + y: 0, + width: 180, + height: 24, + }; let result = futures::executor::block_on(Contacts::place_labels( - &host, &CallContext::default(), + &host, + &CallContext::default(), HostContactsPlaceLabelsRequest::V1(truapi::latest::HostContactsPlaceLabelsRequest { - surface_width: 300, surface_height: 200, + surface_width: 300, + surface_height: 200, slots: vec![truapi::latest::ContactLabelSlot { - slot: 0, handle: truapi::latest::ContactHandle { bytes: handles.mint(&account) }, - rect, clip: rect, + slot: 0, + handle: truapi::latest::ContactHandle { + bytes: handles.mint(&account), + }, + rect, + clip: rect, }], }), )); - assert_eq!(result, Err(CallError::Domain(HostContactsPlaceLabelsError::V1( - truapi::latest::HostContactsPlaceLabelsError::NotConnected, - )))); - assert_eq!(contacts.labels.lock().last(), Some(&crate::platform::PlacedContactLabels { - surface_width: 300, surface_height: 200, labels: vec![], - })); + assert_eq!( + result, + Err(CallError::Domain(HostContactsPlaceLabelsError::V1( + truapi::latest::HostContactsPlaceLabelsError::NotConnected, + ))) + ); + assert_eq!( + contacts.labels.lock().last(), + Some(&crate::platform::PlacedContactLabels { + surface_width: 300, + surface_height: 200, + labels: vec![], + }) + ); } /// A host that implements only the required `contacts` method. @@ -1121,7 +1250,8 @@ fn a_host_that_only_resolves_contacts_reports_unsupported() { #[test] fn workers_cannot_place_contact_labels_even_when_the_host_supports_them() { - let contacts = AudienceContactsPlatform::new(vec![], crate::platform::HostContactsPick::Dismissed); + let contacts = + AudienceContactsPlatform::new(vec![], crate::platform::HostContactsPick::Dismissed); let mut host = contacts_host("seity.dot", stub_platform(), Some(contacts.clone()), true); host.product.execution_kind = crate::platform::ProductExecutionKind::Worker; assert_eq!( From 085489b663321a5d7e052644442f2a5b6e236fa0 Mon Sep 17 00:00:00 2001 From: w Date: Fri, 2 Oct 2026 09:18:12 -0400 Subject: [PATCH 28/30] style(contacts): preserve surrounding documentation layout --- .changeset/profile-disclose.md | 77 ++++---- README.md | 314 +++++++++++++++++------------- docs/rfcs/contacts-api.md | 103 +++++----- js/packages/truapi-host/README.md | 109 ++++++----- rust/crates/truapi/RUNTIME.md | 183 +++++++++-------- 5 files changed, 436 insertions(+), 350 deletions(-) diff --git a/.changeset/profile-disclose.md b/.changeset/profile-disclose.md index 09be47347..ed9ee0531 100644 --- a/.changeset/profile-disclose.md +++ b/.changeset/profile-disclose.md @@ -12,13 +12,13 @@ once through `userConfirmation.confirmPermission` with a new `ProfileDisclosure` This change includes the Chat relay. In legacy `ChatApps` mode the host sends the disclosure to every ready Chat v2 contact as a host-private app-scoped message and keeps, per contact, the newest frame their host sent back, withdrawals included, whatever order the chat product opens them in. Both live in wallet- and network-scoped core storage -(`ProfileDisclosure`, `ProfileReferencesReceived`). The host queues the disclosure when the chat product initializes or -reconciles, in the response to a Chat request in which a contact became ready, and, without delaying the call, as soon -as `disclose` or `retract` changes it while a Chat of the same wallet is open; the chat product still has to run to -submit it. Delivery is best effort: relayed references never take outbox room from other Chat traffic, and one that -lapses unacknowledged after a statement lifetime is signed again for a ready contact, at most three frames per contact -and disclosure. Every `disclose` call is a new disclosure, even with the reference already held: a profile whose record -changed behind the same reference is sent to the selected ready recipients again, with a fresh attempt count. +(`ProfileDisclosure`, `ProfileReferencesReceived`). The host queues the disclosure when the chat product initializes or reconciles, in the +response to a Chat request in which a contact became ready, and, without delaying the call, as soon as `disclose` or +`retract` changes it while a Chat of the same wallet is open; the chat product still has to run to submit it. Delivery +is best effort: relayed references never take outbox room from other Chat traffic, and one that lapses unacknowledged +after a statement lifetime is signed again for a ready contact, at most three frames per contact and disclosure. Every +`disclose` call is a new disclosure, even with the reference already held: a profile whose record changed behind the +same reference is sent to the selected ready recipients again, with a fresh attempt count. Add `profile.placeContactAvatars`. A chat App tells the host where it draws contacts' avatars (surface size and, per avatar, a slot id, peer identity, square rect and clip), and the host draws the photo and mood ring of each contact who @@ -26,44 +26,45 @@ shared a profile with it on its own layer. The core filters the placement to con them with their references and `sharedAt` (Unix ms of the contact's share) to the new `ProfilePlatform.placeContactAvatars(product, placed)` callback, and redraws the remembered placement when a reference arrives, is re-shared or is withdrawn; a larger `sharedAt` for the same reference tells the host its cached profile is -stale; it clears it when the connection goes away. The product is answered `Ok` whoever shared; only a malformed -placement (more than 64 slots, a surface side outside 1 to 16384, a non-square avatar or one outside 1 to 1024 a side, a -repeated slot) is refused, and a host that cannot draw answers `Unsupported`. A JS host that supplies a `profile` group -must implement the callback; the Rust trait's default draws nothing. +stale; it clears it when the connection goes away. +The product is answered `Ok` whoever shared; only a malformed placement (more than 64 slots, a surface side outside 1 to +16384, a non-square avatar or one outside 1 to 1024 a side, a repeated slot) is refused, and a host that cannot draw +answers `Unsupported`. A JS host that supplies a `profile` group must implement the callback; the Rust trait's default +draws nothing. Add `profile.ownStatus` and `profile.presentOwn`, and an optional `own` slot in version 2 of -`profile.placeContactAvatars`. A chat product can report whether its signed-in user has configured a profile and ask the -host to present it without receiving the bearer reference. The core fills the own slot from the user's disclosure and -hands it to the existing callback in the same replacement set as the contact avatars, redraws it when the user discloses -or retracts, and still reveals nothing per slot. Version 1 placements keep working unchanged. The avatar regression -suite also exercises version-1 response downgrading alongside the version-2 own-profile slot. +`profile.placeContactAvatars`. A chat product can report whether its signed-in user has configured a profile and ask +the host to present it without receiving the bearer reference. The core fills the own slot from the user's disclosure +and hands it to the existing callback in the same replacement set as the contact avatars, redraws it when the user +discloses or retracts, and still reveals nothing per slot. Version 1 placements keep working unchanged. +The avatar regression suite also exercises version-1 response downgrading alongside the version-2 own-profile slot. -Version 2 of `profile.disclose` adds explicit `ChatApps`, `App { productId }`, and `Contacts { handles }` audiences. -App-scoped and selected-contact personal grants coexist: personal grants are host-renderable across products, never -returned to them. All handles are verified against the host Contacts lookup before committing the replacement; empty -audiences configure only the user's own profile. Existing V1 calls retain their app-scoped all-Chat behavior. Groups -remain product-owned sets of opaque handles, not a new host group API. +Version 2 of `profile.disclose` adds explicit `ChatApps`, `App { productId }`, and +`Contacts { handles }` audiences. App-scoped and selected-contact personal grants coexist: personal grants are +host-renderable across products, never returned to them. All handles are verified against the host Contacts lookup +before committing the replacement; empty audiences configure only the user's own profile. Existing V1 calls retain +their app-scoped all-Chat behavior. Groups remain product-owned sets of opaque handles, not a new host group API. -Personal relay uses distinct Chat content 22 (scope 1) and wallet/network-scoped `ProfilePersonalReferencesReceived` -storage. App content 21 is unchanged. Durable revisions, separate scoped watermarks and withdrawal tombstones prevent an -older personal share delivered through another app from reviving a withdrawn grant. Removing one audience does not -revoke an overlapping grant in another scope. Delivery still requires a ready authenticated Chat channel and a running -transport product; Contacts membership alone creates neither. +Personal relay uses distinct Chat content 22 (scope 1) and wallet/network-scoped +`ProfilePersonalReferencesReceived` storage. App content 21 is unchanged. Durable revisions, separate scoped +watermarks and withdrawal tombstones prevent an older personal share delivered through another app from reviving a +withdrawn grant. Removing one audience does not revoke an overlapping grant in another scope. Delivery still requires +a ready authenticated Chat channel and a running transport product; Contacts membership alone creates neither. Version 2 of `profile.presentContact` accepts either a peer identity or a Contacts handle and hides profile availability, including host rendering failures. V1 retains its app-only lookup and errors, so it cannot probe new cross-app personal grants. Version 3 of `profile.placeContactAvatars` accepts the same selectors alongside the own slot; -V1/V2 placement bytes and replies remain compatible. Contacts-change notifications invalidate cached handle lookups and -refresh remembered avatars. App-specific references take precedence over personal ones; personal updates redraw all -affected wallet placements. Personal revisions also advance the host-rendered freshness timestamp when a newer share -arrives through an actor whose clock is older, preventing a same-reference update from leaving stale cached profile -contents. +V1/V2 placement bytes and replies remain compatible. Contacts-change notifications invalidate cached handle lookups +and refresh remembered avatars. App-specific references take precedence over personal ones; personal updates redraw +all affected wallet placements. +Personal revisions also advance the host-rendered freshness timestamp when a newer share arrives through an actor +whose clock is older, preventing a same-reference update from leaving stale cached profile contents. -Add `contacts.pickMany` with preselected opaque handles and explicit picked, dismissed, and no-contacts outcomes. Add -`contacts.placeLabels` so Apps can reserve host-rendered contact names without receiving those names or profile +Add `contacts.pickMany` with preselected opaque handles and explicit picked, dismissed, and no-contacts outcomes. +Add `contacts.placeLabels` so Apps can reserve host-rendered contact names without receiving those names or profile availability. The core validates bounded placements and wallet-scoped handles, refreshes labels after Contacts changes, -and releases them when the connection closes. Hosts without a label surface return `Unsupported`; Worker products cannot -place labels. Clearing a session serializes removal of its remembered contact labels with pending refreshes. Host-side -interruption returns a Contacts domain error, reserving wire `Cancelled` for a peer's explicit cancellation. Failed -directory lookups preserve the prior label surface and report a retryable error instead of clearing it as if the -contacts were missing. +and releases them when the connection closes. Hosts without a label surface return `Unsupported`; Worker products +cannot place labels. Clearing a session serializes removal of its remembered contact labels with pending refreshes. +Host-side interruption returns a Contacts domain error, reserving wire `Cancelled` for a peer's explicit cancellation. +Failed directory lookups preserve the prior label surface and report a retryable error instead of clearing it as if +the contacts were missing. diff --git a/README.md b/README.md index 60b25ee6e..06e1d735c 100644 --- a/README.md +++ b/README.md @@ -51,21 +51,27 @@ dependencies, and editor types. Use `/script --run` to rerun it or `/script --ed survive session cleanup. See the [`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) for setup and existing project scripts. Release checks install and typecheck the default SDK template against the public registry. -`truapi-host signing-host --session ` opens an interactive session and restores or creates its signer. -`/session ` switches to the saved account or resumes an unfinished setup. In `exec` mode, `--session` selects the -command's session; inspection and clearing commands do not create accounts. A username base such as `workbench` selects -the most recently created local session with that base; `workbench.42` selects that exact session. Creating an account -from a session name requires at least six lowercase ASCII letters after digits and separators are omitted. A new -`/session foo` fails immediately as too short; existing saved accounts and aliases still restore normally. - -The signing host registers its built-in full and lite personhood keys when an authorized product first lists -`peopl.` (for example, `peopl.paseo`). The first listing reads People-chain metadata; later listings -reuse the saved registrations, including after restart. Registration makes the handles discoverable; proof creation -still checks permission and ring membership. The `listRingVrfKeys` example checks that both built-in keys are -discoverable under `peopl.paseo` on Paseo. - -Preimage lookups that miss the core's cache read the selected network's Bulletin node through `bitswap_v1_get`. The CLI -verifies the returned bytes against the requested key and keeps missing lookups subscribed until the blob arrives. +`truapi-host signing-host --session ` opens an interactive session and +restores or creates its signer. `/session ` switches to the saved account +or resumes an unfinished setup. In `exec` mode, `--session` selects the command's +session; inspection and clearing commands do not create accounts. A username +base such as `workbench` selects the most recently created local session with +that base; `workbench.42` selects that exact session. Creating an account from a +session name requires at least six lowercase ASCII letters after digits and +separators are omitted. A new `/session foo` fails immediately as too short; +existing saved accounts and aliases still restore normally. + +The signing host registers its built-in full and lite personhood keys when an +authorized product first lists `peopl.` (for example, +`peopl.paseo`). The first listing reads People-chain metadata; later listings +reuse the saved registrations, including after restart. Registration makes the +handles discoverable; proof creation still checks permission and ring membership. +The `listRingVrfKeys` example checks that both built-in keys are discoverable +under `peopl.paseo` on Paseo. + +Preimage lookups that miss the core's cache read the selected network's Bulletin +node through `bitswap_v1_get`. The CLI verifies the returned bytes against the +requested key and keeps missing lookups subscribed until the blob arrives. Bulletin submissions read the nonce and runtime metadata from current best-block state, but bind the 64-block mortal signature to a finalized checkpoint. A best @@ -78,12 +84,12 @@ Product scripts and `truapi-host dev` use the same web API permission checks fro container through a blocking script tag in your existing browser. Scripts run in Bun and retain filesystem, environment and process access. -To build from source, run `make headless install` with stable Rust, the nightly pinned in `nightly-toolchain` with -rustfmt, Node.js 22 or newer, and Bun installed. The target installs missing workspace build tools and regenerates the -Rust and TypeScript sources before compiling. CI tests this command in both a fresh checkout and one with stale -generated files, then runs a product script through the installed CLI. Code generation and the workspace documentation -check reject rustdoc warnings. These checks are part of the required `CI Status` gate. CLI packaging tests also build an -isolated runner and verify it outside the source checkout. +To build from source, run `make headless install` with stable Rust, the nightly pinned in `nightly-toolchain` with rustfmt, Node.js 22 or newer, and +Bun installed. The target installs missing workspace build tools and regenerates the Rust and TypeScript sources before +compiling. CI tests this command in both a fresh checkout and one with stale generated files, then runs a product script +through the installed CLI. Code generation and the workspace documentation check reject rustdoc warnings. These checks +are part of the required `CI Status` gate. CLI packaging tests also build an isolated runner and verify it outside the +source checkout. ## Usage @@ -122,17 +128,17 @@ retries resume the same recipient/amount operation; incoming batches require dur before acknowledgment. Delivery acknowledgment and finalized clearing are separate states. Signing hosts can use the host-private `getNativeChatContacts()` directory for trusted Contacts UI. It restores -authenticated, ready peers from authorized native Chat products, scopes them to the active wallet and People chain, and -never exposes a product or SSO directory method. Actors opened on this version are durably indexed; historical unindexed -Chat products must be opened once before their peers can appear. Pairing hosts do not supply this directory. +authenticated, ready peers from authorized native Chat products, scopes them to the active wallet and People chain, +and never exposes a product or SSO directory method. Actors opened on this version are durably indexed; historical +unindexed Chat products must be opened once before their peers can appear. Pairing hosts do not supply this directory. Contacts trait 20 retains the single picker at method 0, adds `pickMany({ selected })` at method 1, and -`placeLabels({ surfaceWidth, surfaceHeight, slots })` at method 2. Multi-select confirmation returns only wallet-scoped -handles, including a confirmed empty selection; dismissal never edits the audience. Host-owned labels show directory -usernames or account fallbacks independently of Profile photos, without returning names, accounts or per-slot -availability. Selections and placements are bounded to 256 entries; unresolved initial selections fail closed. Hosts -implement `pickContacts(product, ContactSelection)` and `placeContactLabels(product, PlacedContactLabels)` through the -canonical native/WASM/worker callbacks. +`placeLabels({ surfaceWidth, surfaceHeight, slots })` at method 2. Multi-select confirmation returns only +wallet-scoped handles, including a confirmed empty selection; dismissal never edits the audience. Host-owned +labels show directory usernames or account fallbacks independently of Profile photos, without returning names, +accounts or per-slot availability. Selections and placements are bounded to 256 entries; unresolved initial +selections fail closed. Hosts implement `pickContacts(product, ContactSelection)` and +`placeContactLabels(product, PlacedContactLabels)` through the canonical native/WASM/worker callbacks. The [native Chat/main-purse RFC](docs/rfcs/native-chat-main-purse.md) specifies the method 12 request/response and compatibility contract, device eligibility, custody-before-ACK rule, and delivery versus clearing semantics. It is a @@ -192,9 +198,9 @@ navigation and requires `Notifications` for push delivery. Hosts preserve the us `Deny` choice; Rust owns one-use grants for Rust-backed executions. Android permission prompts belong to one request and close when it finishes or is cancelled, including cancellation while the app is backgrounded. -The shared Rust core asks blessed products (`peopl`, `dim2` and `stash`, on every supported network) only for device -permissions and legacy-account signing. All other operations it handles bypass permission prompts and recorded -decisions. +The shared Rust core asks blessed products (`peopl`, `dim2` and `stash`, +on every supported network) only for device permissions and legacy-account signing. +All other operations it handles bypass permission prompts and recorded decisions. ## Repository layout @@ -241,10 +247,13 @@ scripts/battery.sh Run the generated battery against both headless CLI h scripts/bundle-size.mjs Measure the JS and WASM the truapi-* packages ship, against a baseline ``` -The PolkaVM application runtime, GPU/UI wire contracts, and browser runtime live in -[`paritytech/polkavm-host-runtime`](https://github.com/paritytech/polkavm-host-runtime). Native hosts that need both -runtimes link the optional `truapi-polkavm-host` composition crate; the base `truapi` remains PolkaVM-free. Browser -hosts consume `@parity/polkavm-browser-runtime` directly; browser assets are not shipped from this repository. +The PolkaVM application runtime, GPU/UI wire contracts, and browser runtime +live in +[`paritytech/polkavm-host-runtime`](https://github.com/paritytech/polkavm-host-runtime). +Native hosts that need both runtimes link the optional `truapi-polkavm-host` +composition crate; the base `truapi` remains PolkaVM-free. Browser hosts +consume `@parity/polkavm-browser-runtime` directly; browser assets are not +shipped from this repository. Taking a screenshot opens **Report app issue** wherever the shake-opened Debug menu is, which is every build except the store submission: `DEBUG_TOOLS_ENABLED` on Android, false only for the `release` build type, and `TESTNET_FEATURE` on @@ -267,27 +276,36 @@ logs** remain available. See the [proc-macro guide](rust/crates/truapi-macros/README.md) for typed SSO handlers, their shared response envelope, and the macro implementation modules. -The Swift host adapter (the `TrUAPIHost` SPM package over the truapi UniFFI core) lives under -[`ios/truapi-host/`](ios/truapi-host), with its SPM manifest at the repo root (`Package.swift`) so apps can consume it -as a git-URL dependency. The UniFFI bindings and the container bundle are gitignored build outputs; `scripts/rebuild.sh` -regenerates them along with the xcframework (`make xcframework` + `make uniffi`); see -[`ios/truapi-host/README.md`](ios/truapi-host/README.md). The container publishes the shared client and a temporary -MessagePort adapter for older SDKs. The adapter's removal is tracked in -[#881](https://github.com/paritytech/host-rust-core/issues/881); CLI and iframe MessagePort transports remain supported. -The [container permission boundary](js/container/README.md) documents the protected operations and the built-ins that -remain mutable for product compatibility. Native bindings expose the canonical Rust domain and protocol value types; -native-only adapter types are limited to lifecycle and callback behavior. Native hosts must create an app-private -directory excluded from device backups and pass its path as `HostRuntimeConfig.database_directory` (`databaseDirectory` -in Swift and Kotlin). The shared core opens SQLite once; product executions keep their own callbacks and consent scope -while sharing that database. The store is not compiled into the browser WASM bundles. See the -[core database contract](rust/crates/truapi/RUNTIME.md#core-database). On iOS, a wallet host that manages its own -statement-store SSO session can call `handleSsoRequest` (routes one decrypted remote message through the core, returning -a typed outcome: response bytes to post back, a disconnect marker, or ignored; a `Cancel` returns at once, so the wallet -passes it on without queueing it behind the request it withdraws) and `prepareDisconnectRequest` (builds the -SCALE-encoded wire message for a wallet-initiated disconnect) on `TrUAPIHostRuntime`. Response posting and -session-record cleanup remain on the wallet side. See the core's -[inter-host SSO design](rust/crates/truapi/RUNTIME.md#inter-host-sso) for typed handlers, canonical resource types, and -consent bound to the signing session. Product and SSO signing share canonical payloads and the one-byte `OptionBool` +The Swift host adapter (the `TrUAPIHost` SPM package over the truapi +UniFFI core) lives under [`ios/truapi-host/`](ios/truapi-host), with its SPM +manifest at the repo root (`Package.swift`) so apps can consume it as a git-URL +dependency. The UniFFI bindings and the container bundle are gitignored build +outputs; `scripts/rebuild.sh` regenerates them along with the xcframework +(`make xcframework` + `make uniffi`); see +[`ios/truapi-host/README.md`](ios/truapi-host/README.md). +The container publishes the shared client and a temporary MessagePort adapter for +older SDKs. The adapter's removal is tracked in [#881](https://github.com/paritytech/host-rust-core/issues/881); +CLI and iframe MessagePort transports remain supported. +The [container permission boundary](js/container/README.md) documents the protected +operations and the built-ins that remain mutable for product compatibility. +Native bindings expose the canonical Rust domain and protocol value types; +native-only adapter types are limited to lifecycle and callback behavior. +Native hosts must create an app-private directory excluded from device backups +and pass its path as `HostRuntimeConfig.database_directory` (`databaseDirectory` +in Swift and Kotlin). The shared core opens SQLite once; product executions keep +their own callbacks and consent scope while sharing that database. The store is +not compiled into the browser WASM bundles. See the +[core database contract](rust/crates/truapi/RUNTIME.md#core-database). +On iOS, a wallet host that manages its own statement-store SSO session can call +`handleSsoRequest` (routes one decrypted remote message through the core, +returning a typed outcome: response bytes to post back, a disconnect marker, or +ignored; a `Cancel` returns at once, so the wallet passes it on without queueing +it behind the request it withdraws) and `prepareDisconnectRequest` (builds the SCALE-encoded wire message +for a wallet-initiated disconnect) on `TrUAPIHostRuntime`. Response posting and +session-record cleanup remain on the wallet side. +See the core's [inter-host SSO design](rust/crates/truapi/RUNTIME.md#inter-host-sso) +for typed handlers, canonical resource types, and consent bound to the signing session. +Product and SSO signing share canonical payloads and the one-byte `OptionBool` encoding for `with_signed_transaction`. ### JS Host SDKs @@ -302,20 +320,26 @@ tree-shakeable subpath entries: `createWorkerHostRuntime` shares the native core while `createProvider(product, callbacks)` binds platform callbacks to one product execution. The host UI disposes that execution's pending consent when its provider closes; wallet -authentication and storage remain core-owned. See the [host SDK](js/packages/truapi-host/README.md) for lifecycle -details. +authentication and storage remain core-owned. See the [host SDK](js/packages/truapi-host/README.md) for lifecycle details. ### Chain transport -A host that serves chain traffic itself embeds the `truapi-provider` crate: an embedded smoldot light client plus a -bundled chain-spec catalog, addressed by genesis hash, so the host ships no chain specs and never refreshes them. The -light client holds at most 32 connections at once and refuses a `connect` past that, so a consumer that leaks them fails -instead of growing; closing one hands its slot back. Connections to a remote node, which only the WASM build compiles, -are not counted against it. A light-client connection holds its requests until the chain first syncs and then forwards -them in order; chain-spec queries, statement-store and Bitswap calls, and the `lifecycle_unstable_*` subscription that -reports the sync are forwarded at once. Every artifact exposes the sync progress of a running chain (phase, peer count, -stall verdict) as a watch. The crate compiles to one binary artifact per platform, each exposing the same -`ChainProvider` contract, so a consumer needs neither a Rust toolchain nor a dependency on the crate: +A host that serves chain traffic itself embeds the `truapi-provider` crate: an +embedded smoldot light client plus a bundled chain-spec catalog, addressed by +genesis hash, so the host ships no chain specs and never refreshes them. The light +client holds at most 32 connections at once and refuses a `connect` past that, so a +consumer that leaks them fails instead of growing; closing one hands its slot back. +Connections to a remote node, which only the WASM build compiles, are not counted +against it. A light-client connection holds its requests until the chain first +syncs and then forwards them in order; chain-spec queries, statement-store and +Bitswap calls, and the `lifecycle_unstable_*` subscription that reports the sync are forwarded at +once. +Every artifact exposes the sync progress of a running chain (phase, peer count, +stall verdict) as a watch. +The crate +compiles to one binary artifact per platform, each exposing the same +`ChainProvider` contract, so a consumer needs neither a Rust toolchain nor a +dependency on the crate: - [`@parity/truapi-provider`](js/packages/truapi-provider) is the WASM build for browser and webview hosts, rebuilt by `make wasm` alongside the host bundle. @@ -332,20 +356,25 @@ encrypted. ### Wire debugger -[`@parity/truapi-debugger`](js/packages/truapi-debugger) is the consumer for the payload-blind frame tap in `truapi`. -The core streams raw SCALE frames out of two choke points; the debugger correlates them into per-operation traces, -decodes envelopes and values behind a `TRUAPI_WIRE_SCHEMA_HASH` match, and renders them through one of two mounts: +[`@parity/truapi-debugger`](js/packages/truapi-debugger) is the consumer for the +payload-blind frame tap in `truapi`. The core streams raw SCALE frames out +of two choke points; the debugger correlates them into per-operation traces, +decodes envelopes and values behind a `TRUAPI_WIRE_SCHEMA_HASH` match, and renders +them through one of two mounts: -- `startDebugServer(...)` is a standalone Bun WS+HTTP server on `127.0.0.1:9231` that hosts dial into, so frames from - any host reach one inspector. -- `createInAppDebugger(...)` mounts the same engine inside the host page, with no server and no dial. +- `startDebugServer(...)` is a standalone Bun WS+HTTP server on `127.0.0.1:9231` + that hosts dial into, so frames from any host reach one inspector. +- `createInAppDebugger(...)` mounts the same engine inside the host page, with no + server and no dial. All decoding lives in this package; `@parity/truapi` has no debug seam. Its -[README](js/packages/truapi-debugger/README.md) carries the endpoint list and the per-host enablement recipe. +[README](js/packages/truapi-debugger/README.md) carries the endpoint list and the +per-host enablement recipe. -`make debugger` brings up the inspector on `:9231` alongside a dot.li host and the playground. It builds the host with -`NODE_ENV=development` on purpose: the dial sits behind `import.meta.env.DEV`, which a production bundle replaces with -`false`, so `make dev` leaves the board empty with no error. +`make debugger` brings up the inspector on `:9231` alongside a dot.li host and the +playground. It builds the host with `NODE_ENV=development` on purpose: the dial +sits behind `import.meta.env.DEV`, which a production bundle replaces with `false`, +so `make dev` leaves the board empty with no error. ## How it works @@ -382,12 +411,14 @@ make wasm # rebuild truapi WASM artifacts under js/packages/truapi-host/dist CI regenerates the shared bindings before building and testing both npm packages, so generated client and host callback changes are checked together. -CLI transcript tests share process-wide UI output. Match captured events by request identity rather than queue position: -other tests may emit unrelated logs through the installed UI while the suite runs in parallel. +CLI transcript tests share process-wide UI output. Match captured events by +request identity rather than queue position: other tests may emit unrelated +logs through the installed UI while the suite runs in parallel. -The native `truapi-host` utility runs pairing and signing hosts against the real SSO transport for local end-to-end -work. See [Install the CLI](#install-the-cli) to get it, and the -[`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) for its commands and controls. +The native `truapi-host` utility runs pairing and signing hosts against the real +SSO transport for local end-to-end work. See [Install the CLI](#install-the-cli) +to get it, and the [`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) +for its commands and controls. CLI reserved identities follow the selected network's dotNS suffix. Old account and pairing stores are left unused as the CLI starts fresh under its [versioned state directory](rust/crates/truapi-host-cli/README.md#state-directory). @@ -429,22 +460,30 @@ the host already live. The product reaches it through a development-only `.md`, which names the range, the pull requests in it, and what has to be done to finish the work. -Completing that pull request means running the command above and deleting the file. +Drift is picked up on a schedule. `.github/workflows/backport-host.yml` opens a +pull request carrying a single `BACKPORT-.md`, which names the range, the +pull requests in it, and what has to be done to finish the work. Completing that +pull request means running the command above and deleting the file. ### Working on the iOS host @@ -510,17 +555,21 @@ until they exist. Generate them once: make ios-bootstrap ``` -Then open `hosts/ios/polkadot-app.xcodeproj`. Rerun it after changing anything the bindings are generated from, which is -the `truapi` or `truapi-provider` crates. `SIM_ONLY=1` halves it by skipping the device slice, which is enough for -Simulator but not for an archive. - -Because the app builds against the core in this tree, a core change that breaks it fails here rather than at the next -version bump. Every push to main runs the jobs below, and so does a pull request touching the app, or the crates its -bindings come from, once it is labelled `ios-simulator-build`. They are macOS jobs, so a pull request without the label -runs none of them. CI's `iOS package (Swift + WebKit)` job still compiles the TrUAPIHost package against the core on a -pull request touching `ios/` or the core crates, but the app itself is compiled before merge only with the label. The -first time a pull request touches the iOS or Android app, `build-label-hint.yml` comments with the labels that build it: -`ios-simulator-build`, `ios-device-build` and `android-device-build`. +Then open `hosts/ios/polkadot-app.xcodeproj`. Rerun it after changing anything +the bindings are generated from, which is the `truapi` or `truapi-provider` +crates. `SIM_ONLY=1` halves it by skipping +the device slice, which is enough for Simulator but not for an archive. + +Because the app builds against the core in this tree, a core change that breaks +it fails here rather than at the next version bump. Every push to main runs the +jobs below, and so does a pull request touching the app, or the crates its +bindings come from, once it is labelled `ios-simulator-build`. They are macOS +jobs, so a pull request without the label runs none of them. CI's +`iOS package (Swift + WebKit)` job still compiles the TrUAPIHost package against +the core on a pull request touching `ios/` or the core crates, but the app itself +is compiled before merge only with the label. The first time a pull request +touches the iOS or Android app, `build-label-hint.yml` comments with the labels +that build it: `ios-simulator-build`, `ios-device-build` and `android-device-build`. - `build`, a DevCI compile, failing on any build warning the committed baseline does not already have - `test`, the unit test suite @@ -567,12 +616,15 @@ Two workflows deliver through Firebase App Distribution, which reaches a named t link. That matters beyond convenience: these builds carry configuration that should not be public, so attaching them to a release is not an option. -`android-nightly.yml` runs daily at 22:00 UTC, two hours after the iOS nightly starts, so the two never overlap. Each -announcement lists the pull requests the build carries, with breaking changes, the titles carrying `!`, listed first and -marked `Breaking:`. Both nightlies skip a scheduled night when `main` has not moved past what their last successful run -built. `android-debug-distribution.yml` runs when a pull request merges to `main`, and answers what `main` does right -now. It builds the merge commit rather than the pull request's merge preview, which is computed while the request is -open and would otherwise ship a tree missing whatever landed first. +`android-nightly.yml` runs daily at 22:00 UTC, two hours after the iOS +nightly starts, so the two never overlap. Each announcement lists the pull +requests the build carries, with breaking changes, the titles carrying `!`, +listed first and marked `Breaking:`. Both nightlies skip a scheduled night +when `main` has not moved past what their last successful run built. `android-debug-distribution.yml` runs +when a pull request merges to `main`, and answers what `main` does right now. +It builds the merge commit rather than the pull request's merge preview, which +is computed while the request is open and would otherwise ship a tree missing +whatever landed first. Both authenticate by federation. The run proves its identity with its OIDC token and receives a short lived credential, so no long lived key for that project is stored here. Both check the delivery target before building, since an hour is diff --git a/docs/rfcs/contacts-api.md b/docs/rfcs/contacts-api.md index b20ad3952..36fac6f24 100644 --- a/docs/rfcs/contacts-api.md +++ b/docs/rfcs/contacts-api.md @@ -8,25 +8,27 @@ status: draft ## Summary -_How the implemented pieces fit together is in [Contacts Pick, End to End](../design/contacts-pick-end-to-end.md)._ +_How the implemented pieces fit together is in +[Contacts Pick, End to End](../design/contacts-pick-end-to-end.md)._ -A product asks the Host to let the user pick one or more contacts. The Host renders the picker from its contact -directory and returns opaque handles, never the list, names or accounts. The handle is not an address: the core resolves -it when building a transaction. Host-owned name labels let users recognize selected handles without sharing the names or -requiring a Profile photo. +A product asks the Host to let the user pick one or more contacts. The Host renders the +picker from its contact directory and returns opaque handles, never the list, names or +accounts. The handle is not an address: the core resolves it when building a transaction. +Host-owned name labels let users recognize selected handles without sharing the names +or requiring a Profile photo. ## Motivation -Each user has a different alias and account per context, so no handle identifies a person across products, and a contact -list is how a user keeps that private notebook. Products cannot use any of it today, so users paste raw keys. But "send -this NFT to a friend" needs one recipient the user chose, not the address book — so this exposes the interaction rather -than the list. +Each user has a different alias and account per context, so no handle identifies a person across +products, and a contact list is how a user keeps that private notebook. Products cannot use any of it +today, so users paste raw keys. But "send this NFT to a friend" needs one recipient the user chose, +not the address book — so this exposes the interaction rather than the list. ## Approach -Contacts come from the chat lists the Host's chat extensions hold; Hosts keep their own schema and no new address book -is imposed. A Host **renders the picker itself** and resolves handles on request, so names never cross to the product — -which matters because a Host's only name for a contact is often a globally correlatable People-chain username. +Contacts come from the chat lists the Host's chat extensions hold; Hosts keep their own schema +and no new address book is imposed. A Host **renders the picker itself** and resolves handles on request, so names never cross to the product — which matters because a Host's only name for a contact +is often a globally correlatable People-chain username. ```rust enum ContactPickOutcome { @@ -38,62 +40,59 @@ enum ContactPickOutcome { fn host_contacts_pick() -> Result; ``` -Three outcomes because the retry decision differs: `Dismissed` is worth offering again, `NoContacts` is not, and a Host -with no picker answers `Unsupported`. No permission is requested — the user selecting a contact is the consent. +Three outcomes because the retry decision differs: `Dismissed` is worth offering again, `NoContacts` +is not, and a Host with no picker answers `Unsupported`. No permission is requested — the user +selecting a contact is the consent. -The handle is one value per contact, the same in every product and on every Host of this user, keyed on the user's -entropy so no product can turn it back into an account. It is not an address: a product names it as the recipient and -the core substitutes the account when it builds the transaction. A product-scoped address is not derivable at all, which -is why the handle is resolvable rather than directly usable. +The handle is one value per contact, the same in every product and on every Host of this user, keyed +on the user's entropy so no product can turn it back into an account. It is not an address: a product +names it as the recipient and the core substitutes the account when it builds the transaction. A +product-scoped address is not derivable at all, which is why the handle is resolvable rather than +directly usable. ### Multi-select audiences -Trait 20 method 0 remains `pick`. Method 1, `pickMany({ selected })`, edits a complete selection of at most 256 handles. -The core deduplicates and resolves the initial selection before opening the picker; any unresolved handle rejects the -whole request. The host callback `pickContacts(product, ContactSelection { selected })` receives accounts only inside -the trusted host boundary. Confirming an empty selection returns `Picked { handles: [] }`; closing the picker returns -`Dismissed`. Session or directory invalidation during resolution or confirmation cancels the change. +Trait 20 method 0 remains `pick`. Method 1, `pickMany({ selected })`, edits a complete +selection of at most 256 handles. The core deduplicates and resolves the initial +selection before opening the picker; any unresolved handle rejects the whole request. +The host callback `pickContacts(product, ContactSelection { selected })` receives +accounts only inside the trusted host boundary. Confirming an empty selection returns +`Picked { handles: [] }`; closing the picker returns `Dismissed`. Session or directory +invalidation during resolution or confirmation cancels the change. ### Host-owned contact labels -Method 2, `placeLabels({ surfaceWidth, surfaceHeight, slots })`, replaces at most 256 name rectangles. Each slot -supplies `{ slot, handle, rect, clip }`, reusing `AvatarRect`. The host resolves handles and draws directory usernames, -or account fallbacks, on its own layer. Names do not depend on Profile disclosure. Missing contacts leave no label and -produce the same success response; products never receive names or availability. Surfaces and rectangle sides are -bounded to 16384 units, clip sides may be zero, and slot ids must be unique. Empty slots, connection teardown and -session changes clear the layer. On same-wallet directory invalidation, the host clears stale names and refreshes the -latest live placement without another product request. +Method 2, `placeLabels({ surfaceWidth, surfaceHeight, slots })`, replaces at most 256 +name rectangles. Each slot supplies `{ slot, handle, rect, clip }`, reusing `AvatarRect`. +The host resolves handles and draws directory usernames, or account fallbacks, on its +own layer. Names do not depend on Profile disclosure. Missing contacts leave no label +and produce the same success response; products never receive names or availability. +Surfaces and rectangle sides are bounded to 16384 units, clip sides may be zero, and +slot ids must be unique. Empty slots, connection teardown and session changes clear +the layer. On same-wallet directory invalidation, the host clears stale names and +refreshes the latest live placement without another product request. + ## Trade-offs - A host that serves no picker answers `Unsupported`, which a product cannot retry its way out of. - `NoContacts` reveals whether the user has any contacts — zero-or-not, never a count. - No product-rendered contact directory: every selection is a host-owned user interaction. -- Dropped: returning the list scoped per product (`display_name` was a correlator no scoping fixed, and it needed a - permission over the whole social graph); per-product handles (forfeit a durable shared id, break under contact sync); - returning the chat account (transactable, but a global identifier any two products can join on); an unkeyed handle, or - one keyed on the root account key (recoverable by hashing enumerable accounts). +- Dropped: returning the list scoped per product (`display_name` was a correlator no scoping fixed, + and it needed a permission over the whole social graph); per-product handles (forfeit a durable + shared id, break under contact sync); returning the chat account (transactable, but a global + identifier any two products can join on); an unkeyed handle, or one keyed on the root account key + (recoverable by hashing enumerable accounts). ## Substitution at signing -A product declares the handles its call names, on the transaction payload, and the Host replaces exactly those 32-byte -runs with the accounts they resolve to. It declares them rather than passing an offset because an offset is a number the -product computes about its own encoding and gets wrong silently, while a declared handle is either in the call or it is -not: a Host that cannot find one refuses, rather than signing a call that names somebody else. A handle no contact -matches refuses the same way, which is the only revocation this API has. The core sends the Host only the handles it has -not cached, with the key they were minted under; the Host answers an account per handle and the core re-hashes each one, -so a wrong answer refuses rather than pays. A Host empties the cache by signalling that its contacts changed. The signed -call returns to the product with the real account in it, so a call naming contacts always asks the user, even under an -auto-signing grant; a handle in the call that is not declared refuses rather than pays an address nobody holds. -`contacts` never crosses to the signing host: the pairing Host relays the substituted call in the existing SSO shape, so -host-papp and deployed wallets are unaffected. - -Substitution happens before the confirmation, so the signing overlay is drawn from a call that names an account the Host -can put a name to. That is what closes the display gap for the flow that matters: a product renders a neutral chip, and -the user sees who they are paying in trusted UI at the moment of consent. +A product declares the handles its call names, on the transaction payload, and the Host replaces exactly those 32-byte runs with the accounts they resolve to. It declares them rather than passing an offset because an offset is a number the product computes about its own encoding and gets wrong silently, while a declared handle is either in the call or it is not: a Host that cannot find one refuses, rather than signing a call that names somebody else. A handle no contact matches refuses the same way, which is the only revocation this API has. The core sends the Host only the handles it has not cached, with the key they were minted under; the Host answers an account per handle and the core re-hashes each one, so a wrong answer refuses rather than pays. A Host empties the cache by signalling that its contacts changed. The signed call returns to the product with the real account in it, so a call naming contacts always asks the user, even under an auto-signing grant; a handle in the call that is not declared refuses rather than pays an address nobody holds. `contacts` never crosses to the signing host: the pairing Host relays the substituted call in the existing SSO shape, so host-papp and deployed wallets are unaffected. + +Substitution happens before the confirmation, so the signing overlay is drawn from a call that names an account the Host can put a name to. That is what closes the display gap for the flow that matters: a product renders a neutral chip, and the user sees who they are paying in trusted UI at the moment of consent. ## Recognition outside signing -A product holds only handles and reserves rectangles for `placeLabels`. The host draws names in those rectangles without -returning a global correlator. Profile avatar slots remain separate and photo-only, so users can recognize a contact -even when that contact has never shared a profile. +A product holds only handles and reserves rectangles for `placeLabels`. The host +draws names in those rectangles without returning a global correlator. Profile avatar +slots remain separate and photo-only, so users can recognize a contact even when that +contact has never shared a profile. diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index ae3ae069b..d68ac1c4a 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -27,8 +27,8 @@ which is what lets the test host hold keys and answer resource allocation as gra browser wallet instead needs a web bundle built with `--no-default-features --features wasm-signing-host`, without `test-host`. That enables native signing and wallet administration without the testing-only allocation shortcuts. Build that wallet variant with `npm run build:wasm -- --web-only --signing-host`. The default web build remains pairing-only. -Run the package tests against the generated web/testing bundles, then exercise the consuming host's real worker with the -selected variant. `ProductRuntimeConfig` configures the pairing host and requires no network suffix. The signing +Run the package tests against the generated web/testing bundles, then exercise the consuming host's real worker with +the selected variant. `ProductRuntimeConfig` configures the pairing host and requires no network suffix. The signing constructor's configuration requires `runtimeConfig.networkSuffix` in addition: the bare TLD (`dot`, `paseo`, or `testnet`) matching the People chain and the wallet's onboarding configuration. @@ -82,11 +82,11 @@ The optional callback receives `LocalIdentityProgress` (exported from `@parity/t `authenticating`, `submitting`, `confirming`, or `retrying` with a chain-read `error`. Stages reflect actual work, not elapsed-time estimates. `confirming` means the backend accepted the request, not that the username is owned yet; only the resolved promise confirms ownership. A retry does not submit another registration. Observer exceptions do not -interrupt the operation, and settled or disposed requests receive no further progress. Both methods return -`LocalIdentity` (exported from `@parity/truapi-host/web`): the canonical lowercase `0x`-prefixed `identityAccountId` and -an optional verified `liteUsername`. The backend must allow the worker's origin, or the host must provide an approved -same-origin proxy. Secret material stays in the signing runtime. Disconnecting or replacing the local activation -invalidates an in-flight identity operation; concurrent identity operations are rejected. +interrupt the operation, and settled or disposed requests receive no further progress. +Both methods return `LocalIdentity` (exported from `@parity/truapi-host/web`): the canonical lowercase `0x`-prefixed +`identityAccountId` and an optional verified `liteUsername`. The backend must allow the worker's origin, or the host +must provide an approved same-origin proxy. Secret material stays in the signing runtime. Disconnecting or replacing the +local activation invalidates an in-flight identity operation; concurrent identity operations are rejected. ### Read-only wallet allowance inspection @@ -197,44 +197,45 @@ reading as usable. Omit it and a stored grant answers on its own. replacement, and `removePocketCard` takes one out. The host owns the collection: removing an absent card succeeds, and a card the host pins is refused with `Privileged`. -`profile.presentProfile` shows the profile a product references in host-owned UI and resolves once it is shown, not when -the user dismisses it. The reference is a bearer capability: the host fetches, decrypts and renders it, and the +`profile.presentProfile` shows the profile a product references in host-owned UI and resolves once it is shown, not +when the user dismisses it. The reference is a bearer capability: the host fetches, decrypts and renders it, and the profile's bytes never return to the product. The core forwards only references that are non-empty, at most 2048 bytes and printable ASCII without whitespace; parsing the format is the host's. `profile.presentContactProfile(product, presented)` shows the profile a Chat contact shared when a chat product calls `profile.presentContact`. `presented` carries the `reference`, the `peerIdentity` of the contact whose authenticated Chat device delivered it, the `sharedAt` freshness timestamp (`bigint`) and, when the core knows it, the contact's -`username`, so the drawer can say who shared it rather than which product asked. The username is the one the core's Chat -roster verified for that contact, else the contact's verified dotNS name, looked up for at most 2 seconds; never a name -from the product. Without one, name the contact generically, never by address. It names who sent the reference, not -whose profile it is: the record is not signed by its owner, and a contact can forward someone else's. Same contract as -`presentProfile` otherwise. A `profile` group without it, from a host built before it, has contacts' profiles presented -through `presentProfile`. +`username`, so the drawer can say who shared it rather than which product asked. The username is the one the core's +Chat roster verified for that contact, else the contact's verified dotNS name, looked up for at most 2 seconds; never a +name from the product. Without one, name the contact generically, never by address. It names who sent the reference, +not whose profile it is: the record is not signed by its owner, and a contact can forward someone else's. Same contract +as `presentProfile` otherwise. A `profile` group without it, from a host built before it, has contacts' profiles +presented through `presentProfile`. `profile.placeContactAvatars(product, placed)` draws contacts' avatars over a chat product. `placed` carries the product's surface size and, per avatar, the product's `slot` id, a square `rect`, the `clip` region it is cut to, all in surface units (framebuffer pixels for a PolkaVM product, CSS pixels of the viewport for a web product), and the `reference` that contact disclosed, so the host can draw their photo and mood ring, with a `sharedAt` freshness token (`bigint`). Contact tokens use Unix ms, advanced monotonically for personal revisions across relay actors; the own -avatar uses the disclosure revision, not a date. A changed token invalidates cached contents. Each call replaces what -was drawn for the product; an empty `avatars` clears it. The core calls it again with the same geometry when a contact +avatar uses the disclosure revision, not a date. A changed token invalidates cached contents. Each call replaces what was +drawn for the product; an empty `avatars` clears it. The core calls it again with the same geometry when a contact shares, re-shares or withdraws a profile, and with no avatars when the product's connection goes away. Draw on a layer -the product cannot read that lets pointer input through, and never tell the product what was drawn. The host runtimes -take `RequiredHostCallbacks`, so a `profile` group implements it and `presentContactProfile` alongside `presentProfile`. +the product cannot +read that lets pointer input through, and never tell the product what was drawn. The host runtimes take +`RequiredHostCallbacks`, so a `profile` group implements it and `presentContactProfile` alongside `presentProfile`. `profile.disclose` needs no `profile` group, but the first call from a product asks the user through `userConfirmation.confirmPermission` with a `ProfileDisclosure` review naming that product. V1 shares app-scoped references with every ready Chat contact; V2 can select apps or opaque Contacts handles. Personal grants are -host-renderable across recipient apps. The answer is kept like any other permission, as `ProfileDisclosure`. Audience -mutations currently reuse that product-level consent. A host that cannot render the review should reject the call rather -than answer `Deny`: the product is refused, but no refusal is remembered. +host-renderable across recipient apps. The answer is kept like any other permission, as `ProfileDisclosure`. +Audience mutations currently reuse that product-level consent. A host that cannot render the +review should reject the call rather than answer `Deny`: the product is refused, but no refusal is remembered. `presentContact` V2 accepts peer or Contacts-handle selectors and hides sharing availability; V1 remains app-only. `placeContactAvatars` V3 accepts those selectors alongside the V2 own slot. V1/V2 placement bytes remain compatible. -Hosts must call `notifyContactsChanged()` after directory changes so stale handle resolution and overlays clear. These -APIs do not create a Chat channel or a group editor. See the [Profile RFC](../../../docs/rfcs/profile-disclosure.md) for -audience, transport and withdrawal semantics. +Hosts must call `notifyContactsChanged()` after directory changes so stale handle resolution and overlays clear. +These APIs do not create a Chat channel or a group editor. See the +[Profile RFC](../../../docs/rfcs/profile-disclosure.md) for audience, transport and withdrawal semantics. Under `createWebWorkerPairingHostRuntime` the presence of each optional group is reported to the worker in its `init` message, so the core sees the same capability set on both sides of the boundary. @@ -311,38 +312,44 @@ The index crosses as a SCALE-encoded `DerivationIndex`, the same value a review code behind it stays core-owned and a host never reconstructs it. `productAccountAddress` applies the prefix host-spec C.6 fixes, rather than leaving each host to choose one. -The optional `contacts` group resolves handles through `contacts({ handleKey, handles })`: one entry per handle, in -order, the account or `undefined`. `pickContact` draws a single picker and returns the chosen account. -`pickContacts(product, { selected })` edits a complete selection of at most 256 resolved accounts, returning -`Picked { accounts }`, `Dismissed`, or `NoContacts`. A confirmed empty array is `Picked`, not dismissal. Missing picker -callbacks answer `Unsupported`. A contact's handle is BLAKE2b-256 keyed with `handleKey` over its 32-byte account -(`blake2b(account, { key: handleKey, dkLen: 32 })` in `@noble/hashes`). The core re-checks every account returned. It -caches what it resolves, so call `notifyContactsChanged()` whenever a contact is removed or blocked. Omit blocked +The optional `contacts` group resolves handles through `contacts({ handleKey, handles })`: +one entry per handle, in order, the account or `undefined`. `pickContact` draws a single +picker and returns the chosen account. `pickContacts(product, { selected })` edits a +complete selection of at most 256 resolved accounts, returning `Picked { accounts }`, +`Dismissed`, or `NoContacts`. A confirmed empty array is `Picked`, not dismissal. +Missing picker callbacks answer `Unsupported`. +A contact's handle is BLAKE2b-256 keyed with `handleKey` over its 32-byte +account (`blake2b(account, { key: handleKey, dkLen: 32 })` in `@noble/hashes`). +The core re-checks every account returned. It caches what it resolves, so call +`notifyContactsChanged()` whenever a contact is removed or blocked. Omit blocked contacts from both. See the contacts RFC (`docs/rfcs/contacts-api.md`). -`placeContactLabels(product, placed)` receives surface dimensions and `labels: [{ slot, account, rect, clip }]`. Draw -names from the host's contact directory, using an account fallback when no username exists. Profile-photo absence must -not hide a name. Keep this UI host-owned: return no label or per-slot availability. Return `true` when the host supports -label placement, even when no contact resolves. Return `false` when that UI is unsupported; the adapter supplies this -answer when the callback is omitted. This capability acknowledgment never reports individual contact availability. -Background Workers are denied label placement. Empty placements clear the previous names and cancel queued refreshes. -Clear names and cancel pending work on frame load, navigation or disconnect. On same-wallet directory invalidation, -clear stale names and refresh the latest live placement without waiting for the product to resend it. The core -serializes placements per connection and rejects selections from changed sessions. +`placeContactLabels(product, placed)` receives surface dimensions and +`labels: [{ slot, account, rect, clip }]`. Draw names from the host's contact directory, +using an account fallback when no username exists. Profile-photo absence must not +hide a name. Keep this UI host-owned: return no label or per-slot availability. +Return `true` when the host supports label placement, even when no contact resolves. +Return `false` when that UI is unsupported; the adapter supplies this answer when +the callback is omitted. This capability acknowledgment never reports individual +contact availability. Background Workers are denied label placement. +Empty placements clear the previous names and cancel queued refreshes. Clear names +and cancel pending work on frame load, navigation or disconnect. On same-wallet +directory invalidation, clear stale names and refresh the latest live placement +without waiting for the product to resend it. The core serializes placements per +connection and rejects selections from changed sessions. Browser signing hosts can back this UI with `runtime.getNativeChatContacts()`. It returns -`{ walletPublicKey, genesisHash, contacts: [{ peerIdentity, username? }] }` to trusted host code only. The directory -restores encrypted native Chat actors, checks their current authorization, includes only authenticated ready peers, and -deduplicates identities. Conflicting verified names are omitted. It is not a product or SSO API; pairing hosts reject -it. Bind the result to the active signing public key and People genesis, never to a username. Native -departures/revocations remove readiness; product-private block lists are not a separate directory source. +`{ walletPublicKey, genesisHash, contacts: [{ peerIdentity, username? }] }` to trusted host code only. +The directory restores encrypted native Chat actors, checks their current authorization, includes only authenticated +ready peers, and deduplicates identities. Conflicting verified names are omitted. It is not a product or SSO API; +pairing hosts reject it. Bind the result to the active signing public key and People genesis, never to a username. +Native departures/revocations remove readiness; product-private block lists are not a separate directory source. Actors are indexed when opened. Historical unindexed products must be opened once on the upgraded host; private storage -has no enumeration API. Directory reads share native commit gates, and native state/session/permission changes -invalidate cached handles. A browser adapter must also cancel pending picker/lookup work when its wallet, network, -provider or directory generation changes. Provider-scoped `contacts` callbacks control that provider's UI; absent -overrides inherit the runtime-wide Contacts adapter. Keep the runtime-wide source alive for host-owned rendering until -the owner closes. +has no enumeration API. Directory reads share native commit gates, and native state/session/permission changes invalidate +cached handles. A browser adapter must also cancel pending picker/lookup work when its wallet, network, provider or +directory generation changes. Provider-scoped `contacts` callbacks control that provider's UI; absent overrides inherit +the runtime-wide Contacts adapter. Keep the runtime-wide source alive for host-owned rendering until the owner closes. ## Generated WASM artefacts diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index f5cca70ff..605d3e32e 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -4,7 +4,8 @@ _Runtime core for TrUAPI: dispatcher, protocol frames, SCALE-coded wire envelope ## What the runtime is for -The `runtime` feature of `truapi` turns trait implementations of the protocol API into a working host. It owns: +The `runtime` feature of `truapi` turns trait implementations of the +protocol API into a working host. It owns: - the [`ProtocolMessage`] wire envelope and SCALE codec - the [`Dispatcher`] that routes incoming frames to per-method handlers @@ -205,13 +206,16 @@ resolve it through the host's `PermissionStatusHost`, so an OS refusal reads as stored product decision is never overwritten by it. Remote, identity-disclosure and account-access decisions have no OS gate. -The embedder builds a role handle, `PairingHostRuntime::new(...)` or `SigningHostRuntime::new(...)`, then calls -`product_runtime(product, sink)` for each product connection. Role-specific operations live only on the matching handle: -`cancel_pairing`, `notify_session_store_changed`, `activate_stored_session`, `activate_external_session`, and -`reset_session_state` on the pairing handle, `activate_local_session` on the signing handle. Both handles expose -`clear_product_state` to revoke one product's capability material without touching the session or other products, and -`notify_contacts_changed` to drop the contact handles the core cached once a contact is removed or blocked. Calling the -wrong operation is a compile error, not a runtime `Unavailable`. +The embedder builds a role handle, `PairingHostRuntime::new(...)` or +`SigningHostRuntime::new(...)`, then calls `product_runtime(product, sink)` for +each product connection. Role-specific operations live only on the matching handle: +`cancel_pairing`, `notify_session_store_changed`, `activate_stored_session`, +`activate_external_session`, and `reset_session_state` on the pairing handle, +`activate_local_session` on the signing handle. Both handles expose +`clear_product_state` to revoke one product's capability material without +touching the session or other products, and `notify_contacts_changed` to drop +the contact handles the core cached once a contact is removed or blocked. Calling the wrong operation is +a compile error, not a runtime `Unavailable`. `SigningHostConfig.network_suffix` is the network's bare dotNS TLD (`dot`, `paseo`, or `testnet`). The shell supplies it alongside the chain genesis hashes from the same network configuration used by wallet onboarding. It must match the @@ -250,12 +254,15 @@ responsibility for secure unlock. ### Core database -Native signing hosts (iOS, Android, the CLI) give the core a directory for its own SQLite database (`store` module, -bundled SQLite through `rusqlite` and `async-sqlite`). The runtime opens `core.sqlite3` there at startup, so a missing -or unwritable directory stops it from starting. Keep the directory out of device backups: it holds durable-transaction -state that must not be restored onto another device. The required `HostRuntimeConfig.database_directory` sets it on iOS -and Android, `SigningHostRuntime::set_core_db` on any other embedder, and `core_database_status()` reports the SQLite -version, schema version and path. Web hosts do not compile the store. +Native signing hosts (iOS, Android, the CLI) give the core a directory for its +own SQLite database (`store` module, bundled SQLite through `rusqlite` and +`async-sqlite`). The runtime opens `core.sqlite3` there at startup, so a +missing or unwritable directory stops it from starting. Keep the directory out +of device backups: it holds durable-transaction state that must not be restored +onto another device. The required `HostRuntimeConfig.database_directory` sets +it on iOS and Android, `SigningHostRuntime::set_core_db` on any other embedder, +and `core_database_status()` reports the SQLite version, schema version and +path. Web hosts do not compile the store. ### The two roles @@ -297,10 +304,12 @@ requests use the canonical `truapi::latest::AllocatableResource` type. Signing u Product-scoped VRF requests use `ProductRequest

` to attach the caller to a canonical payload. Both product and SSO signing encode `with_signed_transaction` with the one-byte `OptionBool` codec. -A pairing host that stops waiting because its caller withdrew the request sends a `Cancel` naming it, when that request -is still the newest on the session's request channel. The responder reads statements while it serves a request, so a -`Cancel` fires the running request's token or stops a queued one from starting; a withdrawn request posts no response. -See the [SSO request cancellation RFC](../../../docs/rfcs/sso-request-cancellation.md). +A pairing host that stops waiting because its caller withdrew the request sends +a `Cancel` naming it, when that request is still the newest on the session's +request channel. The responder reads statements while it serves a request, so a +`Cancel` fires the running request's token or stops a queued one from starting; +a withdrawn request posts no response. See the +[SSO request cancellation RFC](../../../docs/rfcs/sso-request-cancellation.md). When a device finishes pairing, the signing host reports it to the embedder's [`DevicePairingObserver`](src/runtime/signing_host/sso_responder.rs), installed once through @@ -342,89 +351,107 @@ preserves message order; match variants directly or use the request's `SsoReques ## Host platform interface -The `platform` module holds the capability traits a TrUAPI host implements. Each host (web/WASM, desktop, iOS/UniFFI, -Android/UniFFI) implements these traits to provide the native capabilities the shared Rust runtime cannot reach -directly. The dispatcher calls this surface while the Rust runtime owns product account management, SSO signing, -statement-store protocol flows, permission state, and auth state transitions. +The `platform` module holds the capability traits a TrUAPI host implements. +Each host (web/WASM, desktop, iOS/UniFFI, Android/UniFFI) implements these +traits to provide the native capabilities the shared Rust runtime cannot reach +directly. The dispatcher calls this surface while the Rust +runtime owns product account management, SSO signing, statement-store protocol +flows, permission state, and auth state transitions. ### Type Imports -Most host-facing wire types are imported from `truapi::latest` by this module and are exposed through the trait -signatures below. `ProductContext` and `ProductExecutionKind` are defined here instead, and codegen emits their host -codecs from these definitions. Both are SCALE-encodable so they can cross the wasm callback boundary, where every -parameter is encoded with `parity-scale-codec`; `ProductContext` decodes through its validating constructor, so a -context off the wire carries a normalized product id. +Most host-facing wire types are imported from `truapi::latest` by this module and +are exposed through the trait signatures below. `ProductContext` and +`ProductExecutionKind` are defined here instead, and codegen emits their host +codecs from these definitions. Both are SCALE-encodable so they can cross the +wasm callback boundary, where every parameter is encoded with +`parity-scale-codec`; `ProductContext` decodes through its validating +constructor, so a context off the wire carries a normalized product id. ### Product Identity -`normalize_product_identifier` is the single chokepoint that turns a host- or wire-supplied product id into the -canonical form derivation, product storage and permission scopes are keyed by; `is_product_identifier` is its boolean -form. +`normalize_product_identifier` is the single chokepoint that turns a host- or +wire-supplied product id into the canonical form derivation, product storage and +permission scopes are keyed by; `is_product_identifier` is its boolean form. -`DOTNS_TLDS` (`dot`, `paseo`, `test`) backs it: the TLDs dotNS deployments register product names under, one entry per -network a host can be pointed at. A name ending in one of them is also what navigation resolves back into the host's own -product surface, so it bypasses the outbound domain grant. +`DOTNS_TLDS` (`dot`, `paseo`, `test`) backs it: the TLDs dotNS deployments +register product names under, one entry per network a host can be pointed at. A +name ending in one of them is also what navigation resolves back into the host's +own product surface, so it bypasses the outbound domain grant. -`REMOTE_PERMISSION_TRUSTED_LABELS` lists the blessed product labels across all networks in `DOTNS_TLDS`. These products -bypass recorded permissions and prompt only for device permissions. The runtime grants account access, username -disclosure, signing with their own product accounts and AutoSigning without approval. Legacy-account signing still asks -the user. +`REMOTE_PERMISSION_TRUSTED_LABELS` lists the blessed product labels across +all networks in `DOTNS_TLDS`. These products bypass recorded permissions and +prompt only for device permissions. The runtime grants +account access, username disclosure, signing with their own product accounts and +AutoSigning without approval. Legacy-account signing still asks the user. ### Host Callback Traits - `ProductStorage`: product-scoped key-value storage. -- `CoreStorage`: typed core-owned storage slots such as auth session, pairing identity, and permission authorization - state. +- `CoreStorage`: typed core-owned storage slots such as auth session, pairing + identity, and permission authorization state. - `Navigation`: open URLs in the system browser. - `Notifications`: deliver and cancel push notifications. - `Permissions`: prompt for device and remote authorizations. - `Features`: report host feature support. -- `ChainProvider` / `JsonRpcConnection`: open JSON-RPC connections to chains. Defined in `truapi_provider::platform` so - the provider implements them without linking the runtime, and re-exported here. +- `ChainProvider` / `JsonRpcConnection`: open JSON-RPC connections to chains. + Defined in `truapi_provider::platform` so the provider implements them + without linking the runtime, and re-exported here. - `AuthPresenter`: render core-owned auth state transitions. -- `UserConfirmation`: confirm signing, transaction, resource, alias, and preimage actions before the core asks the - paired wallet. +- `UserConfirmation`: confirm signing, transaction, resource, alias, and + preimage actions before the core asks the paired wallet. - `ThemeHost`: stream the host theme into the runtime. - `PreimageHost`: submit and look up preimages through the host-selected backend. -- `ChatPlatform`: create product-scoped native chat rooms, register product chat bots, post messages into rooms, and - stream the product's room list. -- `PermissionStatusHost`: report the OS status of a device capability without prompting, so a stored grant can be - revalidated before it is acted on. -- `PocketPlatform`: stream the product's Pocket card collection and remove a card from it. The host owns the collection - and decides which cards are privileged. -- `ContactsPlatform`: resolve opaque handles to contacts, render single or multiple selection pickers, and place - host-owned contact names over product surfaces. `contacts` is the only required method; `pick_contact` and - `pick_contacts` default to `Unsupported`, never a fake selection. The multi-picker receives a host-private - `ContactSelection` record of resolved accounts. Confirmed empty selection is distinct from dismissal, and unresolved - initial handles fail closed. Session and directory generations are checked across host calls. `place_contact_labels` - is independent of Profile grants; products receive neither names nor availability. Placements are serialized and - cleared at connection teardown and session change. Hosts call `notify_contacts_changed` when a contact is removed or - blocked, invalidating cached handles. Their label layers clear stale names and refresh the live placement from the - current directory. -- `ProfilePlatform`: show a product-referenced profile in host-owned UI, show a contact's shared profile naming the - contact who sent it, and draw the avatars of contacts who shared one over a chat product. The host resolves, decrypts - and renders each reference; nothing returns to the product but acceptance. Naming the contact is optional and presents - the reference alone by default; drawing avatars is optional and draws nothing by default. - -`Platform` is a blanket-implemented supertrait that combines the capability traits above except `ChatPlatform`, -`ContactsPlatform`, `PermissionStatusHost`, `PocketPlatform` and `ProfilePlatform`, which `OptionalPlatform` lists -instead: a host supplies each only when it can serve it. Codegen reads `OptionalPlatform` to emit each listed capability -as an optional group on the host-callback surface. - -Omitting `ChatPlatform` makes the core answer Chat calls `Unsupported`, and omitting `ContactsPlatform`, -`PocketPlatform` or `ProfilePlatform` does the same for Contacts, Pocket or Profile calls. Omitting -`PermissionStatusHost` leaves device grants resolving from stored state alone, which is what a host with no OS -permission model does anyway. Serving it gates both halves of the surface: a device permission request and a status read -through `CoreAdmin` resolve the same two gates, so a settings screen never reports a capability as usable when the OS -refuses it. +- `ChatPlatform`: create product-scoped native chat rooms, register product + chat bots, post messages into rooms, and stream the product's room list. +- `PermissionStatusHost`: report the OS status of a device capability without + prompting, so a stored grant can be revalidated before it is acted on. +- `PocketPlatform`: stream the product's Pocket card collection and remove a + card from it. The host owns the collection and decides which cards are + privileged. +- `ContactsPlatform`: resolve opaque handles to contacts, render single or multiple + selection pickers, and place host-owned contact names over product surfaces. + `contacts` is the only required method; `pick_contact` and `pick_contacts` + default to `Unsupported`, never a fake selection. The multi-picker receives a + host-private `ContactSelection` record of resolved accounts. Confirmed empty + selection is distinct from dismissal, and unresolved initial handles fail closed. + Session and directory generations are checked across host calls. + `place_contact_labels` is independent of Profile grants; products receive neither + names nor availability. Placements are serialized and cleared at connection + teardown and session change. Hosts call `notify_contacts_changed` when a contact + is removed or blocked, invalidating cached handles. Their label layers clear stale + names and refresh the live placement from the current directory. +- `ProfilePlatform`: show a product-referenced profile in host-owned UI, show + a contact's shared profile naming the contact who sent it, and draw the + avatars of contacts who shared one over a chat product. The host resolves, + decrypts and renders each reference; nothing returns to the product but + acceptance. Naming the contact is optional and presents the reference alone + by default; drawing avatars is optional and draws nothing by default. + +`Platform` is a blanket-implemented supertrait that combines the capability +traits above except `ChatPlatform`, `ContactsPlatform`, `PermissionStatusHost`, +`PocketPlatform` and `ProfilePlatform`, which `OptionalPlatform` lists instead: +a host supplies each only when it can serve it. Codegen reads `OptionalPlatform` to emit each listed +capability as an optional group on the host-callback surface. + +Omitting `ChatPlatform` makes the core answer Chat calls `Unsupported`, and +omitting `ContactsPlatform`, `PocketPlatform` or `ProfilePlatform` does the same +for Contacts, Pocket or Profile calls. +Omitting `PermissionStatusHost` leaves device grants resolving from stored +state alone, which is what a host with no OS permission model does anyway. +Serving it gates both halves of the surface: a device permission request and a +status read through `CoreAdmin` resolve the same two gates, so a settings +screen never reports a capability as usable when the OS refuses it. ### Core-Owned Admin API -`CoreAdmin` is not part of the host-provided `Platform` callback surface. It is the core-owned control API exposed to -host UI for logout, pairing cancellation, session-store refresh, and permission administration. +`CoreAdmin` is not part of the host-provided `Platform` callback surface. It is +the core-owned control API exposed to host UI for logout, pairing cancellation, +session-store refresh, and permission administration. -It also serves the session's X25519 chat identity private key. Public session material a host needs to address the -identity or the paired device travels on `SessionUiInfo` instead; only the secret requires this deliberate call. +It also serves the session's X25519 chat identity private key. Public session +material a host needs to address the identity or the paired device travels on +`SessionUiInfo` instead; only the secret requires this deliberate call. ## Wire envelope From 9a5c696a8e8532e070acfd739bbd27a80773acdd Mon Sep 17 00:00:00 2001 From: w Date: Fri, 2 Oct 2026 17:22:56 -0400 Subject: [PATCH 29/30] fix(profile): present host-owned feedback for absent contact profiles --- .../profile-present-contact-attribution.md | 15 +- README.md | 5 + docs/rfcs/profile-disclosure.md | 16 +- js/packages/truapi-host/README.md | 13 +- .../truapi-host/src/adapter-support.ts | 13 +- .../src/host-callbacks-adapter.test.ts | 40 ++--- rust/crates/truapi/src/platform.rs | 56 ++++--- rust/crates/truapi/src/runtime.rs | 44 +++--- rust/crates/truapi/src/runtime/tests.rs | 142 +++++++++++++++++- 9 files changed, 243 insertions(+), 101 deletions(-) diff --git a/.changeset/profile-present-contact-attribution.md b/.changeset/profile-present-contact-attribution.md index 579a7d104..01f939f44 100644 --- a/.changeset/profile-present-contact-attribution.md +++ b/.changeset/profile-present-contact-attribution.md @@ -2,12 +2,13 @@ "@parity/truapi-host": minor --- -Name the contact who shared a presented profile. `profile.presentContact` now reaches the new -`ProfilePlatform.presentContactProfile(product, presented)` callback, where `presented` carries the `reference`, the -`peerIdentity` of the contact whose authenticated Chat device delivered it, `sharedAt` (Unix ms of that share) and, when -the core knows one, the contact's `username`, so a host can say who shared a profile rather than which product asked. +Name the contact in host-owned profile presentation, including a friendly empty state when no live reference has arrived. +`ProfilePlatform.presentContactProfile(product, presented)` receives `peerIdentity`, optional verified `username`, +and optional `shared: { reference, sharedAt }`. Absence of `shared` requests empty-profile feedback without a fetch. +The product-facing V2 reply does not reveal whether any information was available or displayed. The username is the one the product's Chat roster verified for that contact, else the contact's verified dotNS name, looked up for at most 2 seconds; it never comes from the product. It names who sent the reference, not whose profile it -is: the record is not signed by its owner, and a contact can forward someone else's reference. A JS host that supplies a -`profile` group must implement the callback; one built before it still has contacts' profiles presented through -`presentProfile`, as the Rust trait's default does. The product-facing Profile wire is unchanged. +is: the record is not signed by its owner, and a contact can forward someone else's reference. The default adapter +can present a shared reference through `presentProfile`; empty-profile feedback requires `presentContactProfile`. +Storage errors, invalid references and invalid handles are not misrepresented as absent sharing. The product-facing +Profile wire is unchanged. diff --git a/README.md b/README.md index 06e1d735c..9c34af13d 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,11 @@ accounts or per-slot availability. Selections and placements are bounded to 256 selections fail closed. Hosts implement `pickContacts(product, ContactSelection)` and `placeContactLabels(product, PlacedContactLabels)` through the canonical native/WASM/worker callbacks. +Profile V2 presentation opens host-owned feedback even when no live contact reference has arrived. +`PresentedContactProfile.shared` holds the reference and freshness timestamp when present; `None` requests an +empty-profile view. The host receives the verified contact name, while the product receives the same success reply +for shared and absent information. Storage failures and invalid handles are not presented as an empty profile. + The [native Chat/main-purse RFC](docs/rfcs/native-chat-main-purse.md) specifies the method 12 request/response and compatibility contract, device eligibility, custody-before-ACK rule, and delivery versus clearing semantics. It is a draft for review in #709, not an approved standard or a release claim. It builds on diff --git a/docs/rfcs/profile-disclosure.md b/docs/rfcs/profile-disclosure.md index 67c55627d..83b45a1bb 100644 --- a/docs/rfcs/profile-disclosure.md +++ b/docs/rfcs/profile-disclosure.md @@ -168,15 +168,19 @@ and preserves its legacy app digest, so migration alone does not broaden or rese V1 `present_contact` reads only the caller's app-scoped grant and preserves its existing errors. It cannot probe personal grants through `NotShared`. V2 accepts a peer or verified Contacts handle, selects the live app grant then personal fallback, and answers uniformly for an absent, unknown or unreadable profile, including host drawing failures. -Neither path returns the reference. The core hands a found reference to -`ProfilePlatform::present_contact_profile(product, PresentedContactProfile { reference, peer_identity, shared_at, -username })`. `shared_at` is a frame freshness timestamp; personal grants advance it monotonically even when a newer -revision arrives from an actor with an older clock. `username` is the host's own name for the contact, never one from +Neither path returns the reference. The core calls +`ProfilePlatform::present_contact_profile(product, PresentedContactProfile { shared, peer_identity, username })`. +`shared` is `Some(SharedContactProfile { reference, shared_at })` for a live reference and `None` for a genuinely absent +or retracted V2 reference. The host opens friendly empty-profile feedback for `None`, without claiming that unreadable +storage or an invalid reference means nothing was shared. `shared_at` is a frame freshness timestamp; personal grants +advance it monotonically even when a newer revision arrives from an actor with an older clock. `username` is the host's +own name for the contact, never one from the product: the name the calling product's Chat roster holds for that peer, verified when the contact was bound or first authenticated, else the peer's verified dotNS name. The core waits at most 2 seconds for it and passes `None` when it knows none, so a slow directory never holds the drawer back; the -host then names the contact generically, never by address. The default calls `present_profile` with the reference -alone, so a host that does not implement it shows the profile as before. +host then names the contact generically, never by address. The default can call `present_profile` for a shared reference; +empty-profile feedback requires the contact presenter. The core rechecks the wallet and handle generation before either +presentation, so a late lookup cannot open another wallet's contact. ### Provenance diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index d68ac1c4a..ab43bb7d6 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -202,15 +202,16 @@ when the user dismisses it. The reference is a bearer capability: the host fetch profile's bytes never return to the product. The core forwards only references that are non-empty, at most 2048 bytes and printable ASCII without whitespace; parsing the format is the host's. -`profile.presentContactProfile(product, presented)` shows the profile a Chat contact shared when a chat product calls -`profile.presentContact`. `presented` carries the `reference`, the `peerIdentity` of the contact whose authenticated -Chat device delivered it, the `sharedAt` freshness timestamp (`bigint`) and, when the core knows it, the contact's -`username`, so the drawer can say who shared it rather than which product asked. The username is the one the core's +`profile.presentContactProfile(product, presented)` opens host-owned contact profile UI when a product calls +`profile.presentContact`. `presented` carries the `peerIdentity`, an optional `shared` record containing the bearer +`reference` and `sharedAt` freshness timestamp (`bigint`), and the contact's optional verified `username`. +An absent `shared` requests friendly empty-profile feedback, not a fetch or an error. The username is the one the core's Chat roster verified for that contact, else the contact's verified dotNS name, looked up for at most 2 seconds; never a name from the product. Without one, name the contact generically, never by address. It names who sent the reference, not whose profile it is: the record is not signed by its owner, and a contact can forward someone else's. Same contract -as `presentProfile` otherwise. A `profile` group without it, from a host built before it, has contacts' profiles -presented through `presentProfile`. +as `presentProfile` otherwise. The default adapter can present a shared reference through `presentProfile`; +hosts implement `presentContactProfile` to show empty-profile feedback. V2 never reports availability or rendering +failures to the product. A failed reference lookup is not represented as an empty profile. `profile.placeContactAvatars(product, placed)` draws contacts' avatars over a chat product. `placed` carries the product's surface size and, per avatar, the product's `slot` id, a square `rect`, the `clip` region it is cut to, all in diff --git a/js/packages/truapi-host/src/adapter-support.ts b/js/packages/truapi-host/src/adapter-support.ts index e067fe0d5..1d8c2a5aa 100644 --- a/js/packages/truapi-host/src/adapter-support.ts +++ b/js/packages/truapi-host/src/adapter-support.ts @@ -193,7 +193,7 @@ export function coinageWalletHostAdapter( /** * A profile host built before `presentContactProfile` still shows a contact's * profile: without it, the contact's reference is presented as - * `presentProfile` would, the core's own default, rather than failing. + * `presentProfile` would. Empty-profile feedback requires the contact callback. */ export function profileHostAdapter( host: Required | undefined, @@ -202,8 +202,15 @@ export function profileHostAdapter( return host; return { presentProfile: (product, request) => host.presentProfile(product, request), - presentContactProfile: (product, presented) => - host.presentProfile(product, { reference: presented.reference }), + presentContactProfile: (product, presented) => { + if (presented.shared === undefined) + return Promise.reject( + new Error("Contact profile feedback is unavailable"), + ); + return host.presentProfile(product, { + reference: presented.shared.reference, + }); + }, placeContactAvatars: (product, placed) => host.placeContactAvatars(product, placed), }; diff --git a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts index f82486ed0..81ab402fe 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -915,37 +915,15 @@ describe("createWasmRawCallbacks", () => { executionKind: "App", }); const presented = { - reference: "seity-contacts:v1:ab", + shared: { + reference: "seity-contacts:v1:ab", + sharedAt: 1_700_000_000_500n, + }, peerIdentity: new Uint8Array(32).fill(0xa1), - sharedAt: 1_700_000_000_500n, username: "alice.01", }; - it("hands the host the contact who shared the profile", async () => { - const contacts: (typeof presented)[] = []; - const references: string[] = []; - const raw = createWasmRawCallbacks( - makeHostCallbacks({ - profile: { - presentProfile: async (_product, request) => { - references.push(request.reference); - }, - presentContactProfile: async (_product, contact) => { - contacts.push(contact); - }, - }, - }), - ); - - await raw.presentContactProfile!( - product, - PresentedContactProfile.enc(presented), - ); - expect(contacts).toEqual([presented]); - expect(references).toEqual([]); - }); - - it("presents the reference alone for a host built before it", async () => { + it("preserves shared profiles without fabricating a reference for empty feedback on older hosts", async () => { const references: string[] = []; const legacy = { async presentProfile( @@ -965,7 +943,13 @@ describe("createWasmRawCallbacks", () => { product, PresentedContactProfile.enc(presented), ); - expect(references).toEqual([presented.reference]); + await expect( + raw.presentContactProfile!( + product, + PresentedContactProfile.enc({ ...presented, shared: undefined }), + ), + ).rejects.toThrow("Contact profile feedback is unavailable"); + expect(references).toEqual([presented.shared.reference]); }); }); }); diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index 0eb6195fc..a595a61f4 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -3951,27 +3951,30 @@ pub trait ProfilePlatform: Send + Sync { request: HostProfilePresentRequest, ) -> Result<(), HostProfilePresentError>; - /// Show a profile a Chat contact shared with the user, for the product - /// that asked with `profile.presentContact`. Same contract as - /// [`ProfilePlatform::present_profile`]: return once it is shown, and - /// report an unparseable reference as `InvalidReference`. + /// Show a Chat contact's shared profile, or host-owned feedback when no + /// profile is shared. Return once it is shown, without waiting for dismissal. + /// Report an unparseable shared reference as `InvalidReference`. /// /// The core holds this reference because it arrived over the /// authenticated Chat channel from `peer_identity`'s own device, so the /// host can name that contact as who shared it, rather than the product /// that asked. It cannot vouch for more: the record behind the reference /// is not signed by its owner, so a contact can forward someone else's - /// reference. The default presents it as - /// [`ProfilePlatform::present_profile`] would, without the contact. + /// reference. The default presents a shared profile as + /// [`ProfilePlatform::present_profile`] would, without the contact, and + /// reports an error when empty-profile feedback is unsupported. async fn present_contact_profile( &self, product: &ProductContext, presented: PresentedContactProfile, ) -> Result<(), HostProfilePresentError> { + let shared = presented.shared.ok_or_else(|| HostProfilePresentError::Unknown { + reason: "Contact profile feedback is unavailable".to_string(), + })?; self.present_profile( product, HostProfilePresentRequest { - reference: presented.reference, + reference: shared.reference, }, ) .await @@ -3998,23 +4001,19 @@ pub trait ProfilePlatform: Send + Sync { } } -/// A profile a Chat contact shared with the user, with the contact who sent -/// it. -#[derive(Clone, PartialEq, Eq, Encode, Decode)] +/// Host-only presentation of a contact's shared profile or its absence. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] #[cfg_attr( all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record) )] pub struct PresentedContactProfile { - /// The profile reference the contact disclosed. A bearer capability, as - /// in [`ProfilePlatform::present_profile`]. - pub reference: String, - /// The contact whose authenticated Chat device delivered the reference: - /// who shared it, not necessarily whose profile it is. + /// The profile currently shared with the user. `None` means no received, + /// unretracted profile, never a storage or loading failure. + pub shared: Option, + /// The contact being presented. When shared, their authenticated Chat + /// device delivered the reference, not necessarily their own profile. pub peer_identity: [u8; 32], - /// The share's freshness timestamp, as in [`PlacedAvatar::shared_at`]. - /// Personal grants advance it monotonically across relay actors. - pub shared_at: u64, /// The contact's username, when the core knows one: the name its Chat /// roster holds for `peer_identity`, verified when the contact was bound /// or first authenticated, else the peer's verified dotNS name. Never a @@ -4023,13 +4022,26 @@ pub struct PresentedContactProfile { pub username: Option, } -impl core::fmt::Debug for PresentedContactProfile { +/// A profile reference received from an authenticated Chat contact. +#[derive(Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct SharedContactProfile { + /// The profile reference the contact disclosed. A bearer capability, as + /// in [`ProfilePlatform::present_profile`]. + pub reference: String, + /// The share's freshness timestamp, as in [`PlacedAvatar::shared_at`]. + /// Personal grants advance it monotonically across relay actors. + pub shared_at: u64, +} + +impl core::fmt::Debug for SharedContactProfile { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("PresentedContactProfile") + f.debug_struct("SharedContactProfile") .field("reference", &"[REDACTED]") - .field("peer_identity", &self.peer_identity) .field("shared_at", &self.shared_at) - .field("username", &self.username) .finish() } } diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 98a0b2203..7b67a17e5 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -2221,27 +2221,28 @@ impl Profile for ProductRuntimeHost { })); } }; - let Some((reference, shared_at)) = received.and_then(|received| { - received - .reference - .map(|reference| (reference, received.timestamp)) - }) else { - return if version >= 2 { - Ok(success()) - } else { - Err(domain(v01::HostProfilePresentContactError::NotShared)) - }; - }; - // A stored reference passed the same screen when it arrived; check - // again rather than trust storage. - if !is_screened_profile_reference(&reference) { - if version >= 2 { - return Ok(success()); + let shared = match received.and_then(|received| { + received.reference.map(|reference| crate::platform::SharedContactProfile { + reference, + shared_at: received.timestamp, + }) + }) { + Some(shared) => { + // A stored reference passed the same screen when it arrived; + // check again rather than trust storage. + if !is_screened_profile_reference(&shared.reference) { + if version >= 2 { + return Ok(success()); + } + return Err(domain( + v01::HostProfilePresentContactError::InvalidReference, + )); + } + Some(shared) } - return Err(domain( - v01::HostProfilePresentContactError::InvalidReference, - )); - } + None if version >= 2 => None, + None => return Err(domain(v01::HostProfilePresentContactError::NotShared)), + }; let username = self.contact_username(&peer_identity).await; if self.profile_owner() != Some(owner) || (matches!(contact, ProfileContact::Handle { .. }) @@ -2253,9 +2254,8 @@ impl Profile for ProductRuntimeHost { .present_contact_profile( &self.product, crate::platform::PresentedContactProfile { - reference, + shared, peer_identity, - shared_at, username, }, ) diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 8874d16d6..2bff84923 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -3151,7 +3151,7 @@ fn profile_handle_presentation_hides_absence_and_removed_contacts_in_an_unrelate let platform = stub_platform(); let account = [0xa1; 32]; let contacts = StubContactsPlatform::picking(account); - let presented = Arc::new(RecordingProfilePlatform::default()); + let presented = Arc::new(RecordingContactProfilePlatform::default()); let mut host = contacts_host("notes.dot", platform.clone(), Some(contacts.clone()), true); host.profile_platform = Some(presented.clone()); let handle = picked_handle(&host); @@ -3178,6 +3178,17 @@ fn profile_handle_presentation_hides_absence_and_removed_contacts_in_an_unrelate "legacy raw-peer requests must not reveal that a personal profile became available" ); assert_eq!(present_selected_contact(&host, selected), success); + futures::executor::block_on(profile::record_personal_received_reference( + platform.as_ref(), + owner_of(&host), + account, + "seity.dot".into(), + 2, + 2, + None, + )) + .unwrap(); + assert_eq!(present_selected_contact(&host, selected), success); assert_eq!( present_selected_contact( &host, @@ -3196,15 +3207,122 @@ fn profile_handle_presentation_hides_absence_and_removed_contacts_in_an_unrelate assert_eq!(present_selected_contact(&host, selected), success); assert_eq!( presented - .presented + .contacts .lock() - .expect("presented mutex poisoned") + .expect("contacts mutex poisoned") .as_slice(), - [("notes.dot".to_string(), CONTACTS_REFERENCE.to_string())], - "only a current verified contact reaches host UI, never the reference or availability response", + [ + ("notes.dot".to_string(), crate::platform::PresentedContactProfile { + shared: None, + peer_identity: account, + username: None, + }), + ("notes.dot".to_string(), crate::platform::PresentedContactProfile { + shared: Some(crate::platform::SharedContactProfile { + reference: CONTACTS_REFERENCE.to_string(), + shared_at: 1, + }), + peer_identity: account, + username: None, + }), + ("notes.dot".to_string(), crate::platform::PresentedContactProfile { + shared: None, + peer_identity: account, + username: None, + }), + ], + "absence and retraction reach only host UI; invalid or removed handles do not", ); } +#[test] +fn profile_v2_read_failures_and_invalid_references_are_not_presented_as_absence() { + for invalid_reference in [false, true] { + let platform = Arc::new(StubPlatform { + local_storage_error: (!invalid_reference).then_some("storage unavailable"), + ..Default::default() + }); + let presenter = Arc::new(RecordingContactProfilePlatform::default()); + let host = signed_in( + profile_host_on(platform.clone(), egui_chat(), Some(presenter.clone())), + WALLET, + ); + let account = [0xa1; 32]; + if invalid_reference { + futures::executor::block_on(profile::record_received_reference( + platform.as_ref(), + owner_of(&host), + "egui-chat.dot", + account, + "seity.dot".into(), + 1, + Some("invalid stored reference".into()), + )) + .unwrap(); + } + assert_eq!( + present_selected_contact( + &host, + truapi::latest::ProfileContact::Peer { peer_identity: account }, + ), + Ok(HostProfilePresentContactResponse::V2), + ); + assert!(presenter.contacts.lock().unwrap().is_empty()); + } +} + +#[test] +fn profile_v2_empty_presentation_is_discarded_after_a_wallet_switch_during_lookup() { + struct DelayedContacts { + release: parking_lot::Mutex>>, + account: [u8; 32], + } + #[truapi::async_trait] + impl crate::platform::ContactsPlatform for DelayedContacts { + async fn contacts( + &self, + _lookup: &crate::platform::HostContactLookup, + ) -> Result { + let release = self.release.lock().take().unwrap(); + release.await.unwrap(); + Ok(crate::platform::HostContactMatches { + accounts: vec![Some(self.account)], + }) + } + } + let (release, wait) = futures::channel::oneshot::channel(); + let contacts = Arc::new(DelayedContacts { + release: parking_lot::Mutex::new(Some(wait)), + account: [0xa1; 32], + }); + let presenter = Arc::new(RecordingContactProfilePlatform::default()); + let mut host = contacts_host("notes.dot", stub_platform(), Some(contacts), true); + host.profile_platform = Some(presenter.clone()); + let (_, handles) = host.contacts_picker().unwrap(); + let handle = truapi::latest::ContactHandle { + bytes: handles.mint(&[0xa1; 32]), + }; + futures::executor::block_on(async { + let context = CallContext::default(); + let presentation = Profile::present_contact( + &host, + &context, + HostProfilePresentContactRequest::V2(truapi::latest::HostProfilePresentContactRequest { + contact: truapi::latest::ProfileContact::Handle { handle }, + }), + ); + futures::pin_mut!(presentation); + assert!(futures::poll!(presentation.as_mut()).is_pending()); + host.test_session_state().set_session(SessionInfo { + public_key: [0x99; 32], + ..session_info() + }); + release.send(()).unwrap(); + assert_eq!(presentation.await, Ok(HostProfilePresentContactResponse::V2)); + }); + assert!(presenter.contacts.lock().unwrap().is_empty()); +} + #[test] fn profile_v2_presentation_does_not_expose_host_parse_failures() { struct RejectingProfile; @@ -3228,6 +3346,14 @@ fn profile_v2_presentation_does_not_expose_host_parse_failures() { WALLET, ); let account = [0xa1; 32]; + assert_eq!( + present_selected_contact( + &host, + truapi::latest::ProfileContact::Peer { peer_identity: account }, + ), + Ok(HostProfilePresentContactResponse::V2), + "an adapter without empty-profile feedback must not expose absence", + ); futures::executor::block_on(profile::record_received_reference( platform.as_ref(), owner_of(&host), @@ -3626,9 +3752,11 @@ fn profile_present_contact_names_the_contact_who_shared_it() { [( "egui-chat.dot".to_string(), crate::platform::PresentedContactProfile { - reference: CONTACTS_REFERENCE.to_string(), + shared: Some(crate::platform::SharedContactProfile { + reference: CONTACTS_REFERENCE.to_string(), + shared_at: 1_700_000_000_500, + }), peer_identity: alice, - shared_at: 1_700_000_000_500, // A paired host's Chat roster lives on the signing host. username: None, } From ee0d338d86d42841e26aed07dc882a68c4508603 Mon Sep 17 00:00:00 2001 From: w Date: Fri, 2 Oct 2026 17:43:45 -0400 Subject: [PATCH 30/30] chore(codegen): regenerate combined locale and Profile client --- rust/crates/truapi-client/src/generated.rs | 489 ++++++++++++++++----- 1 file changed, 389 insertions(+), 100 deletions(-) diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index f4d71af7c..02f628291 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -21,7 +21,10 @@ impl AccountConnectionStatusSubscribe { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Subscription(MethodIds { trait_id: 2, method_id: 0 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 2, + method_id: 0, + }), }; } impl SubscriptionMethod for AccountConnectionStatusSubscribe { @@ -45,7 +48,10 @@ impl AccountGetAccount { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 2, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 1, + }), }; } impl RequestMethod for AccountGetAccount { @@ -69,7 +75,10 @@ impl AccountGetAccountAlias { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 2, method_id: 2 }), + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 2, + }), }; } impl RequestMethod for AccountGetAccountAlias { @@ -93,7 +102,10 @@ impl AccountCreateAccountProof { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 2, method_id: 3 }), + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 3, + }), }; } impl RequestMethod for AccountCreateAccountProof { @@ -117,7 +129,10 @@ impl AccountSignVrf { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 2, method_id: 7 }), + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 7, + }), }; } impl RequestMethod for AccountSignVrf { @@ -141,7 +156,10 @@ impl AccountRegisterRingVrfKey { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 2, method_id: 8 }), + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 8, + }), }; } impl RequestMethod for AccountRegisterRingVrfKey { @@ -165,7 +183,10 @@ impl AccountListRingVrfKeys { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 2, method_id: 9 }), + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 9, + }), }; } impl RequestMethod for AccountListRingVrfKeys { @@ -189,7 +210,10 @@ impl AccountRingVrfSign { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 2, method_id: 10 }), + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 10, + }), }; } impl RequestMethod for AccountRingVrfSign { @@ -213,7 +237,10 @@ impl AccountProductDeviceChat { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 2, method_id: 12 }), + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 12, + }), }; } impl RequestMethod for AccountProductDeviceChat { @@ -237,7 +264,10 @@ impl AccountGetLegacyAccounts { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 2, method_id: 4 }), + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 4, + }), }; } impl RequestMethod for AccountGetLegacyAccounts { @@ -261,7 +291,10 @@ impl AccountGetUserId { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 2, method_id: 5 }), + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 5, + }), }; } impl RequestMethod for AccountGetUserId { @@ -285,7 +318,10 @@ impl AccountRequestLogin { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 2, method_id: 6 }), + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 6, + }), }; } impl RequestMethod for AccountRequestLogin { @@ -309,7 +345,10 @@ impl ChainFollowHeadSubscribe { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Subscription(MethodIds { trait_id: 3, method_id: 0 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 3, + method_id: 0, + }), }; } impl SubscriptionMethod for ChainFollowHeadSubscribe { @@ -333,7 +372,10 @@ impl ChainGetHeadHeader { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 3, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 1, + }), }; } impl RequestMethod for ChainGetHeadHeader { @@ -357,7 +399,10 @@ impl ChainGetHeadBody { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 3, method_id: 2 }), + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 2, + }), }; } impl RequestMethod for ChainGetHeadBody { @@ -381,7 +426,10 @@ impl ChainGetHeadStorage { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 3, method_id: 3 }), + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 3, + }), }; } impl RequestMethod for ChainGetHeadStorage { @@ -405,7 +453,10 @@ impl ChainCallHead { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 3, method_id: 4 }), + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 4, + }), }; } impl RequestMethod for ChainCallHead { @@ -429,7 +480,10 @@ impl ChainUnpinHead { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 3, method_id: 5 }), + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 5, + }), }; } impl RequestMethod for ChainUnpinHead { @@ -453,7 +507,10 @@ impl ChainContinueHead { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 3, method_id: 6 }), + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 6, + }), }; } impl RequestMethod for ChainContinueHead { @@ -477,7 +534,10 @@ impl ChainStopHeadOperation { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 3, method_id: 7 }), + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 7, + }), }; } impl RequestMethod for ChainStopHeadOperation { @@ -501,7 +561,10 @@ impl ChainGetSpecGenesisHash { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 3, method_id: 8 }), + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 8, + }), }; } impl RequestMethod for ChainGetSpecGenesisHash { @@ -525,7 +588,10 @@ impl ChainGetSpecChainName { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 3, method_id: 9 }), + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 9, + }), }; } impl RequestMethod for ChainGetSpecChainName { @@ -549,7 +615,10 @@ impl ChainGetSpecProperties { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 3, method_id: 10 }), + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 10, + }), }; } impl RequestMethod for ChainGetSpecProperties { @@ -573,7 +642,10 @@ impl ChainBroadcastTransaction { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 3, method_id: 11 }), + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 11, + }), }; } impl RequestMethod for ChainBroadcastTransaction { @@ -597,7 +669,10 @@ impl ChainStopTransaction { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 3, method_id: 12 }), + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 12, + }), }; } impl RequestMethod for ChainStopTransaction { @@ -621,7 +696,10 @@ impl ChainGetChainInfo { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 3, method_id: 13 }), + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 13, + }), }; } impl RequestMethod for ChainGetChainInfo { @@ -645,7 +723,10 @@ impl ChatCreateRoom { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: Some(ExecutionKind::Worker), - wire: MethodWire::Request(MethodIds { trait_id: 4, method_id: 0 }), + wire: MethodWire::Request(MethodIds { + trait_id: 4, + method_id: 0, + }), }; } impl RequestMethod for ChatCreateRoom { @@ -669,7 +750,10 @@ impl ChatRegisterBot { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: Some(ExecutionKind::Worker), - wire: MethodWire::Request(MethodIds { trait_id: 4, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 4, + method_id: 1, + }), }; } impl RequestMethod for ChatRegisterBot { @@ -693,7 +777,10 @@ impl ChatListSubscribe { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: Some(ExecutionKind::Worker), - wire: MethodWire::Subscription(MethodIds { trait_id: 4, method_id: 2 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 4, + method_id: 2, + }), }; } impl SubscriptionMethod for ChatListSubscribe { @@ -717,7 +804,10 @@ impl ChatPostMessage { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: Some(ExecutionKind::Worker), - wire: MethodWire::Request(MethodIds { trait_id: 4, method_id: 3 }), + wire: MethodWire::Request(MethodIds { + trait_id: 4, + method_id: 3, + }), }; } impl RequestMethod for ChatPostMessage { @@ -741,7 +831,10 @@ impl ChatActionSubscribe { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: Some(ExecutionKind::Worker), - wire: MethodWire::Subscription(MethodIds { trait_id: 4, method_id: 4 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 4, + method_id: 4, + }), }; } impl SubscriptionMethod for ChatActionSubscribe { @@ -765,7 +858,10 @@ impl CoinPaymentCreatePurse { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 5, method_id: 0 }), + wire: MethodWire::Request(MethodIds { + trait_id: 5, + method_id: 0, + }), }; } impl RequestMethod for CoinPaymentCreatePurse { @@ -789,7 +885,10 @@ impl CoinPaymentQueryPurse { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 5, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 5, + method_id: 1, + }), }; } impl RequestMethod for CoinPaymentQueryPurse { @@ -813,7 +912,10 @@ impl CoinPaymentRebalancePurse { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Subscription(MethodIds { trait_id: 5, method_id: 2 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 5, + method_id: 2, + }), }; } impl SubscriptionMethod for CoinPaymentRebalancePurse { @@ -837,7 +939,10 @@ impl CoinPaymentDeletePurse { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Subscription(MethodIds { trait_id: 5, method_id: 3 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 5, + method_id: 3, + }), }; } impl SubscriptionMethod for CoinPaymentDeletePurse { @@ -861,7 +966,10 @@ impl CoinPaymentCreateReceivable { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 5, method_id: 4 }), + wire: MethodWire::Request(MethodIds { + trait_id: 5, + method_id: 4, + }), }; } impl RequestMethod for CoinPaymentCreateReceivable { @@ -885,7 +993,10 @@ impl CoinPaymentCreateCheque { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 5, method_id: 5 }), + wire: MethodWire::Request(MethodIds { + trait_id: 5, + method_id: 5, + }), }; } impl RequestMethod for CoinPaymentCreateCheque { @@ -909,7 +1020,10 @@ impl CoinPaymentDeposit { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Subscription(MethodIds { trait_id: 5, method_id: 6 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 5, + method_id: 6, + }), }; } impl SubscriptionMethod for CoinPaymentDeposit { @@ -933,7 +1047,10 @@ impl CoinPaymentRefund { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Subscription(MethodIds { trait_id: 5, method_id: 7 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 5, + method_id: 7, + }), }; } impl SubscriptionMethod for CoinPaymentRefund { @@ -957,7 +1074,10 @@ impl CoinPaymentListenForPayment { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Subscription(MethodIds { trait_id: 5, method_id: 8 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 5, + method_id: 8, + }), }; } impl SubscriptionMethod for CoinPaymentListenForPayment { @@ -981,7 +1101,10 @@ impl ContactsPick { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 20, method_id: 0 }), + wire: MethodWire::Request(MethodIds { + trait_id: 20, + method_id: 0, + }), }; } impl RequestMethod for ContactsPick { @@ -1005,7 +1128,10 @@ impl ContactsPickMany { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 20, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 20, + method_id: 1, + }), }; } impl RequestMethod for ContactsPickMany { @@ -1029,7 +1155,10 @@ impl ContactsPlaceLabels { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 20, method_id: 2 }), + wire: MethodWire::Request(MethodIds { + trait_id: 20, + method_id: 2, + }), }; } impl RequestMethod for ContactsPlaceLabels { @@ -1053,7 +1182,10 @@ impl EntropyDerive { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 6, method_id: 0 }), + wire: MethodWire::Request(MethodIds { + trait_id: 6, + method_id: 0, + }), }; } impl RequestMethod for EntropyDerive { @@ -1077,7 +1209,10 @@ impl LocalStorageRead { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 7, method_id: 0 }), + wire: MethodWire::Request(MethodIds { + trait_id: 7, + method_id: 0, + }), }; } impl RequestMethod for LocalStorageRead { @@ -1101,7 +1236,10 @@ impl LocalStorageWrite { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 7, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 7, + method_id: 1, + }), }; } impl RequestMethod for LocalStorageWrite { @@ -1125,7 +1263,10 @@ impl LocalStorageClear { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 7, method_id: 2 }), + wire: MethodWire::Request(MethodIds { + trait_id: 7, + method_id: 2, + }), }; } impl RequestMethod for LocalStorageClear { @@ -1149,7 +1290,10 @@ impl LocalStorageSubscribe { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Subscription(MethodIds { trait_id: 7, method_id: 3 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 7, + method_id: 3, + }), }; } impl SubscriptionMethod for LocalStorageSubscribe { @@ -1173,7 +1317,10 @@ impl LocaleSubscribe { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Subscription(MethodIds { trait_id: 16, method_id: 0 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 16, + method_id: 0, + }), }; } impl SubscriptionMethod for LocaleSubscribe { @@ -1197,7 +1344,10 @@ impl LocaleLocalizeTimestamps { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 16, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 16, + method_id: 1, + }), }; } impl RequestMethod for LocaleLocalizeTimestamps { @@ -1221,7 +1371,10 @@ impl NotificationsSendPushNotification { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 8, method_id: 0 }), + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 0, + }), }; } impl RequestMethod for NotificationsSendPushNotification { @@ -1245,7 +1398,10 @@ impl NotificationsCancelPushNotification { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 8, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 1, + }), }; } impl RequestMethod for NotificationsCancelPushNotification { @@ -1269,7 +1425,10 @@ impl PaymentBalanceSubscribe { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Subscription(MethodIds { trait_id: 9, method_id: 0 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 9, + method_id: 0, + }), }; } impl SubscriptionMethod for PaymentBalanceSubscribe { @@ -1293,7 +1452,10 @@ impl PaymentRequest { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 9, method_id: 2 }), + wire: MethodWire::Request(MethodIds { + trait_id: 9, + method_id: 2, + }), }; } impl RequestMethod for PaymentRequest { @@ -1317,7 +1479,10 @@ impl PaymentStatusSubscribe { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Subscription(MethodIds { trait_id: 9, method_id: 3 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 9, + method_id: 3, + }), }; } impl SubscriptionMethod for PaymentStatusSubscribe { @@ -1341,7 +1506,10 @@ impl PaymentTopUp { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 9, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 9, + method_id: 1, + }), }; } impl RequestMethod for PaymentTopUp { @@ -1365,7 +1533,10 @@ impl PermissionsRequestDevicePermission { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 10, method_id: 0 }), + wire: MethodWire::Request(MethodIds { + trait_id: 10, + method_id: 0, + }), }; } impl RequestMethod for PermissionsRequestDevicePermission { @@ -1389,7 +1560,10 @@ impl PermissionsRequestRemotePermission { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 10, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 10, + method_id: 1, + }), }; } impl RequestMethod for PermissionsRequestRemotePermission { @@ -1413,7 +1587,10 @@ impl PermissionsAuthorizeRemotePermission { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 10, method_id: 2 }), + wire: MethodWire::Request(MethodIds { + trait_id: 10, + method_id: 2, + }), }; } impl RequestMethod for PermissionsAuthorizeRemotePermission { @@ -1437,7 +1614,10 @@ impl PermissionsAuthorizeDevicePermission { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 10, method_id: 3 }), + wire: MethodWire::Request(MethodIds { + trait_id: 10, + method_id: 3, + }), }; } impl RequestMethod for PermissionsAuthorizeDevicePermission { @@ -1461,7 +1641,10 @@ impl PocketListSubscribe { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: Some(ExecutionKind::Worker), - wire: MethodWire::Subscription(MethodIds { trait_id: 18, method_id: 0 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 18, + method_id: 0, + }), }; } impl SubscriptionMethod for PocketListSubscribe { @@ -1485,7 +1668,10 @@ impl PocketRemoveCard { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: Some(ExecutionKind::Worker), - wire: MethodWire::Request(MethodIds { trait_id: 18, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 18, + method_id: 1, + }), }; } impl RequestMethod for PocketRemoveCard { @@ -1509,7 +1695,10 @@ impl PreimageLookupSubscribe { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Subscription(MethodIds { trait_id: 11, method_id: 0 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 11, + method_id: 0, + }), }; } impl SubscriptionMethod for PreimageLookupSubscribe { @@ -1533,7 +1722,10 @@ impl PreimageSubmit { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 11, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 11, + method_id: 1, + }), }; } impl RequestMethod for PreimageSubmit { @@ -1557,7 +1749,10 @@ impl ProfilePresent { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 69, method_id: 0 }), + wire: MethodWire::Request(MethodIds { + trait_id: 69, + method_id: 0, + }), }; } impl RequestMethod for ProfilePresent { @@ -1581,7 +1776,10 @@ impl ProfileDisclose { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 69, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 69, + method_id: 1, + }), }; } impl RequestMethod for ProfileDisclose { @@ -1605,7 +1803,10 @@ impl ProfileRetract { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 69, method_id: 2 }), + wire: MethodWire::Request(MethodIds { + trait_id: 69, + method_id: 2, + }), }; } impl RequestMethod for ProfileRetract { @@ -1629,7 +1830,10 @@ impl ProfilePresentContact { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 69, method_id: 3 }), + wire: MethodWire::Request(MethodIds { + trait_id: 69, + method_id: 3, + }), }; } impl RequestMethod for ProfilePresentContact { @@ -1653,7 +1857,10 @@ impl ProfilePlaceContactAvatars { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 69, method_id: 4 }), + wire: MethodWire::Request(MethodIds { + trait_id: 69, + method_id: 4, + }), }; } impl RequestMethod for ProfilePlaceContactAvatars { @@ -1677,7 +1884,10 @@ impl ProfileOwnStatus { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 69, method_id: 5 }), + wire: MethodWire::Request(MethodIds { + trait_id: 69, + method_id: 5, + }), }; } impl RequestMethod for ProfileOwnStatus { @@ -1701,7 +1911,10 @@ impl ProfilePresentOwn { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 69, method_id: 6 }), + wire: MethodWire::Request(MethodIds { + trait_id: 69, + method_id: 6, + }), }; } impl RequestMethod for ProfilePresentOwn { @@ -1725,7 +1938,10 @@ impl RendererRender { kind: MethodKind::Subscription, direction: Direction::HostToProduct, required_execution: Some(ExecutionKind::Worker), - wire: MethodWire::Subscription(MethodIds { trait_id: 17, method_id: 0 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 17, + method_id: 0, + }), }; } impl HostSubscriptionMethod for RendererRender { @@ -1749,7 +1965,10 @@ impl RendererActionSubscribe { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: Some(ExecutionKind::Worker), - wire: MethodWire::Subscription(MethodIds { trait_id: 17, method_id: 1 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 17, + method_id: 1, + }), }; } impl SubscriptionMethod for RendererActionSubscribe { @@ -1769,11 +1988,16 @@ impl ResourceAllocationRequest { wire_name: "resource_allocation_request", request_type: "truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest", response_type: "truapi::versioned::resource_allocation::HostRequestResourceAllocationResponse", - error_type: Some("truapi::versioned::resource_allocation::HostRequestResourceAllocationError"), + error_type: Some( + "truapi::versioned::resource_allocation::HostRequestResourceAllocationError", + ), kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 12, method_id: 0 }), + wire: MethodWire::Request(MethodIds { + trait_id: 12, + method_id: 0, + }), }; } impl RequestMethod for ResourceAllocationRequest { @@ -1797,7 +2021,10 @@ impl SigningCreateTransaction { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 13, method_id: 0 }), + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 0, + }), }; } impl RequestMethod for SigningCreateTransaction { @@ -1821,7 +2048,10 @@ impl SigningCreateTransactionWithLegacyAccount { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 13, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 1, + }), }; } impl RequestMethod for SigningCreateTransactionWithLegacyAccount { @@ -1845,7 +2075,10 @@ impl SigningSignRawWithLegacyAccount { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 13, method_id: 2 }), + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 2, + }), }; } impl RequestMethod for SigningSignRawWithLegacyAccount { @@ -1869,7 +2102,10 @@ impl SigningSignPayloadWithLegacyAccount { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 13, method_id: 3 }), + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 3, + }), }; } impl RequestMethod for SigningSignPayloadWithLegacyAccount { @@ -1893,7 +2129,10 @@ impl SigningSignRaw { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 13, method_id: 4 }), + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 4, + }), }; } impl RequestMethod for SigningSignRaw { @@ -1917,7 +2156,10 @@ impl SigningSignPayload { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 13, method_id: 5 }), + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 5, + }), }; } impl RequestMethod for SigningSignPayload { @@ -1941,7 +2183,10 @@ impl SigningSignRawUnwatermarkedDeprecated { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 13, method_id: 6 }), + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 6, + }), }; } impl RequestMethod for SigningSignRawUnwatermarkedDeprecated { @@ -1965,7 +2210,10 @@ impl SigningSignRawUnwatermarkedDeprecatedWithLegacyAccount { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 13, method_id: 7 }), + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 7, + }), }; } impl RequestMethod for SigningSignRawUnwatermarkedDeprecatedWithLegacyAccount { @@ -1989,7 +2237,10 @@ impl StatementStoreSubscribe { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Subscription(MethodIds { trait_id: 14, method_id: 0 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 14, + method_id: 0, + }), }; } impl SubscriptionMethod for StatementStoreSubscribe { @@ -2009,11 +2260,16 @@ impl StatementStoreCreateProof { wire_name: "statement_store_create_proof", request_type: "truapi::versioned::statement_store::RemoteStatementStoreCreateProofRequest", response_type: "truapi::versioned::statement_store::RemoteStatementStoreCreateProofResponse", - error_type: Some("truapi::versioned::statement_store::RemoteStatementStoreCreateProofError"), + error_type: Some( + "truapi::versioned::statement_store::RemoteStatementStoreCreateProofError", + ), kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 14, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 14, + method_id: 1, + }), }; } impl RequestMethod for StatementStoreCreateProof { @@ -2033,16 +2289,23 @@ impl StatementStoreCreateProofAuthorized { wire_name: "statement_store_create_proof_authorized", request_type: "truapi::versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedRequest", response_type: "truapi::versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedResponse", - error_type: Some("truapi::versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedError"), + error_type: Some( + "truapi::versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedError", + ), kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 14, method_id: 3 }), + wire: MethodWire::Request(MethodIds { + trait_id: 14, + method_id: 3, + }), }; } impl RequestMethod for StatementStoreCreateProofAuthorized { - type Request = truapi::versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedRequest; - type Response = truapi::versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedResponse; + type Request = + truapi::versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedRequest; + type Response = + truapi::versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedResponse; type Error = truapi::versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedError; const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } @@ -2061,7 +2324,10 @@ impl StatementStoreSubmit { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 14, method_id: 2 }), + wire: MethodWire::Request(MethodIds { + trait_id: 14, + method_id: 2, + }), }; } impl RequestMethod for StatementStoreSubmit { @@ -2085,7 +2351,10 @@ impl SystemHandshake { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 1, method_id: 0 }), + wire: MethodWire::Request(MethodIds { + trait_id: 1, + method_id: 0, + }), }; } impl RequestMethod for SystemHandshake { @@ -2109,7 +2378,10 @@ impl SystemFeatureSupported { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 1, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 1, + method_id: 1, + }), }; } impl RequestMethod for SystemFeatureSupported { @@ -2133,7 +2405,10 @@ impl SystemNavigateTo { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 1, method_id: 2 }), + wire: MethodWire::Request(MethodIds { + trait_id: 1, + method_id: 2, + }), }; } impl RequestMethod for SystemNavigateTo { @@ -2157,7 +2432,10 @@ impl SystemHostInfo { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 1, method_id: 3 }), + wire: MethodWire::Request(MethodIds { + trait_id: 1, + method_id: 3, + }), }; } impl RequestMethod for SystemHostInfo { @@ -2181,7 +2459,10 @@ impl SystemGetProductContext { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Request(MethodIds { trait_id: 1, method_id: 4 }), + wire: MethodWire::Request(MethodIds { + trait_id: 1, + method_id: 4, + }), }; } impl RequestMethod for SystemGetProductContext { @@ -2205,7 +2486,10 @@ impl ThemeSubscribe { kind: MethodKind::Subscription, direction: Direction::ProductToHost, required_execution: None, - wire: MethodWire::Subscription(MethodIds { trait_id: 15, method_id: 0 }), + wire: MethodWire::Subscription(MethodIds { + trait_id: 15, + method_id: 0, + }), }; } impl SubscriptionMethod for ThemeSubscribe { @@ -2229,7 +2513,10 @@ impl WorkerBeginOperation { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: Some(ExecutionKind::Worker), - wire: MethodWire::Request(MethodIds { trait_id: 19, method_id: 0 }), + wire: MethodWire::Request(MethodIds { + trait_id: 19, + method_id: 0, + }), }; } impl RequestMethod for WorkerBeginOperation { @@ -2253,7 +2540,10 @@ impl WorkerEndOperation { kind: MethodKind::Request, direction: Direction::ProductToHost, required_execution: Some(ExecutionKind::Worker), - wire: MethodWire::Request(MethodIds { trait_id: 19, method_id: 1 }), + wire: MethodWire::Request(MethodIds { + trait_id: 19, + method_id: 1, + }), }; } impl RequestMethod for WorkerEndOperation { @@ -2549,4 +2839,3 @@ pub const WORKER_ONLY_METHODS: &[MethodDescriptor] = &[ WorkerBeginOperation::DESCRIPTOR, WorkerEndOperation::DESCRIPTOR, ]; -