diff --git a/.changeset/profile-disclose.md b/.changeset/profile-disclose.md new file mode 100644 index 000000000..ed9ee0531 --- /dev/null +++ b/.changeset/profile-disclose.md @@ -0,0 +1,70 @@ +--- +"@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. 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. 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. + +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 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. + +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. + +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. + +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. diff --git a/.changeset/profile-present-contact-attribution.md b/.changeset/profile-present-contact-attribution.md new file mode 100644 index 000000000..01f939f44 --- /dev/null +++ b/.changeset/profile-present-contact-attribution.md @@ -0,0 +1,14 @@ +--- +"@parity/truapi-host": minor +--- + +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. 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/.changeset/profile-present.md b/.changeset/profile-present.md new file mode 100644 index 000000000..44abd291a --- /dev/null +++ b/.changeset/profile-present.md @@ -0,0 +1,11 @@ +--- +"@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`. + +Keep the optional profile bindings available under the consolidated native +`runtime` feature, and use the shared host clock for disclosure revisions. diff --git a/README.md b/README.md index 08648888d..e1c251649 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/trinity-user-agents/) - [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) @@ -130,6 +131,19 @@ 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. + +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/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt index bf1d6cbca..f9c11e945 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 @@ -101,6 +101,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 @@ -798,6 +802,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 { @@ -812,6 +827,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/docs/rfcs/profile-disclosure.md b/docs/rfcs/profile-disclosure.md new file mode 100644 index 000000000..83b45a1bb --- /dev/null +++ b/docs/rfcs/profile-disclosure.md @@ -0,0 +1,262 @@ +--- +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 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 + +`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:** 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:** 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 + +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, 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 +pub enum ProfileAudience { + ChatApps, + App { product_id: String }, + Contacts { handles: Vec }, +} +pub struct HostProfileDiscloseRequest { + pub reference: String, + pub audiences: Vec, +} +pub enum ProfileContact { + Peer { peer_identity: [u8; 32] }, + Handle { handle: ContactHandle }, +} +pub struct HostProfilePresentContactRequest { + pub contact: ProfileContact, +} +pub struct ContactAvatarSlot { + pub slot: u32, + pub contact: ProfileContact, + pub rect: AvatarRect, + pub clip: AvatarRect, +} +``` + +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 + +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`. 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 + +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 + +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: + +- 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. + +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. + +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 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. 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 + +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 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 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 + +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. + +`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 +`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 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. + +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. + +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. + +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 +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 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 + 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. + +## Open questions + +- 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 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 + 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 de162b270..07b11ca8f 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/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 2a40c7cc9..b753adf0c 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -338,6 +338,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 { @@ -571,6 +587,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 bf128f90e..8e7283cab 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -21,8 +21,6 @@ The package exposes tree-shakeable subpath exports — import only what your env | `@parity/truapi-host/testing/host-page` | The browser half the fixture drives, for a suite that boots its own page. | | `@parity/truapi-host/wasm/testing` | The raw glue for the signing-enabled bundle the test host runs on. | -The shipped WASM includes `WasmSigningHostRuntime`. Its configuration requires `runtimeConfig.networkSuffix`: the bare -TLD (`dot`, `paseo`, or `testnet`) matching the People chain and the wallet's onboarding configuration. `scripts/build-wasm.mjs` builds two WASM bundles, both `--no-default-features`. `wasm/web` is the production browser host and excludes `WasmSigningHostRuntime`; `wasm/testing` adds the Rust `wasm-signing-host` and `test-host` features, which is what lets the test host hold keys and answer resource allocation as granted without allocating anything. A real @@ -194,6 +192,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 profiles and draws contact avatars in host UI contacts, // optional: leave it out and contacts calls get `Unsupported` }; ``` @@ -206,6 +205,47 @@ 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. + +`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. 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 +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 +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`. + +`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. + +`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. @@ -313,16 +353,32 @@ A running fixture answers the same address through `testHost.getProductAccountAddress(productId?, index?)`, which reads the session the host actually holds and so returns `undefined` while it is signed out. -`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`. +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. The directory restores encrypted native Chat actors, checks their current authorization, includes only authenticated diff --git a/js/packages/truapi-host/src/adapter-support.ts b/js/packages/truapi-host/src/adapter-support.ts index bea241656..1d8c2a5aa 100644 --- a/js/packages/truapi-host/src/adapter-support.ts +++ b/js/packages/truapi-host/src/adapter-support.ts @@ -12,11 +12,30 @@ 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 }; @@ -171,6 +190,32 @@ 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. Empty-profile feedback requires the contact callback. + */ +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) => { + 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), + }; +} + /** 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 3a8aa17b2..81ab402fe 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, @@ -907,6 +908,50 @@ 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 = { + shared: { + reference: "seity-contacts:v1:ab", + sharedAt: 1_700_000_000_500n, + }, + peerIdentity: new Uint8Array(32).fill(0xa1), + username: "alice.01", + }; + + it("preserves shared profiles without fabricating a reference for empty feedback on older hosts", 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), + ); + await expect( + raw.presentContactProfile!( + product, + PresentedContactProfile.enc({ ...presented, shared: undefined }), + ), + ).rejects.toThrow("Contact profile feedback is unavailable"); + expect(references).toEqual([presented.shared.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 76a69d38e..43348e44c 100644 --- a/js/packages/truapi-host/src/test-support.ts +++ b/js/packages/truapi-host/src/test-support.ts @@ -150,6 +150,18 @@ 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 () => {}, + presentContactProfile: async () => {}, + placeContactAvatars: 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 263dea78e..e3e74098e 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 @@ -1744,6 +1744,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, contacts: host.contacts !== undefined, @@ -1883,6 +1884,7 @@ function buildRuntime( contacts: callbacks.contacts !== 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 5b31655a1..2d0cc050d 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -278,6 +278,7 @@ describe("createWebWorkerPairingHostRuntime", () => { chat: false, permissionStatus: false, pocket: false, + profile: false, identityBackend: false, coinageWallet: false, contacts: false, @@ -439,52 +440,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, - 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, - 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-chat-v2/src/lib.rs b/rust/crates/truapi-chat-v2/src/lib.rs index 72317d31a..fac5846a9 100644 --- a/rust/crates/truapi-chat-v2/src/lib.rs +++ b/rust/crates/truapi-chat-v2/src/lib.rs @@ -377,6 +377,22 @@ 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. + /// + /// Known gap (docs/rfcs/profile-disclosure.md): index 21 is not yet agreed with native Chat. + ProfileReference { + 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 }, @@ -977,6 +993,61 @@ 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 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, @@ -1272,6 +1343,47 @@ pub fn decode_message(data: &[u8]) -> Result { }, } } + 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, + 1 => Some(cursor.read_string("reference")?), + value => { + return Err(ChatError::InvalidEncoding(format!( + "invalid profile reference option {value}" + ))); + } + }; + cursor.finish()?; + if content_index == 22 { + V2ChatMessageContent::PersonalProfileReference { + discloser_product_id, + reference, + revision, + } + } else { + V2ChatMessageContent::ProfileReference { + discloser_product_id, + reference, + } + } + } index => V2ChatMessageContent::UnsupportedContent { content_index: index, }, @@ -3598,6 +3710,74 @@ 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 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 908f0c770..02f628291 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 = "f7be28c22289b365"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "6bbdd3b23a6400bb"; /// `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 { @@ -1681,6 +1735,195 @@ 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: 69, + 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; +} + +/// `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: 69, + 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: 69, + 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: 69, + 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; +} + +/// `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: 69, + 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; +} + +/// `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 { @@ -2348,6 +2591,8 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ CoinPaymentRefund::DESCRIPTOR, CoinPaymentListenForPayment::DESCRIPTOR, ContactsPick::DESCRIPTOR, + ContactsPickMany::DESCRIPTOR, + ContactsPlaceLabels::DESCRIPTOR, EntropyDerive::DESCRIPTOR, LocalStorageRead::DESCRIPTOR, LocalStorageWrite::DESCRIPTOR, @@ -2367,6 +2612,13 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ PermissionsAuthorizeDevicePermission::DESCRIPTOR, PreimageLookupSubscribe::DESCRIPTOR, PreimageSubmit::DESCRIPTOR, + ProfilePresent::DESCRIPTOR, + ProfileDisclose::DESCRIPTOR, + ProfileRetract::DESCRIPTOR, + ProfilePresentContact::DESCRIPTOR, + ProfilePlaceContactAvatars::DESCRIPTOR, + ProfileOwnStatus::DESCRIPTOR, + ProfilePresentOwn::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, SigningCreateTransaction::DESCRIPTOR, SigningCreateTransactionWithLegacyAccount::DESCRIPTOR, @@ -2426,6 +2678,8 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ CoinPaymentRefund::DESCRIPTOR, CoinPaymentListenForPayment::DESCRIPTOR, ContactsPick::DESCRIPTOR, + ContactsPickMany::DESCRIPTOR, + ContactsPlaceLabels::DESCRIPTOR, EntropyDerive::DESCRIPTOR, LocalStorageRead::DESCRIPTOR, LocalStorageWrite::DESCRIPTOR, @@ -2445,6 +2699,13 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ PermissionsAuthorizeDevicePermission::DESCRIPTOR, PreimageLookupSubscribe::DESCRIPTOR, PreimageSubmit::DESCRIPTOR, + ProfilePresent::DESCRIPTOR, + ProfileDisclose::DESCRIPTOR, + ProfileRetract::DESCRIPTOR, + ProfilePresentContact::DESCRIPTOR, + ProfilePlaceContactAvatars::DESCRIPTOR, + ProfileOwnStatus::DESCRIPTOR, + ProfilePresentOwn::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, SigningCreateTransaction::DESCRIPTOR, SigningCreateTransactionWithLegacyAccount::DESCRIPTOR, @@ -2509,6 +2770,8 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ CoinPaymentRefund::DESCRIPTOR, CoinPaymentListenForPayment::DESCRIPTOR, ContactsPick::DESCRIPTOR, + ContactsPickMany::DESCRIPTOR, + ContactsPlaceLabels::DESCRIPTOR, EntropyDerive::DESCRIPTOR, LocalStorageRead::DESCRIPTOR, LocalStorageWrite::DESCRIPTOR, @@ -2530,6 +2793,13 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ PocketRemoveCard::DESCRIPTOR, PreimageLookupSubscribe::DESCRIPTOR, PreimageSubmit::DESCRIPTOR, + ProfilePresent::DESCRIPTOR, + ProfileDisclose::DESCRIPTOR, + 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 48e6a91fe..c1e4f6b1c 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -713,6 +713,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>, @@ -724,6 +727,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-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index b1d40c90c..13634ddcb 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,21 @@ 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"), + "ContactsPlatform" => Some("contactsHostAdapter"), + "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 @@ -1710,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-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 2634a6e1b..d572683e9 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -26,6 +26,7 @@ mod network; mod platform; mod pocket; mod product_config; +mod profile; mod qr_scanner; mod register_name; mod script_project; @@ -1260,6 +1261,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()); pairing_runtime.set_contacts_platform(contacts::CliContactsHost::from_env( storage_platform.clone(), )); @@ -1833,6 +1835,7 @@ fn build_signing_runtime( if let Some(pocket) = pocket { runtime.set_pocket_platform(pocket); } + runtime.set_profile_platform(profile::CliProfileHost::from_env()); runtime.set_core_db(core_db); runtime.start_statement_allowance_renewal(); Ok((runtime, platform)) diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index bcfa31565..2a2498bd6 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -1189,6 +1189,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-host-cli/src/profile.rs b/rust/crates/truapi-host-cli/src/profile.rs new file mode 100644 index 000000000..8b9bbbc00 --- /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/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 83ea8f57b..605d3e32e 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -409,23 +409,34 @@ 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, + 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` -and `PocketPlatform`, which `OptionalPlatform` lists instead: a host supplies -each only when it can serve it. Codegen reads `OptionalPlatform` to emit each listed +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` or `PocketPlatform` does the same for Contacts or -Pocket calls. +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 diff --git a/rust/crates/truapi/src/api.rs b/rust/crates/truapi/src/api.rs index 89a6b2a97..1e5e5e54c 100644 --- a/rust/crates/truapi/src/api.rs +++ b/rust/crates/truapi/src/api.rs @@ -13,6 +13,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; @@ -34,6 +35,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; @@ -57,6 +59,7 @@ pub trait TrUApi: + Permissions + Pocket + Preimage + + Profile + Renderer + ResourceAllocation + Signing @@ -83,6 +86,7 @@ impl TrUApi for T where + Permissions + Pocket + Preimage + + Profile + Renderer + ResourceAllocation + Signing diff --git a/rust/crates/truapi/src/api/contacts.rs b/rust/crates/truapi/src/api/contacts.rs index f360068a1..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 @@ -24,11 +26,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({}); @@ -54,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/api/profile.rs b/rust/crates/truapi/src/api/profile.rs new file mode 100644 index 000000000..d4111df4f --- /dev/null +++ b/rust/crates/truapi/src/api/profile.rs @@ -0,0 +1,200 @@ +//! Unified [`Profile`] trait. + +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, +}; +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 = 69)] +#[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()) + } + /// Store the user's profile and replace its independent delivery audiences. + /// + /// `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); + /// ``` + #[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 contact's available profile in host-owned UI. + /// + /// 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({ + /// contact: { tag: "Peer", value: { + /// peerIdentity: "0x0000000000000000000000000000000000000000000000000000000000000000", + /// } }, + /// }); + /// console.log("contact profile presentation:", result); + /// ``` + #[wire(id = 3)] + async fn present_contact( + &self, + _cx: &CallContext, + _request: HostProfilePresentContactRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// 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. + /// + /// 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 + /// 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 `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, + /// contact: { tag: "Peer", value: { + /// 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()) + } + + /// 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/host_core.rs b/rust/crates/truapi/src/host_core.rs index 949a2afaf..dcd72f1f9 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -16,6 +16,7 @@ use std::time::Duration; use crate::platform::{ ChatPlatform, CoinageWalletHost, ContactsPlatform, PermissionStatusHost, PocketPlatform, + ProfilePlatform, }; use crate::platform::{ CoreAdmin, PairingHostAdmin, PairingHostConfig, PermissionAuthorizationRequest, @@ -268,6 +269,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) + } + /// Install the host's [`ContactsPlatform`], which owns the contact list and /// draws the picker. /// @@ -279,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.invalidate_contacts(); + self.services + .contact_avatars + .contacts_changed(&self.services.spawner); } /// Build a product-facing runtime from this pairing host. @@ -712,6 +725,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 [`ContactsPlatform`], which owns the contact list and /// draws the picker. /// @@ -723,12 +746,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.invalidate_contacts(); + self.services + .contact_avatars + .contacts_changed(&self.services.spawner); } /// Install the host's [`DevicePairingObserver`], told whenever a device @@ -1250,7 +1275,7 @@ impl SigningHostRuntime { /// action streams. Unscoped 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 struct ConnectionAdapters { pub platform: Arc, @@ -1267,6 +1292,7 @@ pub struct ConnectionAdapters { pub chat: Arc>, pub renderer: Arc>, pub pocket_platform: Option>, + pub profile_platform: Option>, } impl ConnectionAdapters { @@ -1281,6 +1307,7 @@ impl ConnectionAdapters { chat: Arc::new(ActionChannel::chat()), renderer: Arc::new(ActionChannel::renderer()), pocket_platform: services.pocket_platform(), + profile_platform: services.profile_platform(), } } } @@ -1617,15 +1644,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.invalidate_contacts(); + services.contact_avatars.contacts_changed(&services.spawner); } /// Build a product-facing host core around a platform implementation and @@ -1895,6 +1919,8 @@ 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.admin.product_runtime.release_contact_labels(); self.host_subscriptions.close(); self.core.cancel_subscriptions(); } @@ -3658,9 +3684,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 { @@ -3693,7 +3719,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/host_internal/permissions.rs b/rust/crates/truapi/src/host_internal/permissions.rs index 47e8e4303..63350565a 100644 --- a/rust/crates/truapi/src/host_internal/permissions.rs +++ b/rust/crates/truapi/src/host_internal/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 crate::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 @@ -426,6 +428,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 + } } } @@ -490,6 +499,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 @@ -581,6 +593,41 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a } } + /// 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/src/lib.rs b/rust/crates/truapi/src/lib.rs index 8df1ba96d..2332f59fd 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, 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, ChatReaction, ChatRichText, ChatRoom, ChatRoomParticipation, ChatRoomRegistrationStatus, ColorToken, ColumnProps, ContactHandle, ContactPickOutcome, ContentAlignment, ContextualAlias, DerivationIndex, Dimensions, Effect, EffectProps, GenericError, HorizontalAlignment, HostAccountCreateProofRequest, @@ -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. @@ -164,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; @@ -219,6 +238,21 @@ 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; + /// 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/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs index c267a5a5f..46c3f01bd 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)] @@ -394,4 +394,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..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`]. /// @@ -60,6 +58,14 @@ 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 e9201a31b..4c3b3fb9a 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 @@ -152,6 +180,30 @@ 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/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index 7c429d268..c6eaa3416 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -658,6 +658,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/src/platform.rs b/rust/crates/truapi/src/platform.rs index 0d2cb1904..2ce4d9de9 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -26,15 +26,16 @@ 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, + HostProfilePlaceContactAvatarsError, HostProfilePresentError, HostProfilePresentRequest, HostPushNotificationRequest, HostPushNotificationResponse, HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, HostSignRawWithLegacyAccountRequest, HostThemeSubscribeItem, HostWorkerBeginOperationResponse, @@ -1217,6 +1218,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. @@ -1381,7 +1386,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], @@ -1393,7 +1401,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], @@ -1412,7 +1423,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>, @@ -1422,7 +1436,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, @@ -1495,7 +1512,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, @@ -1511,7 +1531,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, @@ -1531,7 +1554,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. @@ -1549,7 +1575,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 { @@ -1609,7 +1638,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, @@ -1623,7 +1655,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, @@ -1633,7 +1668,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, @@ -1890,6 +1928,42 @@ pub enum CoreStorageKey { /// Host-selected Chat network. genesis_hash: [u8; 32], }, + /// 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 { + /// 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, + }, + /// 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`]. @@ -1946,6 +2020,13 @@ 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::ProfilePersonalReferencesReceived { .. } => { + ("ProfilePersonalReferencesReceived", None) + } CoreStorageKey::NativeChatFileChunk { product_id, .. } => { ("NativeChatFileChunk", Some(product_id)) } @@ -2033,6 +2114,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. @@ -3590,18 +3680,38 @@ 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, } +/// 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")), + 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 /// 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, @@ -3671,6 +3781,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. @@ -3828,6 +3941,173 @@ 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>; + + /// 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 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: shared.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 + /// 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, 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. + async fn place_contact_avatars( + &self, + product: &ProductContext, + placed: PlacedAvatars, + ) -> Result<(), HostProfilePlaceContactAvatarsError> { + let _ = (product, placed); + Ok(()) + } +} + +/// 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 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 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, +} + +/// 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("SharedContactProfile") + .field("reference", &"[REDACTED]") + .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. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[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, + /// 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( + 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, + /// 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, + /// 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, +} + +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]") + .field("shared_at", &self.shared_at) + .finish() + } +} + /// What the operating system currently says about a device capability. /// /// Distinct from [`PermissionAuthorizationStatus`], which is the product-scoped @@ -3952,6 +4232,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. /// @@ -3982,11 +4313,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 @@ -3999,6 +4326,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 @@ -4048,7 +4401,13 @@ 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 + ContactsPlatform + PermissionStatusHost + PocketPlatform + IdentityBackendHost + CoinageWalletHost + ChatPlatform + + ContactsPlatform + + PermissionStatusHost + + PocketPlatform + + ProfilePlatform + + IdentityBackendHost + + CoinageWalletHost { } @@ -4057,6 +4416,7 @@ impl OptionalPlatform for T where + ContactsPlatform + PermissionStatusHost + PocketPlatform + + ProfilePlatform + IdentityBackendHost + CoinageWalletHost { diff --git a/rust/crates/truapi/src/platform/mock.rs b/rust/crates/truapi/src/platform/mock.rs index bbaa2c352..4ded4476a 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. @@ -93,6 +94,8 @@ pub enum ConfirmKind { ChatAuthority, /// [`UserConfirmationReview::MainPurseChatPayment`]. MainPurseChatPayment, + /// [`UserConfirmationReview::ProfileDisclosure`]. + ProfileDisclosure, } impl ConfirmKind { @@ -114,6 +117,7 @@ impl ConfirmKind { UserConfirmationReview::ProductSubtree(_) => ConfirmKind::ProductSubtree, UserConfirmationReview::ChatAuthority(_) => ConfirmKind::ChatAuthority, UserConfirmationReview::MainPurseChatPayment(_) => ConfirmKind::MainPurseChatPayment, + UserConfirmationReview::ProfileDisclosure(_) => ConfirmKind::ProfileDisclosure, } } } @@ -827,6 +831,31 @@ fn core_key(key: &CoreStorageKey) -> String { hex_key(root_public_key), hex_key(genesis_hash) ), + 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::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 09ac3b3c5..7b67a17e5 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -29,6 +29,7 @@ mod native_chat; mod pairing_host; pub mod product_manifest; mod product_subtree; +mod profile; mod renderer; mod ring_vrf_registry; /// Role-neutral runtime services shared by product-facing runtimes. @@ -93,7 +94,7 @@ pub use signing_host::{ }; pub use signing_host::{LocalIdentity, LocalIdentityContext, WalletAllowanceSnapshot}; use tracing::{instrument, warn}; -use truapi::api::{Chat, Contacts, Pocket, Renderer}; +use truapi::api::{Chat, Contacts, Pocket, Profile, Renderer}; use truapi::versioned::account::{ HostAccountGetError, HostAccountSignVrfError, HostProductDeviceChatError, }; @@ -105,13 +106,25 @@ use truapi::versioned::chat::{ HostChatRegisterBotError, HostChatRegisterBotRequest, HostChatRegisterBotResponse, }; use truapi::versioned::contacts::{ - HostContactsPickError, HostContactsPickRequest, HostContactsPickResponse, + HostContactsPickError, HostContactsPickManyError, HostContactsPickManyRequest, + HostContactsPickManyResponse, HostContactsPickRequest, HostContactsPickResponse, + HostContactsPlaceLabelsError, HostContactsPlaceLabelsRequest, HostContactsPlaceLabelsResponse, }; use truapi::versioned::pocket::{ HostPocketListSubscribeError, HostPocketListSubscribeItem, HostPocketListSubscribeRequest, HostPocketRemoveCardError, HostPocketRemoveCardRequest, HostPocketRemoveCardResponse, }; 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, +}; use truapi::versioned::renderer::{ HostRendererActionSubscribeError, HostRendererActionSubscribeItem, HostRendererActionSubscribeRequest, @@ -163,6 +176,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"; @@ -317,6 +333,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. @@ -337,6 +354,8 @@ pub struct ProductRuntimeHost { impl Drop for ProductRuntimeHost { fn drop(&mut self) { self.release_open_operations(); + self.release_contact_avatars(); + self.release_contact_labels(); } } @@ -363,6 +382,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()), } } @@ -493,6 +513,7 @@ impl ProductRuntimeHost { chat, renderer, pocket_platform: None, + profile_platform: None, open_operations: Mutex::new(HashSet::new()), }; (host, pairing_host) @@ -738,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 } @@ -811,6 +832,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, @@ -1223,6 +1257,21 @@ 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); + } + + /// 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) { @@ -1264,6 +1313,54 @@ 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) + } + + /// 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, + }) + } + + /// 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. + fn profile_disclosure_changed(&self) { + if let Some(session) = self.authority.current_session() { + self.authority.profile_disclosure_changed(&session); + } + } + /// Replace the contact handles a call declares with the accounts they /// name, before the call is shown to the user or signed. /// @@ -1285,6 +1382,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) => { @@ -1295,48 +1405,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 @@ -1375,15 +1450,66 @@ 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"))] 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))?; @@ -1397,11 +1523,29 @@ 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(|_| { + 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(unknown(v01::GenericError { + reason: "contact picker interrupted".into(), + })); + } + let outcome = match picked.map_err(unknown)? { crate::platform::HostContactPick::Picked { account } => { let handle = handles.mint(&account); self.services @@ -1421,6 +1565,227 @@ 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(|_| { + 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(), + })); + } + 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(|_| { + 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(), + })); + } + 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(error(Error::NotConnected)); + } + 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(|_| { + 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, + }) + }) + .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; + 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(error(Error::NotConnected)); + } + 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. @@ -1614,6 +1979,428 @@ 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))) + } + + #[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 != crate::platform::ProductExecutionKind::App { + return Err(CallError::Denied); + } + 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))?; + 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( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => { + return Err(domain(v01::HostProfileDiscloseError::PermissionDenied)); + } + Err(reason) => return Err(domain(v01::HostProfileDiscloseError::Unknown { reason })), + } + 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 + .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, + 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::from_latest((), version)) + } + + #[instrument(skip_all, fields(runtime.method = "profile.retract"))] + async fn retract( + &self, + _cx: &CallContext, + _request: HostProfileRetractRequest, + ) -> Result> { + if self.product.execution_kind != crate::platform::ProductExecutionKind::App { + return Err(CallError::Denied); + } + 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(); + let guard = self.services.profile_state_gate.lock().await; + 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(domain(v01::HostProfileRetractError::NotDiscloser)) + } + Some(_) => { + profile::clear_disclosure(storage, owner) + .await + .map_err(unknown)?; + drop(guard); + self.profile_disclosure_changed(); + self.services + .contact_avatars + .redraw_owner(owner, &self.services.spawner); + Ok(HostProfileRetractResponse::V1) + } + } + } + + #[instrument(skip_all, fields(runtime.method = "profile.present_contact"))] + async fn present_contact( + &self, + _cx: &CallContext, + request: HostProfilePresentContactRequest, + ) -> Result> { + let platform = self.profile_platform()?; + 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))?; + 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 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) + } + 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 { .. }) + && self.services.contact_handles.generation() != generation) + { + return Ok(success()); + } + let result = platform + .present_contact_profile( + &self.product, + crate::platform::PresentedContactProfile { + shared, + peer_identity, + username, + }, + ) + .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"))] + 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 != crate::platform::ProductExecutionKind::App { + return Err(CallError::Denied); + } + let platform = self.profile_platform()?; + 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 }) + })?; + let placement = self + .services + .contact_avatars + .for_runtime(self.core_instance, || { + profile::avatars::ContactAvatarPlacement::new( + platform, + self.platform.clone(), + self.product.clone(), + Arc::downgrade(&self.services), + ) + }); + 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, + )); + }; + let authority = request + .slots + .iter() + .any(|slot| matches!(slot.contact, truapi::latest::ProfileContact::Handle { .. })) + .then(|| Arc::downgrade(&self.authority)); + placement + .place(owner, request, authority) + .await + .map(|()| HostProfilePlaceContactAvatarsResponse::from_latest((), version)) + .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 { + !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/src/runtime/authority.rs b/rust/crates/truapi/src/runtime/authority.rs index 61af3bb78..e2d6f1013 100644 --- a/rust/crates/truapi/src/runtime/authority.rs +++ b/rust/crates/truapi/src/runtime/authority.rs @@ -642,6 +642,26 @@ pub 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) {} + + /// 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/src/runtime/chat_device.rs b/rust/crates/truapi/src/runtime/chat_device.rs index a83513c59..e90579003 100644 --- a/rust/crates/truapi/src/runtime/chat_device.rs +++ b/rust/crates/truapi/src/runtime/chat_device.rs @@ -105,6 +105,34 @@ 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, + /// 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. +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 +572,53 @@ 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, + } => { + 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::App, + revision: 0, + }, + )); + } ordinary => { validate_ordinary(&ordinary)?; return Ok(OpenedDeviceMessage::Ordinary(core::mem::take(bytes))); diff --git a/rust/crates/truapi/src/runtime/contacts.rs b/rust/crates/truapi/src/runtime/contacts.rs index 42f67d3d2..cabe0bc75 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,151 @@ //! 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(); + if placements.is_empty() { + return; + } + 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 +500,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 818f12907..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), @@ -270,6 +270,57 @@ 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; + } + })); + } + + /// 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( @@ -491,6 +542,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(); @@ -500,6 +554,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?); @@ -585,6 +640,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/src/runtime/native_chat/actor.rs b/rust/crates/truapi/src/runtime/native_chat/actor.rs index fd895760d..b0b3e9068 100644 --- a/rust/crates/truapi/src/runtime/native_chat/actor.rs +++ b/rust/crates/truapi/src/runtime/native_chat/actor.rs @@ -4,6 +4,7 @@ mod files; mod history; +mod profile; mod receive; #[cfg(test)] mod tests; @@ -37,6 +38,8 @@ use crate::unix_time::current_unix_secs; type Error = HostProductDeviceChatError; const MAX_PEERS: usize = 256; const MAX_OUTBOX: usize = 256; +/// 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; const LIFETIME: u64 = 2 * 86_400; @@ -147,6 +150,21 @@ enum OutgoingKind { Payment([u8; 32]), Acknowledgment, 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)] @@ -211,6 +229,9 @@ 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, } impl State { @@ -233,6 +254,8 @@ impl State { rich_messages: Vec::new(), marker: *b"HCN3", boundary: BoundaryState::default(), + profile_marker: profile::WATERMARK_MARKER, + profile_shared: Vec::new(), }) } fn peer(&self, identity: &[u8; 32]) -> Result<&Peer, Error> { @@ -264,12 +287,26 @@ impl State { } return Ok(()); } - if self.outbox.len() >= MAX_OUTBOX { + let profile = outgoing.kind.profile_scope().is_some(); + 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| entry.kind.profile_scope().is_some() == profile) + .count() + } } impl Decode for State { @@ -304,6 +341,18 @@ impl Decode for State { } BoundaryState::decode(input)? }; + // Added after the boundary state: a snapshot that ends here predates it. + // 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, index, @@ -322,6 +371,8 @@ impl Decode for State { rich_messages, marker: *b"HCN3", boundary, + profile_marker: profile::WATERMARK_MARKER, + profile_shared, }) } } @@ -469,7 +520,9 @@ 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.profile_shared.len() > MAX_PEERS * 2 || state.received.len() > MAX_RECEIPTS || state.sent.len() > MAX_RECEIPTS || state.accepted_payments.len() > MAX_RECEIPTS @@ -657,6 +710,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,9 +730,21 @@ impl NativeChatActor { state.boundary.legacy_pending || matches!( entry.kind, - OutgoingKind::Payment(_) | OutgoingKind::Rich(_) + 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 { @@ -737,6 +813,11 @@ impl NativeChatActor { state .outbox .retain(|entry| matches!(entry.kind, OutgoingKind::Payment(_))); + // 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(); @@ -1181,6 +1262,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| { 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 88f4fa372..cec134b55 100644 --- a/rust/crates/truapi/src/runtime/native_chat/actor/history.rs +++ b/rust/crates/truapi/src/runtime/native_chat/actor/history.rs @@ -198,6 +198,10 @@ impl NativeChatActor { let mut bytes_seen = 0usize; let mut had_history = false; let mut had_rich = 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 @@ -297,6 +301,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?; @@ -310,10 +325,11 @@ impl NativeChatActor { files::merge_received(state, rich) }) .await?; - // Preserve the original canonical request when it needed neither HOP - // expansion nor removal of private rich-content frames, except - // references already transferred by legacy migration. - let plaintext = if had_history || had_rich { + self.record_profile_references(context, peer, profile_references) + .await?; + // Preserve the original canonical request only when no history + // expansion or removal of private frames was needed. + let plaintext = if had_history || had_rich || stripped { wire::encode_transport_request_plaintext(&request_id, &expanded) .map_err(|_| Error::InvalidStatement)? } else { @@ -381,9 +397,73 @@ impl NativeChatActor { validate_deliveries(&state.boundary.history) }) .await?; + self.record_profile_references(context, peer, profile_references) + .await?; self.continue_open(context, id, 0).await } + /// 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, + peer: [u8; 32], + 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 { + 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; + } + 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, + &context.services.spawner, + ); + } + Ok(()) + } + pub(in crate::runtime::native_chat) async fn continue_open( self: &Arc, context: &NativeChatContext, diff --git a/rust/crates/truapi/src/runtime/native_chat/actor/profile.rs b/rust/crates/truapi/src/runtime/native_chat/actor/profile.rs new file mode 100644 index 000000000..7f38fb13d --- /dev/null +++ b/rust/crates/truapi/src/runtime/native_chat/actor/profile.rs @@ -0,0 +1,734 @@ +// 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 +//! publish: every ready peer whose watermark differs from the disclosure is +//! 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. +//! +//! 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 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 +//! [`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 user discloses or retracts. + +use super::*; +use crate::runtime::native_chat::background::require_authorized; +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]>, + /// 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, + /// Frames signed for this digest, the one sent included. + pub(super) attempts: u8, + /// The frame sent lapsed without an acknowledgement. + 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)] +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, 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 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( + bytes: &[u8], +) -> Result, parity_scale_codec::Error> { + use parity_scale_codec::DecodeAll; + 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, + attempts: 1, + lapsed: false, + }) + .collect()); + } + 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 { + root_public_key: context.session.public_key, + genesis_hash: context.genesis_hash, + } +} + +/// 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. +pub(super) 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-v2", + &disclosure.product_id, + &disclosure.reference, + disclosure.revision, + ) + .encode(), + ) +} + +/// 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, +} + +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 +/// it holds what it should, is still offered it, or has had every attempt. +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, + Some(&disclosure.reference), + Some(*digest), + ), + (None, Some(watermark)) => (&watermark.discloser_product_id, None, None), + (None, None) => return None, + }; + Some(Frame { + discloser: discloser.clone(), + reference: reference.cloned(), + digest, + attempts, + }) +} + +/// A queued reference whose statement lifetime is over. +fn lapsed(entry: &Outgoing, now: u64) -> bool { + 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 + /// 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 _profile_state = context.services.profile_state_gate.lock().await; + if self + .store + .read(|state| state.boundary.legacy_pending) + .await? + { + return Ok(false); + } + self.retire_lapsed_profile_references(context).await?; + 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| { + 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() { + 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); + } + let now = current_unix_secs().saturating_mul(1000); + let mut queued = false; + for (identity, scope) in stale { + let peer = state.peer(&identity)?.clone(); + if !peer.ready() { + continue; + } + let current = state + .profile_shared + .iter() + .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 + // 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 = 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 = 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( + state, + &peer, + &peer.active_devices(), + &request_id, + &messages, + )?; + // Each scope keeps its own pending share or withdrawal. + state.outbox.retain(|entry| { + entry.peer != identity || entry.kind.profile_scope() != Some(scope) + }); + match state.queue(Outgoing { + peer: identity, + request_id, + digest: hash(&messages.encode()), + kind: match scope { + ProfileScope::App => OutgoingKind::ProfileReference(tag), + ProfileScope::Personal => OutgoingKind::PersonalProfileReference(tag), + }, + roster_revision: peer.revision, + statement, + last_attempt: 0, + }) { + Ok(()) => queued = true, + // No room: this peer and the rest keep their + // watermarks, so a later publish retries them. + Err(Error::StorageUnavailable) => break, + Err(error) => return Err(error), + } + state + .profile_shared + .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, + attempts: frame.attempts, + lapsed: false, + }); + } + Ok(queued) + }) + .await + } + + /// 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 + } + + /// 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, + 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> { + 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); + } + // 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 + && entry.kind.profile_scope() == Some(watermark.scope) + && lapsed(entry, now) + }); + } + state.outbox.retain(|entry| !lapsed(entry, now)); + Ok(()) + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn disclosure(reference: &str) -> (Disclosure, [u8; 32]) { + let disclosure = Disclosure { + 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) + } + + #[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], + 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), 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), 0).expect("a withdrawal is sent to a holder"), + Frame { + discloser: "seity.dot".into(), + reference: None, + digest: None, + attempts: 1, + } + ); + let withdrawn = ProfileWatermark { + digest: None, + ..held + }; + assert!( + wanted(None, Some(&withdrawn), 0).is_none(), + "a withdrawal is sent once" + ); + assert!( + wanted(Some(¤t), Some(&withdrawn), 0).is_some(), + "a withdrawn peer is sent a new disclosure" + ); + assert!( + wanted(None, None, 0).is_none(), + "nothing to withdraw from a new peer" + ); + assert!( + wanted(Some(¤t), None, 0).is_some(), + "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], + 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)), + 0, + ) + .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)), + 0 + ) + .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)), + 0 + ) + .expect("a new disclosure is sent") + .attempts, + 1, + "with attempts of its own" + ); + assert_eq!( + 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)), 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 64c65dd69..690f26ecf 100644 --- a/rust/crates/truapi/src/runtime/native_chat/actor/receive.rs +++ b/rust/crates/truapi/src/runtime/native_chat/actor/receive.rs @@ -959,6 +959,23 @@ fn exchange_digest(messages: &[OpenedDeviceMessage]) -> Result<[u8; 32], Error> hasher.update(digest); continue; } + OpenedDeviceMessage::ProfileReference(frame) => { + 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, + 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/src/runtime/native_chat/actor/tests.rs b/rust/crates/truapi/src/runtime/native_chat/actor/tests.rs index 951e50e6d..9f380f6ab 100644 --- a/rust/crates/truapi/src/runtime/native_chat/actor/tests.rs +++ b/rust/crates/truapi/src/runtime/native_chat/actor/tests.rs @@ -12,7 +12,8 @@ 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}, + versioned::IntoLatest, }; use futures::{ executor::block_on, @@ -1589,3 +1590,1773 @@ 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()); } + +/// 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, + crate::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() - 4].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"); + }); +} + +/// 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, + crate::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() - 4].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 { + 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(); + let owner = profile::profile_owner(&fixture.context); + set_product_grants( + &fixture.platform, + PRODUCT, + crate::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(), + owner, + &Disclosure { + product_id: "seity.dot".into(), + reference: PROFILE_REFERENCE.into(), + revision: 1, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), + }, + ) + .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); + let first_request = view.prepared[0].request_id.clone(); + 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(), + owner, + &Disclosure { + product_id: "seity.dot".into(), + reference: format!("{PROFILE_REFERENCE}ff"), + revision: 1, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), + }, + ) + .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(), owner) + .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 + .iter() + .all(|watermark| watermark.digest.is_none())) + .await + .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(), + revision: 1, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), + }, + ) + .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); + 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, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), + }, + ) + .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" + ); + }); +} + +#[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 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.as_deref(), Some(PROFILE_REFERENCE)); + assert_eq!(held.discloser_product_id, "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 + ); + }); +} + +#[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(), + crate::platform::ProductContext::new(PRODUCT.to_string()).unwrap(), + Arc::downgrade(&fixture.context.services), + ) + }); + 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::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(); + let placed = |avatars| { + ( + PRODUCT.to_string(), + crate::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; + let avatar = |shared_at| crate::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![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-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(4)[3], placed(Vec::new())); + }); +} + +/// 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(); +} + +/// 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(), + revision: 1, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), + }, + ) + .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, + crate::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 + .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!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "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, + crate::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" + ); + }); +} + +/// 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_is_resent_a_bounded_number_of_times_per_disclosure() { + block_on(async { + let fixture = Fixture::new(); + set_product_grants( + &fixture.platform, + PRODUCT, + crate::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 registry = NativeChatRegistry::default(); + assert!( + actor + .publish_profile_reference(&fixture.context) + .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()); + } + + 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 last attempt is dropped, not re-signed" + ); + assert!( + !actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "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"), + revision: 1, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), + }, + ) + .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, + crate::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, + crate::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-")); + }); +} + +/// 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, + crate::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] +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, + crate::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(), + revision: 1, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), + }, + )) + .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" + ); +} + +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/native_chat/identity.rs b/rust/crates/truapi/src/runtime/native_chat/identity.rs index 981e493ad..d8c38d526 100644 --- a/rust/crates/truapi/src/runtime/native_chat/identity.rs +++ b/rust/crates/truapi/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/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/profile.rs b/rust/crates/truapi/src/runtime/profile.rs new file mode 100644 index 000000000..c0305cada --- /dev/null +++ b/rust/crates/truapi/src/runtime/profile.rs @@ -0,0 +1,435 @@ +//! Profile disclosure state: the reference the user disclosed to their chat +//! 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` 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 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. +#[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 { + pub(crate) 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(), + } + } + + 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. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +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, + /// 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. +#[derive(Decode)] +struct UnrevisedDisclosure { + product_id: String, + reference: String, +} + +/// 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, + /// 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. + pub(crate) reference: Option, +} + +/// 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), +} + +#[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; + +fn storage_error(error: impl core::fmt::Debug) -> String { + format!("profile storage failed: {error:?}") +} + +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((0, None)); + }; + let bytes = raw.as_slice(); + 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); + } + if let Ok(old) = AllChatDisclosure::decode_all(&mut &bytes[..]) { + return Ok(( + old.revision, + Some(Disclosure { + product_id: old.product_id, + reference: old.reference, + 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}")) +} + +pub(crate) async fn write_disclosure( + storage: &(impl CoreStorage + ?Sized), + 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(), bytes) + .await + .map_err(storage_error) +} + +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 + .write_core_storage( + owner.disclosure_key(), + (DISCLOSURE_MARKER, revision, None::).encode(), + ) + .await + .map_err(storage_error) +} + +async fn read_received_slot( + storage: &(impl CoreStorage + ?Sized), + key: CoreStorageKey, +) -> Result, String> { + let Some(raw) = storage + .read_core_storage(key) + .await + .map_err(storage_error)? + else { + return Ok(Vec::new()); + }; + 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}")), + } +} + +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_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. +/// `true` when the frame was kept. +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 { + 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, + timestamp, + reference, + }; + match entries + .iter() + .position(|entry| entry.peer_identity == peer_identity) + { + 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()); + } + None => entries.push(received), + } + storage + .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 new file mode 100644 index 000000000..8d9e18d62 --- /dev/null +++ b/rust/crates/truapi/src/runtime/profile/avatars.rs @@ -0,0 +1,459 @@ +//! 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 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 +//! 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, 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, Weak}; + +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::{ + 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. +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: &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() + 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 {slot} must be square and 1 to {MAX_AVATAR_SIDE} a side" + )); + } + if !seen.insert(slot) { + return Err(format!("avatar slot {slot} is placed twice")); + } + } + Ok(()) +} + +/// One product connection's placement. +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, + /// The connection is gone; nothing is drawn for it again. + closed: bool, +} + +impl ContactAvatarPlacement { + pub(crate) fn new( + platform: Arc, + storage: Arc, + product: ProductContext, + services: Weak, + ) -> Self { + Self { + platform, + storage, + product, + services, + 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: HostProfilePlaceContactAvatarsRequest, + authority: Option>, + ) -> Result<(), HostProfilePlaceContactAvatarsError> { + let mut state = self.state.lock().await; + if state.closed { + return Ok(()); + } + state.placed = None; + self.draw(owner, &request, authority.as_ref()).await?; + if request.own.is_some() || !request.slots.is_empty() { + state.placed = Some(RememberedPlacement { + owner, + request, + authority, + }); + } + 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(RememberedPlacement { request, .. }) = state.placed.take() else { + return; + }; + let (surface_width, surface_height) = (request.surface_width, request.surface_height); + let cleared = PlacedAvatars { + surface_width, + 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, or what + /// `owner` disclosed, changed. + async fn redraw(&self, owner: ProfileOwner) { + let state = self.state.lock().await; + 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, 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: &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 + .map_err(unknown)?; + if let Some(disclosure) = + disclosure.filter(|disclosure| is_screened_profile_reference(&disclosure.reference)) + { + own_avatar = Some(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 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, + surface_height, + avatars, + }; + match self + .platform + .place_contact_avatars(&self.product, placed) + .await + { + Ok(()) => Ok(()), + 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. + 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 and when it was shared. + async fn drawable( + &self, + owner: ProfileOwner, + slots: &[ContactAvatarSlot], + authority: Option<&Weak>, + ) -> Result, String> { + if slots.is_empty() { + return Ok(Vec::new()); + } + 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, 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(&identity) + .map(|(reference, shared_at)| PlacedAvatar { + slot: slot.slot, + rect: slot.rect, + clip: slot.clip, + reference: reference.clone(), + shared_at: *shared_at, + }) + }) + .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; + } + })); + } + + /// 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; + } + })); + } + + /// 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 02769625a..918042cbc 100644 --- a/rust/crates/truapi/src/runtime/services.rs +++ b/rust/crates/truapi/src/runtime/services.rs @@ -48,6 +48,14 @@ pub struct RuntimeServices { permission_status: OnceLock>, /// Host Pocket adapter, installed once at startup by a host with a Pocket /// surface. Unset leaves every product Pocket call `Unsupported`. + /// 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, + /// 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>, @@ -57,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>, @@ -150,9 +160,13 @@ impl RuntimeServices { native_wallet, permission_status: OnceLock::new(), 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(), + contact_labels: Default::default(), device_pairing_observer: OnceLock::new(), #[cfg(not(target_arch = "wasm32"))] core_db: OnceLock::new(), @@ -230,6 +244,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 contacts adapter. Answers whether this call was the /// one that installed it. pub fn install_contacts_platform( @@ -244,6 +274,18 @@ 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 62edc5ee1..37b9d1a12 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -570,7 +570,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() @@ -591,7 +591,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") @@ -1605,6 +1605,24 @@ 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 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, @@ -4882,8 +4900,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(); @@ -5166,7 +5185,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 6b21852f0..2bff84923 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,379 @@ 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!(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 + ); +} + +#[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!(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 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_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]; + 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 +1234,39 @@ 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] @@ -2239,6 +2645,1736 @@ 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 crate::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(()) + } +} + +/// 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 crate::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: crate::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( + 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) + )); +} + +/// 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; + 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, + }), + )) +} + +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, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), + }, + )) + .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]; + +/// 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 = 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(), owner)) + .expect("readable") + .expect("stored"); + 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( + 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(), owner)) + .expect("readable"), + None + ); + assert_eq!( + retract(&seity).expect("retracting nothing is not an error"), + HostProfileRetractResponse::V1 + ); +} + +#[test] +fn a_disclosure_stored_before_revisions_still_reads() { + 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); + 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, + 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(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); + 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); + 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, + 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 + .contacts + .lock() + .expect("contacts mutex poisoned") + .as_slice(), + [ + ("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; + #[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]; + 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), + "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()); + // The first product is refused, the second allowed, each asked once. + platform + .permission_confirmation_decisions + .lock() + .expect("permission confirmation mutex poisoned") + .extend([ + crate::platform::PermissionDecision::Deny, + crate::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(), + crate::platform::ProductExecutionKind::Worker, + ) + .expect("valid product"), + None, + ), + WALLET, + ); + assert!(matches!( + disclose(&worker, CONTACTS_REFERENCE), + Err(CallError::Denied) + )); + assert!(matches!(retract(&worker), Err(CallError::Denied))); + + let app = app_host(&platform, "seity.dot"); + 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(), 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_state_belongs_to_the_signed_in_wallet() { + let platform = consenting_platform(); + let presented = Arc::new(RecordingProfilePlatform::default()); + 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], + ); + 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(), + 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"), + HostProfilePresentContactResponse::V1 + ); + assert_eq!( + presented + .presented + .lock() + .expect("presented mutex poisoned") + .as_slice(), + [("egui-chat.dot".to_string(), CONTACTS_REFERENCE.to_string())], + "a host without contact attribution presents the stored reference by default" + ); + 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 = 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), + Err(CallError::Domain(HostProfilePresentContactError::V1( + v01::HostProfilePresentContactError::NotShared + ))) + )); + + // 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( + v01::HostProfilePresentContactError::NotShared + ))) + )); + + assert!(matches!( + present_contact( + &profile_host_on( + platform.clone(), + ProductContext::new("egui-chat.dot".to_string()).expect("valid product"), + 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, + ), + Err(CallError::Unsupported) + )); +} + +#[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(), + crate::platform::PresentedContactProfile { + shared: Some(crate::platform::SharedContactProfile { + reference: CONTACTS_REFERENCE.to_string(), + shared_at: 1_700_000_000_500, + }), + peer_identity: alice, + // 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, and no name \ + it does not know" + ); + 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( + 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`], shared by a +/// frame sent at time 1. +fn placed_avatar(slot: u32, reference: &str) -> crate::platform::PlacedAvatar { + crate::platform::PlacedAvatar { + slot, + rect: avatar_rect(16, 80 + 56 * slot as i32, 44), + clip: AVATAR_CLIP, + shared_at: 1, + reference: reference.to_string(), + } +} + +fn placed_avatars(avatars: Vec) -> crate::platform::PlacedAvatars { + crate::platform::PlacedAvatars { + surface_width: 360, + surface_height: 640, + avatars, + } +} + +/// 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| { + let () = response.into_latest(); + HostProfilePlaceContactAvatarsResponse::from_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 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(); + 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, + all_chat_apps: true, + app_products: Vec::new(), + contacts: Vec::new(), + }, + )) + .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!( + futures::executor::block_on(Profile::place_contact_avatars( + &chat, + &CallContext::default(), + HostProfilePlaceContactAvatarsRequest::V3(request), + )), + Err(CallError::Domain(HostProfilePlaceContactAvatarsError::V3( + v01::HostProfilePlaceContactAvatarsError::Unknown { .. } + ))) + )); + assert!(avatars.placements().is_empty()); +} + +#[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(), + crate::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/src/test_support.rs b/rust/crates/truapi/src/test_support.rs index dea7fd54a..1cb84368b 100644 --- a/rust/crates/truapi/src/test_support.rs +++ b/rust/crates/truapi/src/test_support.rs @@ -28,6 +28,7 @@ use crate::platform::{ SignRawReview, SignVrfReview, StatementStoreProductSignReview, ThemeHost, UserConfirmation, ChatAuthorityReview, HopProvider, MainPurseChatPaymentReview, NativeChatFileExportRequest, NativeChatFilePickRequest, NativeChatFilesHost, NativeChatPickedFile, + ProfileDisclosureReview, UserConfirmationReview, }; use futures::Stream; @@ -72,6 +73,54 @@ pub 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, crate::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, crate::platform::PlacedAvatars)> { + wait_until( + || self.placements().len() >= count, + "the host was not handed the expected avatar placements", + ); + self.placements() + } +} + +#[crate::platform::async_trait] +impl crate::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: crate::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. @@ -111,6 +160,8 @@ pub struct StubPlatform { Mutex>, /// Inverted so the derived default (`false`) approves, matching the /// pre-consent behavior where a cold own-account resolve was not gated. + pub profile_disclosure_confirmed: bool, + pub profile_disclosure_reviews: Arc>>, pub product_subtree_denied: bool, pub product_subtree_reviews: Arc>>, pub identity_disclosure_confirmed: bool, @@ -2118,6 +2169,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/v01.rs b/rust/crates/truapi/src/v01.rs index e3a5d760c..c43f6afdd 100644 --- a/rust/crates/truapi/src/v01.rs +++ b/rust/crates/truapi/src/v01.rs @@ -14,6 +14,7 @@ mod payment; mod permissions; mod pocket; mod preimage; +mod profile; mod renderer; mod resource_allocation; mod signing; @@ -37,6 +38,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/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/v01/profile.rs b/rust/crates/truapi/src/v01/profile.rs new file mode 100644 index 000000000..4d4425378 --- /dev/null +++ b/rust/crates/truapi/src/v01/profile.rs @@ -0,0 +1,263 @@ +use alloc::string::String; +use alloc::vec::Vec; +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( + 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, +} + +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( + 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, + /// Catch-all. + Unknown { + /// Human-readable reason. + 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( + 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, +} + +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( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] +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. + reason: String, + }, +} + +/// Profile retraction failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] +pub enum HostProfileRetractError { + /// Another product disclosed the reference the host holds. + NotDiscloser, + /// No user is signed in. + NotConnected, + /// 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( + 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], +} + +/// Contact profile presentation failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[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, + /// The host holds a reference it cannot parse. + InvalidReference, + /// No user is signed in. + NotConnected, + /// Catch-all. + Unknown { + /// Human-readable reason. + 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( + 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 + /// 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( + 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 + /// 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( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct AvatarRect { + /// Left edge. + pub x: i32, + /// Top edge. + pub y: i32, + /// Width. + pub width: u32, + /// Height. + 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( + all(feature = "runtime", not(target_arch = "wasm32")), + 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/v02.rs b/rust/crates/truapi/src/v02.rs index 4d07ccb76..a5000ccf5 100644 --- a/rust/crates/truapi/src/v02.rs +++ b/rust/crates/truapi/src/v02.rs @@ -7,7 +7,9 @@ mod account; mod local_storage; mod locale; +mod profile; pub use account::*; pub use local_storage::*; pub use locale::*; +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..5dfcf72c3 --- /dev/null +++ b/rust/crates/truapi/src/v02/profile.rs @@ -0,0 +1,103 @@ +use alloc::{string::String, vec::Vec}; +use core::fmt; +use parity_scale_codec::{Decode, Encode}; + +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. +/// +/// 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, +} + +/// 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.rs b/rust/crates/truapi/src/versioned.rs index d4b56408e..267189c8b 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -48,6 +48,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/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 } } diff --git a/rust/crates/truapi/src/versioned/profile.rs b/rust/crates/truapi/src/versioned/profile.rs new file mode 100644 index 000000000..4e67f5641 --- /dev/null +++ b/rust/crates/truapi/src/versioned/profile.rs @@ -0,0 +1,299 @@ +//! Versioned wrappers for [`Profile`](crate::api::Profile) methods. +//! 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, 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, + 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, + 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, V3 } + pub enum HostProfilePlaceContactAvatarsError { + V1 => v01::HostProfilePlaceContactAvatarsError, + V2 => v01::HostProfilePlaceContactAvatarsError, + V3 => 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 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 { + Self::V1(v01::HostProfilePlaceContactAvatarsRequest { + surface_width, + surface_height, + slots, + }) => v03::HostProfilePlaceContactAvatarsRequest { + surface_width, + surface_height, + own: None, + slots: slots.into_iter().map(upgrade_slot).collect(), + }, + 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, + } + } +} + +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 {} +} + +impl FromLatest for HostProfilePlaceContactAvatarsResponse { + fn from_latest((): Self::Latest, target: u8) -> Self { + 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) | Self::V3(error) => error, + } + } +} + +impl FromLatest for HostProfilePlaceContactAvatarsError { + fn from_latest(latest: Self::Latest, target: u8) -> Self { + 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, + }], + } + ); + } + } +} diff --git a/rust/crates/truapi/src/wasm.rs b/rust/crates/truapi/src/wasm.rs index 7ce1316df..aaa3b0410 100644 --- a/rust/crates/truapi/src/wasm.rs +++ b/rust/crates/truapi/src/wasm.rs @@ -19,7 +19,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use crate::platform::{ ChainProvider, ChatPlatform, ContactsPlatform, HopProvider, HostInfo, JsonRpcConnection, PairingHostConfig, PermissionStatusHost, PlatformInfo, PocketPlatform, ProductContext, - ProductExecutionKind, ProviderError, RuntimeConfigValidationError, + ProductExecutionKind, ProfilePlatform, ProviderError, RuntimeConfigValidationError, }; #[cfg(feature = "wasm-signing-host")] use crate::platform::{CoinageWalletHost, IdentityBackendHost, SigningHostConfig}; @@ -964,6 +964,7 @@ struct WasmPlatformAdapters { contacts_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")] @@ -976,6 +977,7 @@ fn wasm_platform(bridge: Arc) -> WasmPlatformAdapters { let has_contacts = bridge.has_contacts(); 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")] @@ -985,6 +987,7 @@ fn wasm_platform(bridge: Arc) -> WasmPlatformAdapters { let contacts = has_contacts.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); @@ -996,6 +999,7 @@ fn wasm_platform(bridge: Arc) -> WasmPlatformAdapters { contacts_platform: contacts, status_host: status, pocket_platform: pocket, + profile_platform: profile, #[cfg(feature = "wasm-signing-host")] identity_backend_host: identity_backend, #[cfg(feature = "wasm-signing-host")] @@ -1016,6 +1020,7 @@ fn connection_adapters_from_js( contacts_platform, status_host, pocket_platform, + profile_platform, .. } = wasm_platform(Arc::new(JsBridge::from_js(callbacks)?)); Ok(Some(crate::host_core::ConnectionAdapters { @@ -1026,6 +1031,7 @@ fn connection_adapters_from_js( // One-use grants belong to this execution, not the shared host. permission_grants: Arc::default(), pocket_platform, + profile_platform, chat: Arc::new(crate::runtime::ActionChannel::chat()), renderer: Arc::new(crate::runtime::ActionChannel::renderer()), })) @@ -1091,6 +1097,7 @@ impl WasmPairingHostRuntime { contacts_platform, status_host, pocket_platform, + profile_platform, .. } = wasm_platform(bridge); let spawner: Spawner = Arc::new(|fut| { @@ -1110,6 +1117,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), @@ -1416,6 +1426,7 @@ impl WasmSigningHostRuntime { contacts_platform, status_host, pocket_platform, + profile_platform, identity_backend_host, native_wallet, } = wasm_platform(bridge); @@ -1440,6 +1451,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), @@ -1817,6 +1831,7 @@ impl WasmProductRuntime { contacts_platform, status_host, pocket_platform, + profile_platform, .. } = wasm_platform(bridge); let spawner: Spawner = Arc::new(|fut| { @@ -1833,6 +1848,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); + } if let Some(contacts_platform) = contacts_platform { pairing.set_contacts_platform(contacts_platform); }