@parity/product-sdk-host 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/chat.ts ADDED
@@ -0,0 +1,126 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Wrapper for the host's chat surface (`host_chat_*` family).
5
+ *
6
+ * Shipped flat-in-host rather than as `getTruApi().chat.*` (the shape
7
+ * sketched in issue #93) because the upstream JS `hostApi` is itself a
8
+ * flat object - there is no `.chat` accessor to mirror. A flat
9
+ * `getChatManager()` matches the pattern already used by
10
+ * {@link getThemeProvider}, {@link getAccountsProvider}, and
11
+ * {@link getStatementStore}; if a namespaced view is desirable later, it
12
+ * can be layered on top without breaking this surface.
13
+ *
14
+ * @module
15
+ */
16
+
17
+ import { createLogger } from "@parity/product-sdk-logger";
18
+
19
+ import type {
20
+ ChatBotRegistrationResult as NovasamaChatBotRegistrationResult,
21
+ ChatCustomMessageRenderer as NovasamaChatCustomMessageRenderer,
22
+ ChatCustomMessageRendererParams as NovasamaChatCustomMessageRendererParams,
23
+ ChatMessageContent as NovasamaChatMessageContent,
24
+ ChatReceivedAction as NovasamaChatReceivedAction,
25
+ ChatRoom as NovasamaChatRoom,
26
+ ChatRoomRegistrationResult as NovasamaChatRoomRegistrationResult,
27
+ createProductChatManager,
28
+ } from "@novasamatech/host-api-wrapper";
29
+
30
+ const log = createLogger("host:chat");
31
+
32
+ /** Chat message payload variants. Re-exported from `@novasamatech/host-api-wrapper`. */
33
+ export type ChatMessageContent = NovasamaChatMessageContent;
34
+
35
+ /** Action received via {@link ChatManager.subscribeAction}. Re-exported from `@novasamatech/host-api-wrapper`. */
36
+ export type ChatReceivedAction = NovasamaChatReceivedAction;
37
+
38
+ /** Room metadata delivered to {@link ChatManager.subscribeChatList}. Re-exported from `@novasamatech/host-api-wrapper`. */
39
+ export type ChatRoom = NovasamaChatRoom;
40
+
41
+ /** Result of registering a chat room (`"New" | "Exists"`). Re-exported from `@novasamatech/host-api-wrapper`. */
42
+ export type ChatRoomRegistrationResult = NovasamaChatRoomRegistrationResult;
43
+
44
+ /** Result of registering a bot (`"New" | "Exists"`). Re-exported from `@novasamatech/host-api-wrapper`. */
45
+ export type ChatBotRegistrationResult = NovasamaChatBotRegistrationResult;
46
+
47
+ /** Renderer callback for custom message types. Re-exported from `@novasamatech/host-api-wrapper`. */
48
+ export type ChatCustomMessageRenderer = NovasamaChatCustomMessageRenderer;
49
+
50
+ /** Parameters passed to a {@link ChatCustomMessageRenderer}. Re-exported from `@novasamatech/host-api-wrapper`. */
51
+ export type ChatCustomMessageRendererParams<T = Uint8Array> =
52
+ NovasamaChatCustomMessageRendererParams<T>;
53
+
54
+ /**
55
+ * Chat manager handle. Exposes room/bot registration, message sending,
56
+ * subscription to room list and incoming actions, and custom-renderer
57
+ * registration.
58
+ *
59
+ * Type identical to `createProductChatManager()` from
60
+ * `@novasamatech/host-api-wrapper`.
61
+ */
62
+ export type ChatManager = ReturnType<typeof createProductChatManager>;
63
+
64
+ /**
65
+ * Get the host chat manager.
66
+ *
67
+ * Returns the chat manager from `@novasamatech/host-api-wrapper`, or `null` if
68
+ * the package is unavailable (running outside a host container or the
69
+ * optional peer dep isn't installed).
70
+ *
71
+ * @returns The chat manager, or `null` if unavailable.
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * import { getChatManager } from "@parity/product-sdk-host";
76
+ *
77
+ * const chat = await getChatManager();
78
+ * if (chat) {
79
+ * await chat.registerBot({ botId: "echo", name: "Echo Bot", icon: "" });
80
+ * chat.subscribeAction((action) => { ... });
81
+ * }
82
+ * ```
83
+ */
84
+ export async function getChatManager(): Promise<ChatManager | null> {
85
+ try {
86
+ const sdk = await import("@novasamatech/host-api-wrapper");
87
+ return sdk.createProductChatManager();
88
+ } catch (err) {
89
+ log.debug("getChatManager unavailable", err);
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Dispatch helper that composes multiple custom-message renderers into a
96
+ * single {@link ChatCustomMessageRenderer} keyed by `messageType`.
97
+ *
98
+ * Mirrors `matchChatCustomRenderers` from `@novasamatech/host-api-wrapper`
99
+ * inline (the upstream implementation is pure dispatch logic with no
100
+ * transport / runtime dependency on Novasama), so callers get the same
101
+ * sync signature instead of an async-with-null wrapper.
102
+ *
103
+ * @param map - Object mapping `messageType` strings to renderers.
104
+ * @returns A composed renderer that dispatches to the entry matching
105
+ * `params.messageType`, or throws if no renderer is registered.
106
+ */
107
+ export function matchChatCustomRenderers(
108
+ map: Record<string, ChatCustomMessageRenderer>,
109
+ ): ChatCustomMessageRenderer {
110
+ return (params, render) => {
111
+ const renderer = map[params.messageType];
112
+ if (!renderer) {
113
+ throw new Error(`Renderer for message type ${params.messageType} is not defined`);
114
+ }
115
+ return renderer(params, render);
116
+ };
117
+ }
118
+
119
+ if (import.meta.vitest) {
120
+ const { test, expect } = import.meta.vitest;
121
+
122
+ test("getChatManager returns manager when SDK is available", async () => {
123
+ const chat = await getChatManager();
124
+ expect(chat === null || typeof chat === "object").toBe(true);
125
+ });
126
+ }
package/src/container.ts CHANGED
@@ -1,7 +1,13 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  import type { JsonRpcProvider } from "polkadot-api";
4
+ import { createLogger } from "@parity/product-sdk-logger";
5
+ import type { Transport } from "@novasamatech/host-api";
2
6
 
3
7
  import type { HostLocalStorage, HostStatementStore } from "./types.js";
4
8
 
9
+ const log = createLogger("host:container");
10
+
5
11
  /**
6
12
  * Detect if running inside a Host container (Polkadot Browser / Polkadot Desktop).
7
13
  *
@@ -15,7 +21,7 @@ export async function isInsideContainer(): Promise<boolean> {
15
21
  if (typeof window === "undefined") return false;
16
22
 
17
23
  try {
18
- const sdk = await import("@novasamatech/product-sdk");
24
+ const sdk = await import("@novasamatech/host-api-wrapper");
19
25
  return sdk.sandboxProvider.isCorrectEnvironment();
20
26
  } catch {
21
27
  return isInsideContainerSync();
@@ -30,9 +36,35 @@ export async function getHostLocalStorage(): Promise<HostLocalStorage | null> {
30
36
  if (!(await isInsideContainer())) return null;
31
37
 
32
38
  try {
33
- const sdk = await import("@novasamatech/product-sdk");
39
+ const sdk = await import("@novasamatech/host-api-wrapper");
34
40
  return sdk.hostLocalStorage as HostLocalStorage;
35
- } catch {
41
+ } catch (err) {
42
+ log.debug("getHostLocalStorage unavailable", err);
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Construct a fresh host-backed `HostLocalStorage` instance with an optional
49
+ * custom transport. Use this when you need a non-default transport (e.g.
50
+ * for tests); otherwise prefer {@link getHostLocalStorage}, which returns
51
+ * the shared singleton.
52
+ *
53
+ * Mirrors `createLocalStorage` from `@novasamatech/host-api-wrapper`.
54
+ *
55
+ * @param transport - Optional transport; defaults to the sandbox transport.
56
+ * @returns A new `HostLocalStorage` instance, or `null` if unavailable.
57
+ */
58
+ export async function createHostLocalStorage(
59
+ transport?: Transport,
60
+ ): Promise<HostLocalStorage | null> {
61
+ if (!(await isInsideContainer())) return null;
62
+
63
+ try {
64
+ const sdk = await import("@novasamatech/host-api-wrapper");
65
+ return sdk.createLocalStorage(transport);
66
+ } catch (err) {
67
+ log.debug("createHostLocalStorage unavailable", err);
36
68
  return null;
37
69
  }
38
70
  }
@@ -42,16 +74,17 @@ export async function getHostLocalStorage(): Promise<HostLocalStorage | null> {
42
74
  *
43
75
  * When running inside a Polkadot container, this wraps the chain connection via the
44
76
  * host's `createPapiProvider`, enabling shared connections and efficient routing.
45
- * Returns `null` when `@novasamatech/product-sdk` is unavailable.
77
+ * Returns `null` when `@novasamatech/host-api-wrapper` is unavailable.
46
78
  *
47
79
  * @param genesisHash - Genesis hash of the target chain (`0x`-prefixed hex string).
48
80
  * @returns A host-routed `JsonRpcProvider`, or `null` if unavailable.
49
81
  */
50
82
  export async function getHostProvider(genesisHash: `0x${string}`): Promise<JsonRpcProvider | null> {
51
83
  try {
52
- const sdk = await import("@novasamatech/product-sdk");
84
+ const sdk = await import("@novasamatech/host-api-wrapper");
53
85
  return sdk.createPapiProvider(genesisHash);
54
- } catch {
86
+ } catch (err) {
87
+ log.debug("getHostProvider unavailable", err);
55
88
  return null;
56
89
  }
57
90
  }
@@ -90,15 +123,16 @@ export function isInsideContainerSync(): boolean {
90
123
  *
91
124
  * Returns a statement store with `subscribe`, `createProof`, and `submit` methods
92
125
  * that communicate through the host's native binary protocol — bypassing JSON-RPC
93
- * entirely. Returns `null` when `@novasamatech/product-sdk` is unavailable.
126
+ * entirely. Returns `null` when `@novasamatech/host-api-wrapper` is unavailable.
94
127
  *
95
128
  * @returns The host statement store, or `null` if unavailable.
96
129
  */
97
130
  export async function getStatementStore(): Promise<HostStatementStore | null> {
98
131
  try {
99
- const sdk = await import("@novasamatech/product-sdk");
132
+ const sdk = await import("@novasamatech/host-api-wrapper");
100
133
  return sdk.createStatementStore() as HostStatementStore;
101
- } catch {
134
+ } catch (err) {
135
+ log.debug("getStatementStore unavailable", err);
102
136
  return null;
103
137
  }
104
138
  }
@@ -166,6 +200,10 @@ if (import.meta.vitest) {
166
200
  expect(await getHostLocalStorage()).toBeNull();
167
201
  });
168
202
 
203
+ test("createHostLocalStorage returns null outside container", async () => {
204
+ expect(await createHostLocalStorage()).toBeNull();
205
+ });
206
+
169
207
  test("getHostProvider returns null when product-sdk unavailable", async () => {
170
208
  const result = await getHostProvider("0xabc");
171
209
  expect(result).toBeNull();
package/src/entropy.ts ADDED
@@ -0,0 +1,67 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Higher-level wrapper for the host's entropy derivation (RFC-0007).
5
+ *
6
+ * `hostApi.deriveEntropy` is reachable via {@link getTruApi}, but consumers
7
+ * have to wrap the value in the versioned envelope (`enumValue("v1", ...)`)
8
+ * and unwrap the neverthrow `ResultAsync` themselves. `deriveEntropy`
9
+ * collapses that to a throw-on-error Promise that matches the shape of
10
+ * {@link requestPermission} and {@link requestResourceAllocation}.
11
+ *
12
+ * @module
13
+ */
14
+
15
+ import { createLogger } from "@parity/product-sdk-logger";
16
+
17
+ import { enumValue, formatHostError, getTruApi } from "./truapi.js";
18
+
19
+ const log = createLogger("host:entropy");
20
+
21
+ /**
22
+ * Derive deterministic entropy from a context key (RFC-0007).
23
+ *
24
+ * The host derives entropy from the user's wallet + the provided context
25
+ * key. Calling with the same key on the same wallet yields the same bytes;
26
+ * different keys (or different wallets) yield uncorrelated entropy.
27
+ *
28
+ * @param key - Context key bytes (typically a SCALE-encoded discriminator).
29
+ * @returns The derived entropy bytes.
30
+ * @throws If the host is unavailable or the host-side derivation fails.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * import { deriveEntropy } from "@parity/product-sdk-host";
35
+ *
36
+ * const seed = await deriveEntropy(new TextEncoder().encode("my-app:seed-v1"));
37
+ * ```
38
+ */
39
+ export async function deriveEntropy(key: Uint8Array): Promise<Uint8Array> {
40
+ const truApi = await getTruApi();
41
+ if (!truApi) {
42
+ throw new Error("deriveEntropy: TruAPI unavailable");
43
+ }
44
+ log.debug("deriveEntropy", { keyLen: key.length });
45
+
46
+ return await truApi.deriveEntropy(enumValue("v1", key)).match(
47
+ (envelope: { tag: "v1"; value: Uint8Array }) => envelope.value,
48
+ (err: unknown) => {
49
+ throw new Error(`deriveEntropy failed: ${formatHostError(err)}`, { cause: err });
50
+ },
51
+ );
52
+ }
53
+
54
+ if (import.meta.vitest) {
55
+ const { test, expect } = import.meta.vitest;
56
+
57
+ test("deriveEntropy throws when TruAPI is unavailable", async () => {
58
+ const api = await getTruApi();
59
+ if (api === null) {
60
+ await expect(deriveEntropy(new Uint8Array([1, 2, 3]))).rejects.toThrow(
61
+ /TruAPI unavailable/,
62
+ );
63
+ } else {
64
+ expect(typeof deriveEntropy).toBe("function");
65
+ }
66
+ });
67
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  /**
2
4
  * @parity/product-sdk-host — Detect and talk to the Polkadot Desktop/Mobile host container.
3
5
  *
@@ -11,6 +13,7 @@ export {
11
13
  isInsideContainer,
12
14
  isInsideContainerSync,
13
15
  getHostLocalStorage,
16
+ createHostLocalStorage,
14
17
  getHostProvider,
15
18
  getStatementStore,
16
19
  } from "./container.js";
@@ -18,19 +21,25 @@ export type {
18
21
  HostLocalStorage,
19
22
  HostStatementStore,
20
23
  HostSubscription,
24
+ ProductAccountId,
25
+ SignedStatement,
26
+ Statement,
21
27
  StatementProof,
22
28
  StatementTopicFilter,
23
29
  StatementsPage,
30
+ Topic,
24
31
  } from "./types.js";
25
32
  export { BULLETIN_RPCS, DEFAULT_BULLETIN_ENDPOINT } from "./chains.js";
26
33
 
27
- // TruAPI - re-exports from @novasamatech/product-sdk and @novasamatech/host-api
34
+ // TruAPI - re-exports from @novasamatech/host-api-wrapper and @novasamatech/host-api
28
35
  export {
29
36
  getTruApi,
30
37
  getPreimageManager,
38
+ createHostPreimageManager,
31
39
  getAccountsProvider,
32
40
  requestResourceAllocation,
33
41
  createProofAuthorized,
42
+ formatHostError,
34
43
  // Helpers from @novasamatech/host-api
35
44
  enumValue,
36
45
  isEnumVariant,
@@ -56,8 +65,32 @@ export type {
56
65
  AllocationOutcomeTag,
57
66
  RemotePermission,
58
67
  RemotePermissionTag,
59
- Statement,
60
68
  } from "./truapi.js";
61
69
 
62
- // Higher-level permission wrapper
63
- export { requestPermission } from "./permissions.js";
70
+ // Higher-level permission wrappers
71
+ export { requestPermission, requestDevicePermission } from "./permissions.js";
72
+ export type { DevicePermissionKind, RemotePermissionItem } from "./permissions.js";
73
+
74
+ // Theme provider
75
+ export { getThemeProvider } from "./theme.js";
76
+ export type { ThemeMode, ThemeName, ThemeProvider, ThemeVariant } from "./theme.js";
77
+
78
+ // Entropy derivation (RFC-0007)
79
+ export { deriveEntropy } from "./entropy.js";
80
+
81
+ // Chat
82
+ export { getChatManager, matchChatCustomRenderers } from "./chat.js";
83
+ export type {
84
+ ChatManager,
85
+ ChatMessageContent,
86
+ ChatReceivedAction,
87
+ ChatRoom,
88
+ ChatRoomRegistrationResult,
89
+ ChatBotRegistrationResult,
90
+ ChatCustomMessageRenderer,
91
+ ChatCustomMessageRendererParams,
92
+ } from "./chat.js";
93
+
94
+ // Payments (RFC-0006)
95
+ export { getPaymentManager } from "./payments.js";
96
+ export type { PaymentManager, PaymentBalance, PaymentStatus, TopUpSource } from "./payments.js";
@@ -0,0 +1,100 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Wrapper for the host's payment manager (RFC-0006).
5
+ *
6
+ * Shipped flat-in-host rather than as `getTruApi().payment.*` because the
7
+ * upstream JS `hostApi` is itself a flat object - there is no `.payment`
8
+ * accessor to mirror. A flat `getPaymentManager()` matches the singleton
9
+ * pattern already used by {@link getPreimageManager},
10
+ * {@link getHostLocalStorage}, and {@link getAccountsProvider}.
11
+ *
12
+ * Returns the shared `paymentManager` singleton from
13
+ * `@novasamatech/host-api-wrapper` (not a fresh `createPaymentManager()`
14
+ * instance) so callers share one wrapper + hostApi closure across the app.
15
+ *
16
+ * Distinct from the CoinPayment / merchant-payments surface tracked under
17
+ * `@parity/product-sdk-merchant-payments` (RFC-0017). RFC-0006 is the
18
+ * user-initiated balance / top-up / payment-request flow; RFC-0017 is the
19
+ * merchant-initiated checkout flow.
20
+ *
21
+ * @module
22
+ */
23
+
24
+ import { createLogger } from "@parity/product-sdk-logger";
25
+
26
+ import type {
27
+ PaymentBalance as NovasamaPaymentBalance,
28
+ PaymentStatus as NovasamaPaymentStatus,
29
+ TopUpSource as NovasamaTopUpSource,
30
+ paymentManager,
31
+ } from "@novasamatech/host-api-wrapper";
32
+
33
+ const log = createLogger("host:payments");
34
+
35
+ /** Available balance for the user's payment account. Re-exported from `@novasamatech/host-api-wrapper`. */
36
+ export type PaymentBalance = NovasamaPaymentBalance;
37
+
38
+ /** Status of an in-flight payment request. Re-exported from `@novasamatech/host-api-wrapper`. */
39
+ export type PaymentStatus = NovasamaPaymentStatus;
40
+
41
+ /** Source for {@link PaymentManager.topUp}. Re-exported from `@novasamatech/host-api-wrapper`. */
42
+ export type TopUpSource = NovasamaTopUpSource;
43
+
44
+ /**
45
+ * Payment manager handle. Exposes balance subscription, top-up, payment
46
+ * requests, and payment status subscription.
47
+ *
48
+ * Type identical to `paymentManager` from `@novasamatech/host-api-wrapper`.
49
+ */
50
+ export type PaymentManager = typeof paymentManager;
51
+
52
+ /**
53
+ * Get the host payment manager.
54
+ *
55
+ * Returns the shared `paymentManager` singleton from
56
+ * `@novasamatech/host-api-wrapper`, or `null` if the package is unavailable
57
+ * (running outside a host container or the optional peer dep isn't
58
+ * installed).
59
+ *
60
+ * @returns The payment manager, or `null` if unavailable.
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * import { getPaymentManager } from "@parity/product-sdk-host";
65
+ *
66
+ * const payments = await getPaymentManager();
67
+ * if (payments) {
68
+ * const sub = payments.subscribeBalance((b) => { ... });
69
+ * await payments.topUp(1_000_000n, { type: "productAccount", derivationIndex: 0 });
70
+ * const destination = new Uint8Array(32);
71
+ * const { id } = await payments.requestPayment(500n, destination);
72
+ * sub.unsubscribe();
73
+ * }
74
+ * ```
75
+ */
76
+ export async function getPaymentManager(): Promise<PaymentManager | null> {
77
+ try {
78
+ const sdk = await import("@novasamatech/host-api-wrapper");
79
+ return sdk.paymentManager;
80
+ } catch (err) {
81
+ log.debug("getPaymentManager unavailable", err);
82
+ return null;
83
+ }
84
+ }
85
+
86
+ if (import.meta.vitest) {
87
+ const { test, expect } = import.meta.vitest;
88
+
89
+ test("getPaymentManager returns manager with full RFC-0006 surface when SDK is available", async () => {
90
+ const payments = await getPaymentManager();
91
+ if (payments === null) {
92
+ // Acceptable: SDK couldn't load (e.g. peer dep missing in some envs).
93
+ return;
94
+ }
95
+ expect(typeof payments.subscribeBalance).toBe("function");
96
+ expect(typeof payments.topUp).toBe("function");
97
+ expect(typeof payments.requestPayment).toBe("function");
98
+ expect(typeof payments.subscribePaymentStatus).toBe("function");
99
+ });
100
+ }
@@ -1,22 +1,43 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  /**
2
- * Higher-level wrapper for the host's single-permission flow.
4
+ * Higher-level wrappers for the host's single-permission flows.
3
5
  *
4
- * `hostApi.permission` takes a versioned envelope (`enumValue("v1", ...)`)
5
- * and returns a neverthrow `ResultAsync` of an unwrapped versioned response.
6
- * Consumers rebuild that wrap/unwrap dance every time. `requestPermission`
7
- * collapses it to a one-liner that matches the shape of
8
- * {@link requestResourceAllocation} (throws on error, returns the unwrapped
9
- * payload on success).
6
+ * `hostApi.permission` / `hostApi.devicePermission` take a versioned
7
+ * envelope (`enumValue("v1", ...)`) and return a neverthrow `ResultAsync`
8
+ * of an unwrapped versioned response. Consumers rebuild that wrap/unwrap
9
+ * dance every time. {@link requestPermission} and
10
+ * {@link requestDevicePermission} collapse it to one-liners that match the
11
+ * shape of {@link requestResourceAllocation} (throws on error, returns
12
+ * the unwrapped payload on success).
10
13
  *
11
14
  * @module
12
15
  */
13
16
 
14
17
  import { createLogger } from "@parity/product-sdk-logger";
15
18
 
16
- import { enumValue, getTruApi, type RemotePermission } from "./truapi.js";
19
+ import type { CodecType } from "@novasamatech/host-api";
20
+ import type { DevicePermission as DevicePermissionCodec } from "@novasamatech/host-api";
21
+
22
+ import { enumValue, formatHostError, getTruApi, type RemotePermission } from "./truapi.js";
17
23
 
18
24
  const log = createLogger("host:permissions");
19
25
 
26
+ /**
27
+ * Device permission the dapp can ask the host to grant via
28
+ * {@link requestDevicePermission}.
29
+ *
30
+ * Derived from the upstream codec so variant renames surface as compile
31
+ * errors, not runtime failures.
32
+ */
33
+ export type DevicePermissionKind = CodecType<typeof DevicePermissionCodec>;
34
+
35
+ /**
36
+ * Alias of {@link RemotePermission} matching the upstream
37
+ * `@novasamatech/host-api-wrapper` name. Use either freely.
38
+ */
39
+ export type RemotePermissionItem = RemotePermission;
40
+
20
41
  /**
21
42
  * Request a single remote permission from the host.
22
43
  *
@@ -45,10 +66,43 @@ export async function requestPermission(permission: RemotePermission): Promise<b
45
66
  return await truApi.permission(enumValue("v1", permission)).match(
46
67
  (envelope: { tag: "v1"; value: boolean }) => envelope.value,
47
68
  (err: unknown) => {
48
- throw new Error(
49
- `requestPermission failed: ${err instanceof Error ? err.message : String(err)}`,
50
- { cause: err },
51
- );
69
+ throw new Error(`requestPermission failed: ${formatHostError(err)}`, { cause: err });
70
+ },
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Request a single device permission (camera, microphone, etc.) from the
76
+ * host.
77
+ *
78
+ * Builds the `v1` envelope, calls `hostApi.devicePermission`, unwraps the
79
+ * response, and returns the host's boolean granted/denied outcome.
80
+ *
81
+ * @param permission - The device permission to request.
82
+ * @returns `true` if the host granted the permission, `false` if denied.
83
+ * @throws If the host is unavailable or the request fails.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * const granted = await requestDevicePermission("Camera");
88
+ * if (!granted) {
89
+ * showCameraDeniedMessage();
90
+ * }
91
+ * ```
92
+ */
93
+ export async function requestDevicePermission(permission: DevicePermissionKind): Promise<boolean> {
94
+ const truApi = await getTruApi();
95
+ if (!truApi) {
96
+ throw new Error("requestDevicePermission: TruAPI unavailable");
97
+ }
98
+ log.debug("requestDevicePermission", { permission });
99
+
100
+ return await truApi.devicePermission(enumValue("v1", permission)).match(
101
+ (envelope: { tag: "v1"; value: boolean }) => envelope.value,
102
+ (err: unknown) => {
103
+ throw new Error(`requestDevicePermission failed: ${formatHostError(err)}`, {
104
+ cause: err,
105
+ });
52
106
  },
53
107
  );
54
108
  }
@@ -57,14 +111,21 @@ if (import.meta.vitest) {
57
111
  const { test, expect, describe, vi } = import.meta.vitest;
58
112
 
59
113
  async function withMockedTruApi<T>(
60
- bridge: { permission?: (req: unknown) => unknown } | null,
114
+ bridge: {
115
+ permission?: (req: unknown) => unknown;
116
+ devicePermission?: (req: unknown) => unknown;
117
+ } | null,
61
118
  fn: (mod: typeof import("./permissions.js")) => Promise<T>,
62
119
  ): Promise<T> {
63
120
  vi.resetModules();
64
- vi.doMock("./truapi.js", () => ({
65
- getTruApi: async () => bridge,
66
- enumValue: (version: string, value: unknown) => ({ tag: version, value }),
67
- }));
121
+ vi.doMock("./truapi.js", async (importOriginal) => {
122
+ const original = await importOriginal<typeof import("./truapi.js")>();
123
+ return {
124
+ ...original,
125
+ getTruApi: async () => bridge,
126
+ enumValue: (version: string, value: unknown) => ({ tag: version, value }),
127
+ };
128
+ });
68
129
  try {
69
130
  const mod = await import("./permissions.js");
70
131
  return await fn(mod);
@@ -118,7 +179,54 @@ if (import.meta.vitest) {
118
179
  async (mod) => {
119
180
  await expect(
120
181
  mod.requestPermission({ tag: "ChainSubmit", value: undefined }),
121
- ).rejects.toThrow(/requestPermission failed/);
182
+ ).rejects.toThrow(/requestPermission failed: GenericError: boom/);
183
+ },
184
+ );
185
+ });
186
+ });
187
+
188
+ describe("requestDevicePermission", () => {
189
+ test("throws when TruAPI is unavailable", async () => {
190
+ await withMockedTruApi(null, async (mod) => {
191
+ await expect(mod.requestDevicePermission("Camera")).rejects.toThrow(
192
+ /TruAPI unavailable/,
193
+ );
194
+ });
195
+ });
196
+
197
+ test("unwraps the v1 boolean outcome", async () => {
198
+ await withMockedTruApi(
199
+ {
200
+ devicePermission: vi.fn().mockReturnValue({
201
+ match: async (onOk: (v: unknown) => unknown) =>
202
+ onOk({ tag: "v1", value: true }),
203
+ }),
204
+ },
205
+ async (mod) => {
206
+ const granted = await mod.requestDevicePermission("Camera");
207
+ expect(granted).toBe(true);
208
+ },
209
+ );
210
+ });
211
+
212
+ test("wraps host errors with a diagnostic message", async () => {
213
+ await withMockedTruApi(
214
+ {
215
+ devicePermission: vi.fn().mockReturnValue({
216
+ match: async (
217
+ _onOk: (v: unknown) => unknown,
218
+ onErr: (e: unknown) => unknown,
219
+ ) =>
220
+ onErr({
221
+ tag: "v1",
222
+ value: { name: "GenericError", message: "boom" },
223
+ }),
224
+ }),
225
+ },
226
+ async (mod) => {
227
+ await expect(mod.requestDevicePermission("Camera")).rejects.toThrow(
228
+ /requestDevicePermission failed: GenericError: boom/,
229
+ );
122
230
  },
123
231
  );
124
232
  });