diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb73f0c4d..2db2aa193 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -320,7 +320,7 @@ jobs: run: npm test --prefix js/packages/truapi-host playground: - name: Playground (build + lint) + name: Playground (build + lint + unit) runs-on: ubuntu-latest needs: ts-client env: @@ -334,6 +334,10 @@ jobs: with: node-version: 22 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + - name: Download codegen output uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -364,6 +368,10 @@ jobs: working-directory: playground run: yarn lint + - name: Unit tests + working-directory: playground + run: yarn test:unit + explorer: name: Explorer (build + lint) runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 957d2aa31..fcbb82491 100644 --- a/Makefile +++ b/Makefile @@ -249,7 +249,7 @@ check: ## Full verification suite (build, fmt, clippy, test, TS tests, playgroun cargo test --workspace --all-features --all-targets cd $(TRUAPI_PKG) && npm run build && npm test cd $(HOST_WASM_PKG) && npm install --no-fund --no-audit && npm run build && npm test - cd $(PLAYGROUND) && yarn build && yarn lint + cd $(PLAYGROUND) && yarn build && yarn lint && yarn test:unit clean: ## Remove local build/test artifacts without deleting dependencies. cargo clean diff --git a/README.md b/README.md index 90385c1ae..ca39e43a2 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,9 @@ does not provision or pair a signer-bot user. To exercise the shared-core Chat path with the first-party TrUAPI Playground worker, build and serve the local product, install its worker into the -simulator app's product storage, and open its native Chat application: +simulator app's product storage, and open its native Chat application. The +worker drives all six Chat methods, so a host without bot registration reports +that row red: ```bash make ios-chat-run diff --git a/explorer/README.md b/explorer/README.md index 93f309d15..2c9604a67 100644 --- a/explorer/README.md +++ b/explorer/README.md @@ -16,7 +16,7 @@ Because the matrix is regenerated from `diagnosis-reports/` on every `dev` / `bu ### Data shape -[`src/data/compatibility-types.ts`](src/data/compatibility-types.ts) holds the schema. Each method row carries one `pass | fail | null` entry per host column; `null` means the method was absent from (or skipped in) that host's report. Methods with no measurement on any host are dropped from the matrix. Columns are labelled by host mode (`Web` / `Desktop` / `Android` / `iOS`); when two reports share a mode, the filename disambiguates the label. +[`src/data/compatibility-types.ts`](src/data/compatibility-types.ts) holds the schema. Each method row carries one `pass | fail | null` entry per host column; `null` means the method was absent from (or skipped in) that host's report. Methods with no measurement on any host carry no matrix row, and each compatibility section renders them as unreported gaps for its execution kind. Columns are labelled by host mode (`Web` / `Desktop` / `Android` / `iOS`); when two reports share a mode, the filename disambiguates the label. ### Standalone CLI diff --git a/explorer/diagnosis-reports/spa/signing-host-cli.md b/explorer/diagnosis-reports/spa/signing-host-cli.md index 2107813be..762934e74 100644 --- a/explorer/diagnosis-reports/spa/signing-host-cli.md +++ b/explorer/diagnosis-reports/spa/signing-host-cli.md @@ -27,12 +27,12 @@ | `Chain/get_spec_properties` | ✅ | | | `Chain/broadcast_transaction` | ✅ | | | `Chain/stop_transaction` | ✅ | | -| `Coin Payment/create_purse` | ❌ | createPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | -| `Coin Payment/query_purse` | ❌ | queryPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/create_purse` | ❌ | createPurse failed: { "error": { "tag": "Unsupported" } } | +| `Coin Payment/query_purse` | ❌ | queryPurse failed: { "error": { "tag": "Unsupported" } } | | `Coin Payment/rebalance_purse` | ❌ | Subscription interrupted | | `Coin Payment/delete_purse` | ❌ | Subscription interrupted | -| `Coin Payment/create_receivable` | ❌ | createReceivable failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | -| `Coin Payment/create_cheque` | ❌ | createCheque failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/create_receivable` | ❌ | createReceivable failed: { "error": { "tag": "Unsupported" } } | +| `Coin Payment/create_cheque` | ❌ | createCheque failed: { "error": { "tag": "Unsupported" } } | | `Coin Payment/deposit` | ❌ | Subscription interrupted | | `Coin Payment/refund` | ❌ | Subscription interrupted | | `Coin Payment/listen_for_payment` | ❌ | Subscription interrupted | diff --git a/explorer/src/data/types.ts b/explorer/src/data/types.ts index 0d2cebdee..8d57f9b7b 100644 --- a/explorer/src/data/types.ts +++ b/explorer/src/data/types.ts @@ -17,9 +17,14 @@ export interface MethodInfo { errorType?: string; } +/** Trusted executable kind required to reach a service. */ +export type ProductExecutionKind = "Spa" | "Chat"; + /** A grouping of related methods. */ export interface ServiceInfo { name: string; + /** Executable kind the host must attach, or unrestricted when absent. */ + requiredExecution?: ProductExecutionKind; methods: MethodInfo[]; } diff --git a/explorer/src/pages/CompatibilityPage.tsx b/explorer/src/pages/CompatibilityPage.tsx index b38970c75..bc3a65ac7 100644 --- a/explorer/src/pages/CompatibilityPage.tsx +++ b/explorer/src/pages/CompatibilityPage.tsx @@ -1,7 +1,7 @@ import { Fragment, useState } from "react"; import { Link, useOutletContext } from "react-router-dom"; import { Check, ChevronDown, Minus, X } from "lucide-react"; -import type { VersionEntry } from "../data/types"; +import type { ProductExecutionKind, VersionEntry } from "../data/types"; import { methodPath } from "../data/registry"; import { chatCompatibility, compatibility } from "../data/compatibility"; import type { @@ -88,6 +88,7 @@ export default function CompatibilityPage() { {version.services + .filter((service) => + execution === "Chat" + ? service.requiredExecution === "Chat" + : service.requiredExecution === undefined, + ) .map((service) => ({ name: service.name, - // Only methods the matrix actually measured. Methods absent from - // the matrix (e.g. skipped services) are dropped, and a service - // left with none is not rendered at all. - methods: service.methods.flatMap((m) => { + // Every generated method, measured or not. A method with no + // matrix row renders as not-reported across all hosts rather + // than vanishing, so an unexercised method reads as a gap + // instead of shrinking the denominator. + methods: service.methods.map((m) => { const id = `${service.name}/${m.name}`; const row = byId.get(id); - return row - ? [ - { - name: m.name, - id, - results: row.results, - details: row.details, - }, - ] - : []; + return { + name: m.name, + id, + results: row?.results, + details: row?.details, + }; }), })) - .filter((service) => service.methods.length > 0) .map((service, i) => ( ChatRoomRegistrationStatus + /// Register or resolve a native product Chat bot. The core has bounded and + /// normalized these arguments and screened the icon scheme; escaping them + /// for the surface that renders them is still the host's job. + func registerBot(botId: String, name: String, icon: String) throws + -> ChatBotRegistrationStatus + /// Persist a text message in native Chat storage. func postTextMessage(roomId: String, text: String) throws -> String @@ -484,6 +490,16 @@ private final class ChatCallbackAdapter: NativeChatCallbacks, @unchecked Sendabl } } + func registerBot( + botId: String, + name: String, + icon: String + ) throws -> ChatBotRegistrationStatus { + try withHostRejection { + try bridge.registerBot(botId: botId, name: name, icon: icon) + } + } + func postTextMessage(roomId: String, text: String) throws -> String { try withHostRejection { try bridge.postTextMessage(roomId: roomId, text: text) diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi.swift index 428cc41d3..f6a4c9c73 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi.swift @@ -4021,6 +4021,81 @@ public func FfiConverterTypeChatActionPayload_lower(_ value: ChatActionPayload) +/** + * Whether the bot was newly registered or already existed. + */ + +public enum ChatBotRegistrationStatus: Equatable, Hashable { + + /** + * The bot was registered. + */ + case new + /** + * A bot with this ID already existed. + */ + case exists + + + + + +} + +#if compiler(>=6) +extension ChatBotRegistrationStatus: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeChatBotRegistrationStatus: FfiConverterRustBuffer { + typealias SwiftType = ChatBotRegistrationStatus + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ChatBotRegistrationStatus { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .new + + case 2: return .exists + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: ChatBotRegistrationStatus, into buf: inout [UInt8]) { + switch value { + + + case .new: + writeInt(&buf, Int32(1)) + + + case .exists: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeChatBotRegistrationStatus_lift(_ buf: RustBuffer) throws -> ChatBotRegistrationStatus { + return try FfiConverterTypeChatBotRegistrationStatus.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeChatBotRegistrationStatus_lower(_ value: ChatBotRegistrationStatus) -> RustBuffer { + return FfiConverterTypeChatBotRegistrationStatus.lower(value) +} + + + /** * Content of a chat message -- one of several types. */ diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index adfd4324d..4c6252499 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -1942,8 +1942,8 @@ public func FfiConverterTypeHostCallbacks_lower(_ value: HostCallbacks) -> UInt6 * Native Chat storage and UI adapter. Hosts that support the Chat modality * pass an implementation to * [`NativeTrUApiHostRuntime::open_product_execution`]; hosts that do not - * simply pass `None`. Callbacks run inline on the dispatcher thread and must - * return promptly without blocking. + * simply pass `None`. Callbacks run inline on the process-wide dispatch pool + * shared by every product execution, so one that blocks stalls the others. */ public protocol NativeChatCallbacks: AnyObject, Sendable { @@ -1952,6 +1952,11 @@ public protocol NativeChatCallbacks: AnyObject, Sendable { */ func createRoom(roomId: String, name: String, icon: String) throws -> ChatRoomRegistrationStatus + /** + * Register or resolve a native product Chat bot. + */ + func registerBot(botId: String, name: String, icon: String) throws -> ChatBotRegistrationStatus + /** * Persist a text message in native Chat storage. */ @@ -1972,8 +1977,8 @@ public protocol NativeChatCallbacks: AnyObject, Sendable { * Native Chat storage and UI adapter. Hosts that support the Chat modality * pass an implementation to * [`NativeTrUApiHostRuntime::open_product_execution`]; hosts that do not - * simply pass `None`. Callbacks run inline on the dispatcher thread and must - * return promptly without blocking. + * simply pass `None`. Callbacks run inline on the process-wide dispatch pool + * shared by every product execution, so one that blocks stalls the others. */ open class NativeChatCallbacksImpl: NativeChatCallbacks, @unchecked Sendable { fileprivate let handle: UInt64 @@ -2041,6 +2046,21 @@ open func createRoom(roomId: String, name: String, icon: String)throws -> ChatR FfiConverterString.lower(icon),uniffiCallStatus ) }) +} + + /** + * Register or resolve a native product Chat bot. + */ +open func registerBot(botId: String, name: String, icon: String)throws -> ChatBotRegistrationStatus { + return try FfiConverterTypeChatBotRegistrationStatus_lift(try rustCallWithError(FfiConverterTypeHostRejection_lift) { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativechatcallbacks_register_bot( + self.uniffiCloneHandle(), + FfiConverterString.lower(botId), + FfiConverterString.lower(name), + FfiConverterString.lower(icon),uniffiCallStatus + ) +}) } /** @@ -2141,6 +2161,35 @@ fileprivate struct UniffiCallbackInterfaceNativeChatCallbacks { lowerError: FfiConverterTypeHostRejection_lower ) }, + registerBot: { ( + uniffiHandle: UInt64, + botId: RustBuffer, + name: RustBuffer, + icon: RustBuffer, + uniffiOutReturn: UnsafeMutablePointer, + uniffiCallStatus: UnsafeMutablePointer + ) in + let makeCall = { + () throws -> ChatBotRegistrationStatus in + guard let uniffiObj = try? FfiConverterTypeNativeChatCallbacks.handleMap.get(handle: uniffiHandle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return try uniffiObj.registerBot( + botId: try FfiConverterString.lift(botId), + name: try FfiConverterString.lift(name), + icon: try FfiConverterString.lift(icon) + ) + } + + + let writeReturn = { uniffiOutReturn.pointee = FfiConverterTypeChatBotRegistrationStatus_lower($0) } + uniffiTraitInterfaceCallWithError( + callStatus: uniffiCallStatus, + makeCall: makeCall, + writeReturn: writeReturn, + lowerError: FfiConverterTypeHostRejection_lower + ) + }, postTextMessage: { ( uniffiHandle: UInt64, roomId: RustBuffer, @@ -6270,13 +6319,16 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_nativechatcallbacks_create_room() != 15676) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_nativechatcallbacks_post_text_message() != 10747) { + if (uniffi_truapi_server_checksum_method_nativechatcallbacks_register_bot() != 59357) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_truapi_server_checksum_method_nativechatcallbacks_post_text_message() != 49314) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_nativechatcallbacks_post_custom_message() != 33405) { + if (uniffi_truapi_server_checksum_method_nativechatcallbacks_post_custom_message() != 28844) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_nativechatcallbacks_list_rooms() != 21374) { + if (uniffi_truapi_server_checksum_method_nativechatcallbacks_list_rooms() != 37616) { return InitializationResult.apiChecksumMismatch } if (uniffi_truapi_server_checksum_method_nativeproductexecution_device_encryption_key() != 18707) { diff --git a/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h b/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h index ae36d3eaa..b6b7aa8d8 100644 --- a/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h +++ b/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h @@ -406,21 +406,28 @@ typedef void (*UniffiCallbackInterfaceNativeChatCallbacksMethod0)(uint64_t, Rust #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_NATIVE_CHAT_CALLBACKS_METHOD1 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_NATIVE_CHAT_CALLBACKS_METHOD1 -typedef void (*UniffiCallbackInterfaceNativeChatCallbacksMethod1)(uint64_t, RustBuffer, RustBuffer, RustBuffer* _Nonnull, +typedef void (*UniffiCallbackInterfaceNativeChatCallbacksMethod1)(uint64_t, RustBuffer, RustBuffer, RustBuffer, RustBuffer* _Nonnull, RustCallStatus *_Nonnull uniffiCallStatus ); #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_NATIVE_CHAT_CALLBACKS_METHOD2 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_NATIVE_CHAT_CALLBACKS_METHOD2 -typedef void (*UniffiCallbackInterfaceNativeChatCallbacksMethod2)(uint64_t, RustBuffer, RustBuffer, RustBuffer, RustBuffer* _Nonnull, +typedef void (*UniffiCallbackInterfaceNativeChatCallbacksMethod2)(uint64_t, RustBuffer, RustBuffer, RustBuffer* _Nonnull, RustCallStatus *_Nonnull uniffiCallStatus ); #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_NATIVE_CHAT_CALLBACKS_METHOD3 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_NATIVE_CHAT_CALLBACKS_METHOD3 -typedef void (*UniffiCallbackInterfaceNativeChatCallbacksMethod3)(uint64_t, RustBuffer* _Nonnull, +typedef void (*UniffiCallbackInterfaceNativeChatCallbacksMethod3)(uint64_t, RustBuffer, RustBuffer, RustBuffer, RustBuffer* _Nonnull, + RustCallStatus *_Nonnull uniffiCallStatus + ); + +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_NATIVE_CHAT_CALLBACKS_METHOD4 +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_NATIVE_CHAT_CALLBACKS_METHOD4 +typedef void (*UniffiCallbackInterfaceNativeChatCallbacksMethod4)(uint64_t, RustBuffer* _Nonnull, RustCallStatus *_Nonnull uniffiCallStatus ); @@ -470,9 +477,10 @@ typedef struct UniffiVTableCallbackInterfaceNativeChatCallbacks { UniffiCallbackInterfaceFree _Nonnull uniffiFree; UniffiCallbackInterfaceClone _Nonnull uniffiClone; UniffiCallbackInterfaceNativeChatCallbacksMethod0 _Nonnull createRoom; - UniffiCallbackInterfaceNativeChatCallbacksMethod1 _Nonnull postTextMessage; - UniffiCallbackInterfaceNativeChatCallbacksMethod2 _Nonnull postCustomMessage; - UniffiCallbackInterfaceNativeChatCallbacksMethod3 _Nonnull listRooms; + UniffiCallbackInterfaceNativeChatCallbacksMethod1 _Nonnull registerBot; + UniffiCallbackInterfaceNativeChatCallbacksMethod2 _Nonnull postTextMessage; + UniffiCallbackInterfaceNativeChatCallbacksMethod3 _Nonnull postCustomMessage; + UniffiCallbackInterfaceNativeChatCallbacksMethod4 _Nonnull listRooms; } UniffiVTableCallbackInterfaceNativeChatCallbacks; #endif @@ -616,6 +624,11 @@ void uniffi_truapi_server_fn_init_callback_vtable_nativechatcallbacks(const Unif RustBuffer uniffi_truapi_server_fn_method_nativechatcallbacks_create_room(uint64_t ptr, RustBuffer room_id, RustBuffer name, RustBuffer icon, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVECHATCALLBACKS_REGISTER_BOT +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVECHATCALLBACKS_REGISTER_BOT +RustBuffer uniffi_truapi_server_fn_method_nativechatcallbacks_register_bot(uint64_t ptr, RustBuffer bot_id, RustBuffer name, RustBuffer icon, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVECHATCALLBACKS_POST_TEXT_MESSAGE #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVECHATCALLBACKS_POST_TEXT_MESSAGE RustBuffer uniffi_truapi_server_fn_method_nativechatcallbacks_post_text_message(uint64_t ptr, RustBuffer room_id, RustBuffer text, RustCallStatus *_Nonnull out_status @@ -1313,6 +1326,12 @@ uint16_t uniffi_truapi_server_checksum_method_hostcallbacks_local_storage_clear( #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVECHATCALLBACKS_CREATE_ROOM uint16_t uniffi_truapi_server_checksum_method_nativechatcallbacks_create_room(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVECHATCALLBACKS_REGISTER_BOT +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVECHATCALLBACKS_REGISTER_BOT +uint16_t uniffi_truapi_server_checksum_method_nativechatcallbacks_register_bot(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVECHATCALLBACKS_POST_TEXT_MESSAGE diff --git a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift index e0e438d9b..c0d80590f 100644 --- a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift +++ b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift @@ -83,3 +83,29 @@ final class StubHostBridge: HostBridge { func remotePermission(request _: RemotePermission) async throws -> Bool { false } func featureSupported(request _: HostFeatureSupportedRequest) async throws -> Bool { true } } + +// Conforms to `ChatHostBridge` so a new requirement there fails this job. +// Every member is written out: the protocol supplies no defaults. +final class StubChatHostBridge: ChatHostBridge { + func createRoom( + roomId _: String, + name _: String, + icon _: String + ) throws -> ChatRoomRegistrationStatus { .new } + + func registerBot( + botId _: String, + name _: String, + icon _: String + ) throws -> ChatBotRegistrationStatus { .new } + + func postTextMessage(roomId _: String, text _: String) throws -> String { "message-id" } + + func postCustomMessage( + roomId _: String, + messageType _: String, + payload _: Data + ) throws -> String { "message-id" } + + func listRooms() throws -> [ChatRoom] { [] } +} diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index fc7773bf6..b3c600828 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -4,6 +4,13 @@ WASM-backed TrUAPI host runtime. It embeds the `truapi-server` Rust core (compil behind a Web Worker provider, plus per-environment integration entry points. It is the counterpart to the native Android/iOS host shells. +`executionKind: "Chat"` is accepted by the runtime config but not served: the +Chat modality reaches products through a host-supplied `ChatPlatform` adapter, +which only the native (UniFFI) entrypoints can install. A JS host that opens a +`Chat` execution gets a provider whose Chat requests answer unsupported; its +subscriptions end empty, which is indistinguishable from a healthy close. +Tracked in paritytech/host-rust-core#383. + ## Entry points The package exposes tree-shakeable subpath exports — import only what your environment needs: diff --git a/playground/README.md b/playground/README.md index 6ed744977..c126d5d8c 100644 --- a/playground/README.md +++ b/playground/README.md @@ -15,8 +15,9 @@ The playground is an interactive reference for the SPA-compatible TrUAPI surface - **Diagnosis view**: runs the SPA surface and produces a copy-pasteable markdown report per host. The explorer's Compatibility page aggregates those into a cross-host matrix. See [Diagnosis](#diagnosis). - **Wiring status**: methods that are not yet bound are flagged "Not supported" so you can see protocol coverage at a glance. - **Chat diagnosis**: the same build emits `out/worker/index.js`, a native Chat - application that tests room creation and idempotency, live room-list updates, - text and custom messages, user actions, and host-initiated custom-render streams. It + application that tests room creation and idempotency, bot registration and + idempotency, live room-list updates, text and custom messages, user actions, + and host-initiated custom-render streams. It displays live results in Chat and posts a Chat-only Markdown report after `!diagnose` completes the action check. diff --git a/playground/tests/unit/chat-diagnosis.test.ts b/playground/tests/unit/chat-diagnosis.test.ts index b8ec080f7..99cb11059 100644 --- a/playground/tests/unit/chat-diagnosis.test.ts +++ b/playground/tests/unit/chat-diagnosis.test.ts @@ -1,8 +1,22 @@ import { describe, expect, test } from "bun:test"; +import { services as generatedServices } from "@parity/truapi/playground/services"; +import { servicesForExecution } from "@parity/truapi/playground/services-types"; import { CHAT_DIAGNOSIS_METHODS, ChatDiagnosis } from "../../worker/diagnosis"; describe("ChatDiagnosis", () => { - test("keeps Chat methods ordered and renders a Chat-only report", () => { + // Expectation comes from codegen, so a missing method fails here. + test("covers every generated Chat method", () => { + const generated = servicesForExecution(generatedServices, "Chat") + .filter((service) => service.requiredExecution === "Chat") + .flatMap((service) => + service.methods.map((method) => `${service.name}/${method.name}`), + ); + + expect(generated.length).toBeGreaterThan(0); + expect([...CHAT_DIAGNOSIS_METHODS].sort()).toEqual(generated.sort()); + }); + + test("renders a Chat-only report over every tracked method", () => { const diagnosis = new ChatDiagnosis(); for (const id of CHAT_DIAGNOSIS_METHODS) { diagnosis.pass(id, "worked"); @@ -10,7 +24,9 @@ describe("ChatDiagnosis", () => { expect(diagnosis.isComplete()).toBe(true); expect(diagnosis.markdown()).toContain("## Truapi Chat Diagnosis"); - expect(diagnosis.markdown()).toContain("**5 success · 0 failed**"); + expect(diagnosis.markdown()).toContain( + `**${CHAT_DIAGNOSIS_METHODS.length} success · 0 failed**`, + ); expect(diagnosis.markdown()).not.toContain("Storage/"); }); diff --git a/playground/worker/diagnosis.ts b/playground/worker/diagnosis.ts index 8424481ce..89d8732ae 100644 --- a/playground/worker/diagnosis.ts +++ b/playground/worker/diagnosis.ts @@ -4,8 +4,10 @@ import { type DiagnosisResult, } from "../shared/diagnosis"; +/** Pinned against the generated service metadata by `chat-diagnosis.test.ts`. */ export const CHAT_DIAGNOSIS_METHODS = [ "Chat/create_room", + "Chat/register_bot", "Chat/list_subscribe", "Chat/post_message", "Chat/action_subscribe", diff --git a/playground/worker/index.ts b/playground/worker/index.ts index 41c0a3aca..72179d5b2 100644 --- a/playground/worker/index.ts +++ b/playground/worker/index.ts @@ -21,6 +21,7 @@ const ROOM_NAME = "TrUAPI Playground"; const DIAGNOSIS_COMMAND = "!diagnose"; const ECHO_COMMAND = "!echo"; const RENDER_MESSAGE_TYPE = "truapi-chat-diagnosis"; +const BOT_ID = "truapi-diagnosis-bot"; const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const diagnosticRoomId = `${ROOM_ID}-diagnosis-${runId}`; const renderPayload = bytesToHex( @@ -130,6 +131,40 @@ async function runStartupDiagnosis(): Promise { throw new Error("postMessage did not return distinct message identifiers"); } diagnosis.pass("Chat/post_message", "posted text and custom messages"); + + await runBotRegistrationProbe(); +} + +/** + * Isolated so a host without bot support reports only this row red. The id is + * stable: there is no unregister call, so a per-run id leaks a bot per boot. + */ +async function runBotRegistrationProbe(): Promise { + const bot = { botId: BOT_ID, name: "TrUAPI Diagnosis Bot", icon: "" }; + try { + const first = await chat.registerBot(bot); + if (first.isErr()) { + throw new Error(`registerBot failed: ${JSON.stringify(first.error)}`); + } + + const second = await chat.registerBot(bot); + if (second.isErr()) { + throw new Error( + `second registerBot failed: ${JSON.stringify(second.error)}`, + ); + } + if (second.value.status !== "Exists") { + throw new Error( + `second registerBot returned ${second.value.status}, expected Exists`, + ); + } + diagnosis.pass( + "Chat/register_bot", + `first registration ${first.value.status}, repeat returned Exists`, + ); + } catch (error) { + diagnosis.fail("Chat/register_bot", error); + } } async function ensureRoom(roomId: string, name: string): Promise { diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 43f98d78d..970bcd024 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -31,6 +31,8 @@ import type { HostChatListSubscribeItem, HostChatPostMessageRequest, HostChatPostMessageResponse, + HostChatRegisterBotRequest, + HostChatRegisterBotResponse, HostDevicePermissionResponse, HostFeatureSupportedRequest, HostFeatureSupportedResponse, @@ -826,7 +828,15 @@ export interface ChainProvider { /** * Host-implemented adapter through which product Chat calls reach native - * storage and UI. + * storage and UI. Installed separately from `Platform`, and only by the + * native entrypoints: a WASM/JS host cannot supply one, so requests from a + * `Chat` execution created there answer unsupported and its subscriptions end + * empty, which a product cannot tell from a healthy close. + * + * On `create_room` and `register_bot` the core bounds ids, names and icons, + * NFC-normalizes them, screens control and bidi characters, and restricts an + * icon to `https` or an inline raster image. Contextual output escaping, + * storage limits, and every `post_message` field remain host-owned. */ export interface ChatPlatform { /** @@ -838,7 +848,17 @@ export interface ChatPlatform { ): Promise; /** - * Persist a product-authored message in a native chat room. + * Register or resolve a product-scoped native chat bot. Host-owned in the + * same way rooms are. + */ + registerBot( + product: ProductContext, + request: HostChatRegisterBotRequest, + ): Promise; + + /** + * Persist a product-authored message in a native chat room. A host that + * cannot store a given content variant reports a domain error for it. */ postMessage( product: ProductContext, diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index 8d3e82af5..e1dedc572 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -1180,7 +1180,7 @@ surface. | System | Handshake, feature query, and no-op navigation. | | Theme | One `Dark` subscription value. | | Chat | Typed unavailable/empty-subscription behavior. | -| Coin Payment | Typed unavailable/interrupted-subscription behavior. | +| Coin Payment | Typed unsupported/interrupted-subscription behavior. | | Payment | Typed unsupported/interrupted-subscription behavior. | ### 15.1 Exact reported methods @@ -1236,10 +1236,11 @@ reports: Deliberately unavailable methods: -- all five product-initiated Chat methods; the host-initiated custom-render - subscription is also unused because the CLI has no native Chat UI; -- all nine generated Coin Payment methods; and -- all four generated Payment methods. +- all six product-initiated Chat methods, because the CLI installs no + `ChatPlatform`; the host-initiated custom-render subscription is also unused + because the CLI has no native Chat UI; +- all nine Coin Payment methods, which answer `CallError::Unsupported`; and +- all four Payment methods, which answer typed `Unknown` domain errors. A successful `System/feature_supported` call resolves the queried chain against the host's chain set, the same set `Chain/get_chain_info` answers from, so it @@ -1572,11 +1573,17 @@ The implementation is covered by: The reports currently have identical method results apart from their title: -- 45 implemented-success methods; -- 6 unavailable Chat surface entries (five product-initiated methods plus the - host-initiated custom-render subscription); -- 9 unavailable Coin Payment methods; and -- 4 unavailable Payment methods. +- 65 rows: 52 succeeding methods and 13 failing ones; +- 9 Coin Payment methods, which answer `CallError::Unsupported`; and +- 4 Payment methods, which answer a typed `Unknown` domain error. + +The Chat surface does not appear: it requires a `Chat` execution, and these are +SPA reports. Two caveats on the checked-in reports: the enumeration in 15.1 +lists 45 methods and predates later additions to the surface, and only the +signing-host report carries measured `Unsupported` Coin Payment details — the +pairing-host report still records the older `HostFailure` shape, because the +pairing phase needs a personhood ring member before it runs any method. It +refreshes on the next `make e2e-pairing-cli` run from such a signer. Recommended local verification after CLI changes: diff --git a/rust/crates/truapi-platform/README.md b/rust/crates/truapi-platform/README.md index 3c348aeb7..03a1404e0 100644 --- a/rust/crates/truapi-platform/README.md +++ b/rust/crates/truapi-platform/README.md @@ -33,8 +33,8 @@ constructor, so a context off the wire carries a normalized product id. preimage actions before the core asks the paired wallet. - `ThemeHost`: stream the host theme into the runtime. - `PreimageHost`: submit and look up preimages through the host-selected backend. -- `ChatPlatform`: create product-scoped native chat rooms, post messages into - them, and stream the product's room list. +- `ChatPlatform`: create product-scoped native chat rooms, register product + chat bots, post messages into rooms, and stream the product's room list. `Platform` is a blanket-implemented supertrait that combines the capability traits above except `ChatPlatform`, which a host supplies separately and only diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index a65a8ddee..4e533599b 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -31,6 +31,7 @@ use truapi::latest::{ AllocatableResource, ChainIdentifier, GenericError, HostChatCreateRoomError, HostChatCreateRoomRequest, HostChatCreateRoomResponse, HostChatListSubscribeItem, HostChatPostMessageError, HostChatPostMessageRequest, HostChatPostMessageResponse, + HostChatRegisterBotError, HostChatRegisterBotRequest, HostChatRegisterBotResponse, HostDevicePermissionRequest, HostDevicePermissionResponse, HostFeatureSupportedRequest, HostFeatureSupportedResponse, HostLocalStorageReadError, HostNavigateToError, HostPushNotificationRequest, HostPushNotificationResponse, HostSignPayloadRequest, @@ -276,6 +277,189 @@ pub fn normalize_product_identifier( } } +/// Largest accepted length for a product-supplied chat identifier or display +/// name, in bytes. +pub const CHAT_FIELD_MAX_BYTES: usize = 256; + +/// Largest accepted length for a product-supplied chat icon, in bytes. Wide +/// enough for a `data:` thumbnail, far below the transport frame cap. +pub const CHAT_ICON_MAX_BYTES: usize = 64 * 1024; + +/// Inline image media types a chat icon may carry. SVG is excluded: it can +/// carry script. +const ALLOWED_ICON_DATA_TYPES: [&str; 5] = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/avif", +]; + +/// Normalize a product-supplied chat room or bot identifier. +/// +/// Screened harder than a display name, mirroring +/// [`normalize_product_identifier`]: an identifier is matched, not read, so it +/// also rejects the invisible characters a name legitimately needs — joiners, +/// variation selectors, soft hyphens and non-ASCII spaces — which would +/// otherwise let two distinct ids render identically. +pub fn normalize_chat_identifier(field: &'static str, id: &str) -> Result { + let normalized = normalize_chat_text(field, id)?; + if normalized.is_empty() { + return Err(ChatFieldError::Empty { field }); + } + if normalized.chars().any(is_identifier_unsafe) { + return Err(ChatFieldError::UnsafeCharacter { field }); + } + Ok(normalized) +} + +/// Validate a product-supplied chat display name. +pub fn validate_chat_name(field: &'static str, name: &str) -> Result { + normalize_chat_text(field, name) +} + +/// Trim, NFC-normalize and screen one product-supplied chat string. +/// +/// The byte budget applies to the normalized value, which is what a host +/// receives: NFC can expand the input. +fn normalize_chat_text(field: &'static str, value: &str) -> Result { + let normalized = value.trim().nfc().collect::(); + if normalized.len() > CHAT_FIELD_MAX_BYTES { + return Err(ChatFieldError::TooLong { + field, + limit: CHAT_FIELD_MAX_BYTES, + }); + } + if normalized.chars().any(is_display_unsafe) { + return Err(ChatFieldError::UnsafeCharacter { field }); + } + Ok(normalized) +} + +/// Validate a product-supplied chat icon: absent, an `https` URL, or an inline +/// image in [`ALLOWED_ICON_DATA_TYPES`]. +/// +/// An allowlist rather than a denylist, because a URL parser reaches a scheme +/// through whitespace, tabs and NUL that a prefix comparison does not. +pub fn validate_chat_icon(field: &'static str, icon: &str) -> Result { + let trimmed = icon.trim(); + if trimmed.is_empty() { + return Ok(String::new()); + } + if trimmed.len() > CHAT_ICON_MAX_BYTES { + return Err(ChatFieldError::TooLong { + field, + limit: CHAT_ICON_MAX_BYTES, + }); + } + + match icon_scheme(trimmed).as_deref() { + Some("https") => Url::parse(trimmed) + .ok() + .filter(|parsed| parsed.scheme() == "https") + .map(|_| trimmed.to_string()) + .ok_or(ChatFieldError::RejectedScheme { field }), + Some("data") if is_allowed_icon_data_url(trimmed) => Ok(trimmed.to_string()), + _ => Err(ChatFieldError::RejectedScheme { field }), + } +} + +/// Scheme a URL parser would resolve, with the characters parsers ignore +/// removed so `java\tscript:` and a leading NUL cannot hide one. +fn icon_scheme(candidate: &str) -> Option { + let stripped: String = candidate + .chars() + .filter(|character| !character.is_whitespace() && *character != '\u{0}') + .collect(); + let (scheme, _) = stripped.split_once(':')?; + if scheme.is_empty() + || !scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') + { + return None; + } + Some(scheme.to_ascii_lowercase()) +} + +/// Whether an inline image declares an allowed media type. The media type is +/// read the way a data-URL processor reads it: whitespace-insensitive. +fn is_allowed_icon_data_url(candidate: &str) -> bool { + let Some(rest) = candidate + .char_indices() + .find(|(_, c)| *c == ':') + .map(|(index, _)| &candidate[index + 1..]) + else { + return false; + }; + let media_type: String = rest + .split(&[',', ';'][..]) + .next() + .unwrap_or_default() + .chars() + .filter(|character| !character.is_whitespace()) + .collect::() + .to_ascii_lowercase(); + ALLOWED_ICON_DATA_TYPES.contains(&media_type.as_str()) +} + +/// Invisible characters an identifier must not carry. A display name keeps +/// these: ZWJ builds emoji sequences and ZWNJ is required by Persian. +fn is_identifier_unsafe(character: char) -> bool { + matches!( + character, + '\u{00ad}' // soft hyphen + | '\u{200c}' | '\u{200d}' // ZWNJ, ZWJ + | '\u{2060}'..='\u{2064}' // word joiner, invisible operators + | '\u{fe00}'..='\u{fe0f}' // variation selectors + ) || (character.is_whitespace() && character != ' ') +} + +/// Control characters and bidi overrides let two distinct values render alike. +fn is_display_unsafe(character: char) -> bool { + character.is_control() + || matches!( + character, + '\u{200b}' + | '\u{061c}' + | '\u{202a}'..='\u{202e}' + | '\u{2066}'..='\u{2069}' + | '\u{feff}' + | '\u{e0000}'..='\u{e007f}' + ) +} + +/// Rejection of a product-supplied chat field. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] +pub enum ChatFieldError { + /// The field is required and arrived blank. + #[display("{field} must not be empty")] + Empty { + /// Offending field name. + field: &'static str, + }, + /// The field exceeded its byte budget. + #[display("{field} must be at most {limit} bytes")] + TooLong { + /// Offending field name. + field: &'static str, + /// Accepted maximum. + limit: usize, + }, + /// The field carried characters that make values indistinguishable. + #[display("{field} must not contain control or bidirectional characters")] + UnsafeCharacter { + /// Offending field name. + field: &'static str, + }, + /// The icon carried a scheme a host must not render. + #[display("{field} carries a scheme that cannot be rendered")] + RejectedScheme { + /// Offending field name. + field: &'static str, + }, +} + fn require_non_empty(field: &'static str, value: &str) -> Result<(), RuntimeConfigValidationError> { if value.trim().is_empty() { return Err(RuntimeConfigValidationError::EmptyField { field }); @@ -1317,7 +1501,15 @@ pub trait PreimageHost: Send + Sync { } /// Host-implemented adapter through which product Chat calls reach native -/// storage and UI. +/// storage and UI. Installed separately from [`Platform`], and only by the +/// native entrypoints: a WASM/JS host cannot supply one, so requests from a +/// `Chat` execution created there answer unsupported and its subscriptions end +/// empty, which a product cannot tell from a healthy close. +/// +/// On `create_room` and `register_bot` the core bounds ids, names and icons, +/// NFC-normalizes them, screens control and bidi characters, and restricts an +/// icon to `https` or an inline raster image. Contextual output escaping, +/// storage limits, and every `post_message` field remain host-owned. #[async_trait] pub trait ChatPlatform: Send + Sync { /// Create or resolve a product-scoped native chat room. @@ -1327,7 +1519,16 @@ pub trait ChatPlatform: Send + Sync { request: HostChatCreateRoomRequest, ) -> Result; - /// Persist a product-authored message in a native chat room. + /// Register or resolve a product-scoped native chat bot. Host-owned in the + /// same way rooms are. + async fn register_bot( + &self, + product: &ProductContext, + request: HostChatRegisterBotRequest, + ) -> Result; + + /// Persist a product-authored message in a native chat room. A host that + /// cannot store a given content variant reports a domain error for it. async fn post_message( &self, product: &ProductContext, diff --git a/rust/crates/truapi-platform/tests/bounds.rs b/rust/crates/truapi-platform/tests/bounds.rs index 948c2b721..9e9bd48a7 100644 --- a/rust/crates/truapi-platform/tests/bounds.rs +++ b/rust/crates/truapi-platform/tests/bounds.rs @@ -154,3 +154,142 @@ fn product_storage_key_round_trips_scopes_and_arbitrary_keys() { assert_eq!(decoded, key); assert!(ProductStorageKey::decode("unknown:key").is_err()); } + +#[test] +fn chat_icons_accept_only_https_and_inline_images() { + for hostile in [ + "javascript:alert(1)", + "\u{0}javascript:alert(1)", + "java\u{9}script:alert(1)", + "JavaScript:alert(1)", + "vbscript:msgbox(1)", + "file:///etc/passwd", + "fi\u{9}le:///etc/passwd", + "data:text/html,", + "data: text/html,", + "data:\ttext/html;base64,AAAA", + "data: TEXT/HTML;base64,AAAA", + "data:image/svg+xml,", + "blob:https://evil.example/x", + "about:blank", + "intent://evil#Intent;scheme=http;end", + "content://com.evil/x", + "//evil.example/x.png", + "../../../etc/passwd", + "http://tracker.example/pixel.png", + "ftp://example.invalid/x.png", + ] { + assert!( + truapi_platform::validate_chat_icon("icon", hostile).is_err(), + "{hostile:?} must be rejected" + ); + } + + for allowed in [ + "", + " ", + "https://example.invalid/icon.png", + "data:image/png;base64,iVBORw0KGgo=", + "data:image/jpeg;base64,/9j/4AAQ", + "data:image/gif;base64,R0lGODlh", + "data:image/webp;base64,UklGRg==", + "data:image/avif;base64,AAAAGGZ0", + ] { + assert!( + truapi_platform::validate_chat_icon("icon", allowed).is_ok(), + "{allowed:?} must be accepted" + ); + } +} + +#[test] +fn chat_names_keep_joiners_and_bidi_marks_but_drop_spoofing_controls() { + for legitimate in [ + "👩‍💻 Devs", + "👨‍👩‍👧 Family", + "🏳️‍🌈 Pride", + "می‌روم", + "\u{200e}שלום", + "🎲 Dice", + "", + ] { + assert!( + truapi_platform::validate_chat_name("name", legitimate).is_ok(), + "{legitimate:?} must be accepted" + ); + } + + for spoofing in [ + "a\u{202e}b", + "a\u{2066}b", + "a\u{200b}b", + "a\u{061c}b", + "a\u{e0041}b", + "a\u{feff}b", + "a\u{0}b", + ] { + assert!( + truapi_platform::validate_chat_name("name", spoofing).is_err(), + "{spoofing:?} must be rejected" + ); + } +} + +#[test] +fn chat_identifiers_normalize_and_bound_the_value_the_host_receives() { + let nfc = truapi_platform::normalize_chat_identifier("botId", "cafe\u{301}").unwrap(); + assert_eq!( + nfc, + truapi_platform::normalize_chat_identifier("botId", "caf\u{e9}").unwrap() + ); + + assert!(truapi_platform::normalize_chat_identifier("botId", "").is_err()); + assert!(truapi_platform::normalize_chat_identifier("botId", " ").is_err()); + + // The cap applies after NFC, which can expand the input. + let expanding = "\u{1d160}".repeat(64); + assert!(expanding.len() <= truapi_platform::CHAT_FIELD_MAX_BYTES); + let rejected = truapi_platform::normalize_chat_identifier("botId", &expanding); + assert!( + rejected.is_err(), + "a value that expands past the cap under NFC must be rejected" + ); + + let oversized = "f".repeat(truapi_platform::CHAT_FIELD_MAX_BYTES + 1); + assert!(truapi_platform::normalize_chat_identifier("botId", &oversized).is_err()); + + let icon = "d".repeat(truapi_platform::CHAT_ICON_MAX_BYTES + 1); + assert!(truapi_platform::validate_chat_icon("icon", &icon).is_err()); +} + +#[test] +fn chat_identifiers_reject_invisibles_that_names_keep() { + // Every one of these renders identically to its plain spelling. + for invisible in [ + "flip\u{200d}per", + "flip\u{200c}per", + "flip\u{fe0e}per", + "flip\u{00ad}per", + "flip\u{2060}per", + "flip\u{205f}per", + "flip\u{00a0}per", + ] { + assert!( + truapi_platform::normalize_chat_identifier("botId", invisible).is_err(), + "{invisible:?} must not be a valid identifier" + ); + // A display name still accepts them: emoji and Persian need joiners. + assert!( + truapi_platform::validate_chat_name("name", invisible).is_ok(), + "{invisible:?} must remain a valid display name" + ); + } + + // Ordinary identifiers, including an interior ASCII space, still pass. + for ordinary in ["flipper", "flip-per", "flip per", "café", "支付"] { + assert!( + truapi_platform::normalize_chat_identifier("botId", ordinary).is_ok(), + "{ordinary:?} must remain a valid identifier" + ); + } +} diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index b14716cca..01379791b 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -1073,6 +1073,46 @@ mod tests { assert_eq!(response.payload.value, expected); } + #[test] + fn generated_filter_denies_chat_register_bot_on_spa_connection() { + let sink = Arc::new(RecordingSink::default()); + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + sink.clone(), + ); + let ids = crate::frame::request_ids("chat_register_bot").expect("known Chat request"); + let request = truapi::versioned::chat::HostChatRegisterBotRequest::V1( + v01::HostChatRegisterBotRequest { + bot_id: "bot".into(), + name: "Bot".into(), + icon: String::new(), + }, + ); + let frame = ProtocolMessage { + request_id: "chat:bot".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }; + + futures::executor::block_on(runtime.receive_frame(frame.encode())).unwrap(); + + let frames = sink.frames.lock().unwrap(); + assert_eq!(frames.len(), 1); + let response = ProtocolMessage::decode(&mut frames[0].as_slice()).unwrap(); + assert_eq!(response.payload.id, ids.response_id); + let expected = crate::frame::encode_versioned_err_payload( + truapi::CallError::::Denied, + 1, + ); + assert_eq!(response.payload.value, expected); + } + #[test] fn generated_filter_denies_chat_subscription_on_spa_connection() { let sink = Arc::new(RecordingSink::default()); diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 81847bf3d..0ddf59867 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -489,8 +489,8 @@ pub trait HostCallbacks: Send + Sync { /// Native Chat storage and UI adapter. Hosts that support the Chat modality /// pass an implementation to /// [`NativeTrUApiHostRuntime::open_product_execution`]; hosts that do not -/// simply pass `None`. Callbacks run inline on the dispatcher thread and must -/// return promptly without blocking. +/// simply pass `None`. Callbacks run inline on the process-wide dispatch pool +/// shared by every product execution, so one that blocks stalls the others. #[uniffi::export(rust, foreign)] pub trait NativeChatCallbacks: Send + Sync { /// Create or resolve a native product Chat room. @@ -501,6 +501,14 @@ pub trait NativeChatCallbacks: Send + Sync { icon: String, ) -> Result; + /// Register or resolve a native product Chat bot. + fn register_bot( + &self, + bot_id: String, + name: String, + icon: String, + ) -> Result; + /// Persist a text message in native Chat storage. fn post_text_message(&self, room_id: String, text: String) -> Result; @@ -1767,6 +1775,23 @@ impl truapi_platform::ChatPlatform for ChatCallbackPlatform { Ok(v01::HostChatCreateRoomResponse { status }) } + async fn register_bot( + &self, + _product: &ProductContext, + request: v01::HostChatRegisterBotRequest, + ) -> Result { + let status = self + .chat + .register_bot(request.bot_id, request.name, request.icon) + .map_err(|error| v01::HostChatRegisterBotError::Unknown { + reason: error.to_string(), + })?; + + // No room-list republish: a bot identity is not a room. A host that + // joins the bot to one signals that via `notify_chat_rooms_changed`. + Ok(v01::HostChatRegisterBotResponse { status }) + } + async fn post_message( &self, _product: &ProductContext, @@ -1907,7 +1932,10 @@ mod tests { struct EventCallbacks { chat_room_status: Mutex, - chat_created_rooms: Mutex>, + chat_created_rooms: Mutex>, + chat_bot_status: Mutex, + chat_registered_bots: Mutex>, + chat_bot_rejection: Mutex>, chat_posted_text: Mutex>, theme: Mutex, preimages: Mutex, @@ -1923,6 +1951,9 @@ mod tests { Self { chat_room_status: Mutex::new(v01::ChatRoomRegistrationStatus::New), chat_created_rooms: Mutex::new(Vec::new()), + chat_bot_status: Mutex::new(v01::ChatBotRegistrationStatus::New), + chat_registered_bots: Mutex::new(Vec::new()), + chat_bot_rejection: Mutex::new(None), chat_posted_text: Mutex::new(Vec::new()), theme: Mutex::new(v01::HostThemeSubscribeItem { name: v01::ThemeName::Default, @@ -2050,19 +2081,43 @@ mod tests { fn create_room( &self, room_id: String, - _name: String, - _icon: String, + name: String, + icon: String, ) -> Result { self.chat_created_rooms .lock() .expect("created rooms mutex poisoned") - .push(room_id); + .push((room_id, name, icon)); Ok(*self .chat_room_status .lock() .expect("room status mutex poisoned")) } + fn register_bot( + &self, + bot_id: String, + name: String, + icon: String, + ) -> Result { + if let Some(reason) = self + .chat_bot_rejection + .lock() + .expect("bot rejection mutex poisoned") + .clone() + { + return Err(HostRejection::Rejected { reason }); + } + self.chat_registered_bots + .lock() + .expect("registered bots mutex poisoned") + .push((bot_id, name, icon)); + Ok(*self + .chat_bot_status + .lock() + .expect("bot status mutex poisoned")) + } + fn post_text_message( &self, room_id: String, @@ -2085,11 +2140,13 @@ mod tests { } fn list_rooms(&self) -> Result, HostRejection> { - let mut room_ids = self + let mut room_ids: Vec = self .chat_created_rooms .lock() .expect("created rooms mutex poisoned") - .clone(); + .iter() + .map(|(room_id, _, _)| room_id.clone()) + .collect(); room_ids.sort(); room_ids.dedup(); Ok(room_ids @@ -2382,6 +2439,188 @@ mod tests { ); } + #[test] + fn native_chat_adapter_rejects_the_message_variants_it_cannot_persist() { + let callbacks = Arc::new(EventCallbacks::new()); + let platform = ChatCallbackPlatform { + chat: callbacks.clone(), + events: Arc::new(NativeEventBus::default()), + }; + let product = + ProductContext::new_with_execution("chat.dot".to_string(), ProductExecutionKind::Chat) + .unwrap(); + let reaction = v01::ChatReaction { + message_id: "message-1".to_string(), + emoji: "🎲".to_string(), + }; + // `NativeChatCallbacks` persists text and custom messages only; the + // rest must surface a typed error rather than be dropped. + let unsupported = [ + v01::ChatMessageContent::RichText(v01::ChatRichText { + text: None, + media: Vec::new(), + }), + v01::ChatMessageContent::Actions(v01::ChatActions { + text: None, + actions: Vec::new(), + layout: v01::ChatActionLayout::Column, + }), + v01::ChatMessageContent::File(v01::ChatFile { + url: "https://example.invalid/f".to_string(), + file_name: "f".to_string(), + mime_type: "text/plain".to_string(), + size_bytes: 1, + text: None, + }), + v01::ChatMessageContent::Reaction(reaction.clone()), + v01::ChatMessageContent::ReactionRemoved(reaction), + ]; + + for payload in unsupported { + let error = futures::executor::block_on(truapi_platform::ChatPlatform::post_message( + &platform, + &product, + v01::HostChatPostMessageRequest { + room_id: "support".to_string(), + payload: payload.clone(), + }, + )) + .expect_err("the native adapter cannot persist this variant"); + assert!( + matches!(error, v01::HostChatPostMessageError::Unknown { .. }), + "{payload:?} must report a typed error" + ); + } + + assert!( + callbacks + .chat_posted_text + .lock() + .expect("posted text mutex poisoned") + .is_empty() + ); + } + + #[test] + fn native_chat_adapter_surfaces_a_bot_registration_rejection() { + let callbacks = Arc::new(EventCallbacks::new()); + let platform = ChatCallbackPlatform { + chat: callbacks.clone(), + events: Arc::new(NativeEventBus::default()), + }; + let product = + ProductContext::new_with_execution("chat.dot".to_string(), ProductExecutionKind::Chat) + .unwrap(); + *callbacks + .chat_bot_rejection + .lock() + .expect("bot rejection mutex poisoned") = Some("keychain locked".to_string()); + + let error = futures::executor::block_on(truapi_platform::ChatPlatform::register_bot( + &platform, + &product, + v01::HostChatRegisterBotRequest { + bot_id: "flipper".to_string(), + name: "Flipper".to_string(), + icon: String::new(), + }, + )) + .expect_err("a host rejection must not be reported as a successful registration"); + + // A swallowed rejection would reach the product as `New` for a bot + // that does not exist. + assert_eq!( + error, + v01::HostChatRegisterBotError::Unknown { + reason: "keychain locked".to_string(), + } + ); + assert!( + callbacks + .chat_registered_bots + .lock() + .expect("registered bots mutex poisoned") + .is_empty() + ); + } + + #[test] + fn native_chat_adapter_preserves_bot_status_and_leaves_rooms_alone() { + let callbacks = Arc::new(EventCallbacks::new()); + let events = Arc::new(NativeEventBus::default()); + let platform = ChatCallbackPlatform { + chat: callbacks.clone(), + events: events.clone(), + }; + let product = + ProductContext::new_with_execution("chat.dot".to_string(), ProductExecutionKind::Chat) + .unwrap(); + let request = v01::HostChatRegisterBotRequest { + bot_id: "flipper".to_string(), + name: "Flipper".to_string(), + icon: String::new(), + }; + + let mut rooms = truapi_platform::ChatPlatform::subscribe_rooms(&platform, &product); + assert!( + futures::executor::block_on(rooms.next()) + .expect("initial room list") + .rooms + .is_empty() + ); + + let registered = futures::executor::block_on(truapi_platform::ChatPlatform::register_bot( + &platform, + &product, + request.clone(), + )) + .unwrap(); + + *callbacks + .chat_bot_status + .lock() + .expect("bot status mutex poisoned") = v01::ChatBotRegistrationStatus::Exists; + let existing = futures::executor::block_on(truapi_platform::ChatPlatform::register_bot( + &platform, &product, request, + )) + .unwrap(); + + assert_eq!(registered.status, v01::ChatBotRegistrationStatus::New); + assert_eq!(existing.status, v01::ChatBotRegistrationStatus::Exists); + assert_eq!( + callbacks + .chat_registered_bots + .lock() + .expect("registered bots mutex poisoned") + .as_slice(), + &[ + ("flipper".to_string(), "Flipper".to_string(), String::new()), + ("flipper".to_string(), "Flipper".to_string(), String::new()), + ] + ); + + // Registering a bot is not a room change. Polled without blocking so an + // unexpected replacement fails instead of parking on a live sender. + let mut cx = core::task::Context::from_waker(futures::task::noop_waker_ref()); + assert!(matches!( + rooms.as_mut().poll_next(&mut cx), + core::task::Poll::Pending + )); + + // Still live for genuine room changes. + events.notify_chat_rooms_changed(vec![v01::ChatRoom { + room_id: "support".to_string(), + participating_as: v01::ChatRoomParticipation::Bot, + }]); + assert_eq!( + futures::executor::block_on(rooms.next()) + .expect("genuine room change") + .rooms + .len(), + 1 + ); + } + #[test] fn native_chat_adapter_preserves_room_status_and_message_room() { let callbacks = Arc::new(EventCallbacks::new()); @@ -2444,7 +2683,10 @@ mod tests { .lock() .expect("created rooms mutex poisoned") .as_slice(), - &["support", "support"] + &[ + ("support".to_string(), "Support".to_string(), String::new()), + ("support".to_string(), "Support".to_string(), String::new()), + ] ); assert_eq!( callbacks diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index ee4e48617..992dc3774 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -122,7 +122,22 @@ use truapi::versioned::chain::{ use truapi::versioned::chat::{ HostChatActionSubscribeItem, HostChatCreateRoomError, HostChatCreateRoomRequest, HostChatCreateRoomResponse, HostChatListSubscribeItem, HostChatPostMessageError, - HostChatPostMessageRequest, HostChatPostMessageResponse, + HostChatPostMessageRequest, HostChatPostMessageResponse, HostChatRegisterBotError, + HostChatRegisterBotRequest, HostChatRegisterBotResponse, +}; +use truapi::versioned::coin_payment::{ + HostCoinPaymentCreateChequeError, HostCoinPaymentCreateChequeRequest, + HostCoinPaymentCreateChequeResponse, HostCoinPaymentCreatePurseError, + HostCoinPaymentCreatePurseRequest, HostCoinPaymentCreatePurseResponse, + HostCoinPaymentCreateReceivableError, HostCoinPaymentCreateReceivableRequest, + HostCoinPaymentCreateReceivableResponse, HostCoinPaymentDeletePurseError, + HostCoinPaymentDeletePurseItem, HostCoinPaymentDeletePurseRequest, HostCoinPaymentDepositError, + HostCoinPaymentDepositItem, HostCoinPaymentDepositRequest, HostCoinPaymentListenForError, + HostCoinPaymentListenForItem, HostCoinPaymentListenForRequest, HostCoinPaymentQueryPurseError, + HostCoinPaymentQueryPurseRequest, HostCoinPaymentQueryPurseResponse, + HostCoinPaymentRebalancePurseError, HostCoinPaymentRebalancePurseItem, + HostCoinPaymentRebalancePurseRequest, HostCoinPaymentRefundError, HostCoinPaymentRefundItem, + HostCoinPaymentRefundRequest, }; use truapi::versioned::entropy::{ HostDeriveEntropyError, HostDeriveEntropyRequest, HostDeriveEntropyResponse, @@ -177,7 +192,8 @@ use truapi_platform::{ AccountAccessReview, CreateTransactionReview, IdentityDisclosureReview, PermissionAuthorizationRequest, PermissionAuthorizationStatus, PreimageSubmitReview, ProductContext, ProductStorageKey, ResourceAllocationReview, SessionUiInfo, SignPayloadReview, - SignRawReview, UserConfirmationReview, normalize_product_identifier, + SignRawReview, UserConfirmationReview, normalize_chat_identifier, normalize_product_identifier, + validate_chat_icon, validate_chat_name, }; /// Error reason surfaced to products when a remote permission is not granted. @@ -2135,8 +2151,7 @@ impl ProductRuntimeHost { fn chat_platform(&self) -> Result, CallError> { self.native_chat_platform().map_err(|error| match error { crate::host_core::ProductRuntimeError::Denied => CallError::Denied, - crate::host_core::ProductRuntimeError::Unsupported => CallError::Unsupported, - _ => unreachable!("Chat platform policy only returns Denied or Unsupported"), + _ => CallError::Unsupported, }) } @@ -2154,7 +2169,13 @@ impl Chat for ProductRuntimeHost { request: HostChatCreateRoomRequest, ) -> Result> { let platform = self.chat_platform()?; - let HostChatCreateRoomRequest::V1(request) = request; + let HostChatCreateRoomRequest::V1(mut request) = request; + request.room_id = normalize_chat_identifier("roomId", &request.room_id) + .map_err(chat_create_room_field_error)?; + request.name = + validate_chat_name("name", &request.name).map_err(chat_create_room_field_error)?; + request.icon = + validate_chat_icon("icon", &request.icon).map_err(chat_create_room_field_error)?; platform .create_room(&self.product, request) .await @@ -2162,6 +2183,27 @@ impl Chat for ProductRuntimeHost { .map_err(|error| CallError::Domain(HostChatCreateRoomError::V1(error))) } + #[instrument(skip_all, fields(runtime.method = "chat.register_bot"))] + async fn register_bot( + &self, + _cx: &CallContext, + request: HostChatRegisterBotRequest, + ) -> Result> { + let platform = self.chat_platform()?; + let HostChatRegisterBotRequest::V1(mut request) = request; + request.bot_id = normalize_chat_identifier("botId", &request.bot_id) + .map_err(chat_register_bot_field_error)?; + request.name = + validate_chat_name("name", &request.name).map_err(chat_register_bot_field_error)?; + request.icon = + validate_chat_icon("icon", &request.icon).map_err(chat_register_bot_field_error)?; + platform + .register_bot(&self.product, request) + .await + .map(HostChatRegisterBotResponse::V1) + .map_err(|error| CallError::Domain(HostChatRegisterBotError::V1(error))) + } + #[instrument(skip_all, fields(runtime.method = "chat.list_subscribe"))] async fn list_subscribe(&self, _cx: &CallContext) -> Subscription { let Ok(platform) = self.chat_platform::<()>() else { @@ -2181,7 +2223,17 @@ impl Chat for ProductRuntimeHost { request: HostChatPostMessageRequest, ) -> Result> { let platform = self.chat_platform()?; - let HostChatPostMessageRequest::V1(request) = request; + let HostChatPostMessageRequest::V1(mut request) = request; + // The same normalization create_room applied, so a product's own + // spelling of a room id still resolves to the stored room. + request.room_id = + normalize_chat_identifier("roomId", &request.room_id).map_err(|error| { + CallError::Domain(HostChatPostMessageError::V1( + v01::HostChatPostMessageError::Unknown { + reason: error.to_string(), + }, + )) + })?; platform .post_message(&self.product, request) .await @@ -2200,8 +2252,125 @@ impl Chat for ProductRuntimeHost { self.chat.subscribe_actions() } } +/// Report a rejected chat bot field as a bot-registration domain error. +fn chat_register_bot_field_error( + error: truapi_platform::ChatFieldError, +) -> CallError { + CallError::Domain(HostChatRegisterBotError::V1( + v01::HostChatRegisterBotError::Unknown { + reason: error.to_string(), + }, + )) +} + +/// Report a rejected chat room field as a room-creation domain error. +fn chat_create_room_field_error( + error: truapi_platform::ChatFieldError, +) -> CallError { + CallError::Domain(HostChatCreateRoomError::V1( + v01::HostChatCreateRoomError::Unknown { + reason: error.to_string(), + }, + )) +} + #[truapi::async_trait] -impl CoinPayment for ProductRuntimeHost {} +impl CoinPayment for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "coin_payment.create_purse"))] + async fn create_purse( + &self, + _cx: &CallContext, + _request: HostCoinPaymentCreatePurseRequest, + ) -> Result> + { + Err(CallError::Unsupported) + } + + #[instrument(skip_all, fields(runtime.method = "coin_payment.query_purse"))] + async fn query_purse( + &self, + _cx: &CallContext, + _request: HostCoinPaymentQueryPurseRequest, + ) -> Result> { + Err(CallError::Unsupported) + } + + #[instrument(skip_all, fields(runtime.method = "coin_payment.rebalance_purse"))] + async fn rebalance_purse( + &self, + _cx: &CallContext, + _request: HostCoinPaymentRebalancePurseRequest, + ) -> Result< + Subscription, + CallError, + > { + Err(CallError::Unsupported) + } + + #[instrument(skip_all, fields(runtime.method = "coin_payment.delete_purse"))] + async fn delete_purse( + &self, + _cx: &CallContext, + _request: HostCoinPaymentDeletePurseRequest, + ) -> Result< + Subscription, + CallError, + > { + Err(CallError::Unsupported) + } + + #[instrument(skip_all, fields(runtime.method = "coin_payment.create_receivable"))] + async fn create_receivable( + &self, + _cx: &CallContext, + _request: HostCoinPaymentCreateReceivableRequest, + ) -> Result< + HostCoinPaymentCreateReceivableResponse, + CallError, + > { + Err(CallError::Unsupported) + } + + #[instrument(skip_all, fields(runtime.method = "coin_payment.create_cheque"))] + async fn create_cheque( + &self, + _cx: &CallContext, + _request: HostCoinPaymentCreateChequeRequest, + ) -> Result> + { + Err(CallError::Unsupported) + } + + #[instrument(skip_all, fields(runtime.method = "coin_payment.deposit"))] + async fn deposit( + &self, + _cx: &CallContext, + _request: HostCoinPaymentDepositRequest, + ) -> Result, CallError> + { + Err(CallError::Unsupported) + } + + #[instrument(skip_all, fields(runtime.method = "coin_payment.refund"))] + async fn refund( + &self, + _cx: &CallContext, + _request: HostCoinPaymentRefundRequest, + ) -> Result, CallError> + { + Err(CallError::Unsupported) + } + + #[instrument(skip_all, fields(runtime.method = "coin_payment.listen_for_payment"))] + async fn listen_for_payment( + &self, + _cx: &CallContext, + _request: HostCoinPaymentListenForRequest, + ) -> Result, CallError> + { + Err(CallError::Unsupported) + } +} #[truapi::async_trait] impl Payment for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "payment.balance_subscribe"))] @@ -2726,6 +2895,294 @@ mod tests { ); } + /// Records which `ChatPlatform` methods the runtime actually reached. + #[derive(Default)] + struct RecordingChatPlatform { + registered_bots: Mutex>, + created_rooms: Mutex>, + posted_rooms: Mutex>, + } + + #[truapi::async_trait] + impl truapi_platform::ChatPlatform for RecordingChatPlatform { + async fn create_room( + &self, + _product: &ProductContext, + request: truapi::latest::HostChatCreateRoomRequest, + ) -> Result< + truapi::latest::HostChatCreateRoomResponse, + truapi::latest::HostChatCreateRoomError, + > { + self.created_rooms + .lock() + .expect("created rooms mutex poisoned") + .push(request.room_id); + Ok(truapi::latest::HostChatCreateRoomResponse { + status: v01::ChatRoomRegistrationStatus::New, + }) + } + + async fn register_bot( + &self, + _product: &ProductContext, + request: truapi::latest::HostChatRegisterBotRequest, + ) -> Result< + truapi::latest::HostChatRegisterBotResponse, + truapi::latest::HostChatRegisterBotError, + > { + self.registered_bots + .lock() + .expect("registered bots mutex poisoned") + .push(request.bot_id); + Ok(truapi::latest::HostChatRegisterBotResponse { + status: v01::ChatBotRegistrationStatus::New, + }) + } + + async fn post_message( + &self, + _product: &ProductContext, + request: truapi::latest::HostChatPostMessageRequest, + ) -> Result< + truapi::latest::HostChatPostMessageResponse, + truapi::latest::HostChatPostMessageError, + > { + self.posted_rooms + .lock() + .expect("posted rooms mutex poisoned") + .push(request.room_id); + Ok(truapi::latest::HostChatPostMessageResponse { + message_id: "message-id".to_string(), + }) + } + + fn subscribe_rooms( + &self, + _product: &ProductContext, + ) -> futures::stream::BoxStream<'static, truapi::latest::HostChatListSubscribeItem> + { + Box::pin(futures::stream::empty()) + } + } + + #[test] + fn chat_room_ids_agree_across_create_and_post() { + let (host_config, _) = runtime_config("chat.dot"); + let product = ProductContext::new_with_execution( + "chat.dot".to_string(), + truapi_platform::ProductExecutionKind::Chat, + ) + .expect("test chat product context is valid"); + let spawner = test_spawner(); + let platform: Arc = stub_platform(); + let services = RuntimeServices::new( + platform.clone(), + host_config.people_chain_genesis_hash, + host_config.bulletin_chain_genesis_hash, + spawner.clone(), + ); + let chat_platform = Arc::new(RecordingChatPlatform::default()); + let pairing_host = PairingHost::new(services.clone(), host_config); + let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); + adapters.chat_platform = Some(chat_platform.clone()); + let host = ProductRuntimeHost::from_services(services, adapters, pairing_host, product); + install_pairing_session(&host, session_info()); + + // Precomposed on create, decomposed on post: the host must see one id, + // or the message lands in a room that does not exist. + futures::executor::block_on(Chat::create_room( + &host, + &CallContext::default(), + HostChatCreateRoomRequest::V1(v01::HostChatCreateRoomRequest { + room_id: "caf\u{e9}".to_string(), + name: "Cafe".to_string(), + icon: String::new(), + }), + )) + .expect("create_room accepts a normalizable id"); + + futures::executor::block_on(Chat::post_message( + &host, + &CallContext::default(), + HostChatPostMessageRequest::V1(v01::HostChatPostMessageRequest { + room_id: "cafe\u{301}".to_string(), + payload: v01::ChatMessageContent::Text { + text: "hello".to_string(), + }, + }), + )) + .expect("post_message accepts the other spelling of the same id"); + + let created = chat_platform + .created_rooms + .lock() + .expect("created rooms mutex poisoned") + .clone(); + let posted = chat_platform + .posted_rooms + .lock() + .expect("posted rooms mutex poisoned") + .clone(); + assert_eq!(created, posted); + + // create_room screens the same fields register_bot does. + for (room_id, icon) in [("", ""), ("room\u{202e}", ""), ("room", "javascript:x")] { + let rejected = futures::executor::block_on(Chat::create_room( + &host, + &CallContext::default(), + HostChatCreateRoomRequest::V1(v01::HostChatCreateRoomRequest { + room_id: room_id.to_string(), + name: "Room".to_string(), + icon: icon.to_string(), + }), + )); + assert!( + matches!( + rejected, + Err(CallError::Domain(HostChatCreateRoomError::V1( + v01::HostChatCreateRoomError::Unknown { .. } + ))) + ), + "{room_id:?}/{icon:?} must be a domain error, got {rejected:?}" + ); + } + } + + #[test] + fn chat_register_bot_rejects_unsafe_product_fields() { + let (host_config, _) = runtime_config("chat.dot"); + let product = ProductContext::new_with_execution( + "chat.dot".to_string(), + truapi_platform::ProductExecutionKind::Chat, + ) + .expect("test chat product context is valid"); + let spawner = test_spawner(); + let platform: Arc = stub_platform(); + let services = RuntimeServices::new( + platform.clone(), + host_config.people_chain_genesis_hash, + host_config.bulletin_chain_genesis_hash, + spawner.clone(), + ); + let chat_platform = Arc::new(RecordingChatPlatform::default()); + let pairing_host = PairingHost::new(services.clone(), host_config); + let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); + adapters.chat_platform = Some(chat_platform.clone()); + let host = + ProductRuntimeHost::from_services(services, adapters, pairing_host, product.clone()); + install_pairing_session(&host, session_info()); + + let register = |bot_id: &str, name: &str, icon: &str| { + futures::executor::block_on(Chat::register_bot( + &host, + &CallContext::default(), + HostChatRegisterBotRequest::V1(v01::HostChatRegisterBotRequest { + bot_id: bot_id.to_string(), + name: name.to_string(), + icon: icon.to_string(), + }), + )) + }; + + // A rejected field is a domain error naming the field, not the + // transport-level `Unsupported` that means "this host has no Chat". + for (bot_id, name, icon, expected_field) in [ + ("", "Flipper", "", "botId"), + (" ", "Flipper", "", "botId"), + ("flip\u{202e}per", "Flipper", "", "botId"), + ("flipper", "Flip\u{202e}per", "", "name"), + ("flipper", "Flipper", "javascript:alert(1)", "icon"), + ("flipper", "Flipper", "data: text/html,