|
1 | | -# @novasamatech/host-container |
| 1 | +# @novasamatech/host-chat |
2 | 2 |
|
3 | | -A robust solution for hosting and managing decentralized applications (dapps) within the Polkadot ecosystem. |
| 3 | +Account lookup and chat-message codecs for host applications integrating with the Polkadot People chain. |
4 | 4 |
|
5 | 5 | ## Overview |
6 | 6 |
|
7 | | -Host container provides the infrastructure layer for securely embedding and communicating with third-party dapps. |
8 | | -It handles the isolation boundary, message routing, lifecycle management, and security concerns inherent in hosting untrusted web content. |
| 7 | +`@novasamatech/host-chat` exposes the read side of the chat domain: discovering Polkadot |
| 8 | +accounts by username and resolving their on-chain identity from `Resources.Consumers`. It |
| 9 | +also publishes the SCALE codecs used by the chat wire protocol (messages, attachments, |
| 10 | +local-message envelopes) so host applications can decode statements they receive over the |
| 11 | +statement store. |
| 12 | + |
| 13 | +The package is UI-framework agnostic. The main entry point returns plain async functions |
| 14 | +backed by [`neverthrow`](https://github.com/supermacro/neverthrow) `ResultAsync`, and the |
| 15 | +codec exports are pure SCALE codecs with no runtime side effects. |
9 | 16 |
|
10 | 17 | ## Installation |
11 | 18 |
|
12 | 19 | ```shell |
13 | | -npm install @novasamatech/host-container --save -E |
14 | | -``` |
15 | | - |
16 | | -### Basic Container Setup |
17 | | - |
18 | | -```ts |
19 | | -import { createContainer, createIframeProvider } from '@novasamatech/host-container'; |
20 | | - |
21 | | -const iframe = document.createElement('iframe'); |
22 | | - |
23 | | -const provider = createIframeProvider({ |
24 | | - iframe, |
25 | | - url: 'https://dapp.example.com' |
26 | | -}); |
27 | | -const container = createContainer(provider); |
28 | | - |
29 | | -document.body.appendChild(iframe); |
30 | | -``` |
31 | | - |
32 | | -## API reference |
33 | | - |
34 | | -### handleFeature |
35 | | - |
36 | | -```ts |
37 | | -container.handleFeature((params, { ok, err }) => { |
38 | | - if (params.tag === 'Chat') { |
39 | | - return ok(supportedChains.has(params.value)); |
40 | | - } |
41 | | - return ok(false); |
42 | | -}); |
43 | | -``` |
44 | | - |
45 | | -### handlePermissionRequest |
46 | | - |
47 | | -```ts |
48 | | -container.handlePermissionRequest(async (params, { ok, err }) => { |
49 | | - if (params.tag === 'ChainConnect') { |
50 | | - // Show permission dialog to user |
51 | | - const approved = await showPermissionDialog(params.value); |
52 | | - return approved ? ok(undefined) : err({ tag: 'Rejected' }); |
53 | | - } |
54 | | - return err({ tag: 'Unknown', value: { reason: 'Unsupported permission type' } }); |
55 | | -}); |
56 | | -``` |
57 | | - |
58 | | -### handleStorageRead |
59 | | - |
60 | | -```ts |
61 | | -container.handleStorageRead(async (key, { ok, err }) => { |
62 | | - const value = await storage.get(key); |
63 | | - return ok(value ?? null); |
64 | | -}); |
65 | | -``` |
66 | | - |
67 | | -### handleStorageWrite |
68 | | - |
69 | | -```ts |
70 | | -container.handleStorageWrite(async ([key, value], { ok, err }) => { |
71 | | - try { |
72 | | - await storage.set(key, value); |
73 | | - return ok(undefined); |
74 | | - } catch (e) { |
75 | | - return err({ tag: 'Full' }); |
76 | | - } |
77 | | -}); |
78 | | -``` |
79 | | - |
80 | | -### handleStorageClear |
81 | | - |
82 | | -```ts |
83 | | -container.handleStorageClear(async (key, { ok, err }) => { |
84 | | - await storage.delete(key); |
85 | | - return ok(undefined); |
86 | | -}); |
87 | | -``` |
88 | | - |
89 | | -### handleAccountGet |
90 | | - |
91 | | -```ts |
92 | | -container.handleAccountGet(async ([dotnsId, derivationIndex], { ok, err }) => { |
93 | | - const account = await getProductAccount(dotnsId, derivationIndex); |
94 | | - if (account) { |
95 | | - return ok({ publicKey: account.publicKey, name: account.name ?? null }); |
96 | | - } |
97 | | - return err({ tag: 'NotConnected' }); |
98 | | -}); |
99 | | -``` |
100 | | - |
101 | | -### handleAccountGetAlias |
102 | | - |
103 | | -```ts |
104 | | -container.handleAccountGetAlias(async ([dotnsId, derivationIndex], { ok, err }) => { |
105 | | - const alias = await getAccountAlias(dotnsId, derivationIndex); |
106 | | - if (alias) { |
107 | | - return ok({ context: alias.context, alias: alias.alias }); |
108 | | - } |
109 | | - return err(new RequestCredentialsErr.NotConnected()); |
110 | | -}); |
111 | | -``` |
112 | | - |
113 | | -### handleAccountCreateProof |
114 | | - |
115 | | -```ts |
116 | | -container.handleAccountCreateProof(async ([[dotnsId, derivationIndex], ringLocation, message], { ok, err }) => { |
117 | | - try { |
118 | | - const proof = await createRingProof(dotnsId, derivationIndex, ringLocation, message); |
119 | | - return ok(proof); |
120 | | - } catch (e) { |
121 | | - return err({ tag: 'RingNotFound' }); |
122 | | - } |
123 | | -}); |
124 | | -``` |
125 | | - |
126 | | -### handleGetLegacyAccounts |
127 | | - |
128 | | -```ts |
129 | | -container.handleGetLegacyAccounts(async (_, { ok, err }) => { |
130 | | - const accounts = await getLegacyAccounts(); |
131 | | - return ok(accounts); |
132 | | -}); |
133 | | -``` |
134 | | - |
135 | | -### handleCreateTransaction |
136 | | - |
137 | | -```ts |
138 | | -container.handleCreateTransaction(async ([productAccountId, payload], { ok, err }) => { |
139 | | - try { |
140 | | - const signedTx = await createTransaction(productAccountId, payload); |
141 | | - return ok(signedTx); |
142 | | - } catch (e) { |
143 | | - return err({ tag: 'Rejected' }); |
144 | | - } |
145 | | -}); |
146 | | -``` |
147 | | - |
148 | | -### handleCreateTransactionWithLegacyAccount |
149 | | - |
150 | | -```ts |
151 | | -container.handleCreateTransactionWithLegacyAccount(async (payload, { ok, err }) => { |
152 | | - try { |
153 | | - const signedTx = await createTransactionWithLegacyAccount(payload); |
154 | | - return ok(signedTx); |
155 | | - } catch (e) { |
156 | | - return err({ tag: 'Rejected' }); |
157 | | - } |
158 | | -}); |
159 | | -``` |
160 | | - |
161 | | -### handleSignRaw |
162 | | - |
163 | | -```ts |
164 | | -container.handleSignRaw(async (payload, { ok, err }) => { |
165 | | - try { |
166 | | - const result = await signRaw(payload); |
167 | | - return ok({ signature: result.signature, signedTransaction: result.signedTransaction }); |
168 | | - } catch (e) { |
169 | | - return err({ tag: 'Rejected' }); |
170 | | - } |
171 | | -}); |
172 | | -``` |
173 | | - |
174 | | -### handleSignPayload |
175 | | - |
176 | | -```ts |
177 | | -container.handleSignPayload(async (payload, { ok, err }) => { |
178 | | - try { |
179 | | - const result = await signPayload(payload); |
180 | | - return ok({ signature: result.signature, signedTransaction: result.signedTransaction ?? null }); |
181 | | - } catch (e) { |
182 | | - return err({ tag: 'Rejected' }); |
183 | | - } |
184 | | -}); |
185 | | -``` |
186 | | - |
187 | | -### handleChatCreateContact |
188 | | - |
189 | | -```ts |
190 | | -container.handleChatCreateContact(async (contact, { ok, err }) => { |
191 | | - await chatService.registerContact(contact); |
192 | | - return ok(undefined); |
193 | | -}); |
| 20 | +npm install @novasamatech/host-chat --save -E |
194 | 21 | ``` |
195 | 22 |
|
196 | | -### handleChatPostMessage |
| 23 | +## Getting started |
197 | 24 |
|
198 | 25 | ```ts |
199 | | -container.handleChatPostMessage(async (message, { ok, err }) => { |
200 | | - const messageId = await chatService.postMessage(message); |
201 | | - return ok({ messageId }); |
202 | | -}); |
203 | | -``` |
204 | | - |
205 | | -### handleChatActionSubscribe |
| 26 | +import { createAccountService } from '@novasamatech/host-chat'; |
| 27 | +import { createLazyClient } from '@novasamatech/statement-store'; |
206 | 28 |
|
207 | | -```ts |
208 | | -container.handleChatActionSubscribe((_, send, interrupt) => { |
209 | | - const listener = (action) => send(action); |
210 | | - chatService.on('action', listener); |
211 | | - return () => chatService.off('action', listener); |
212 | | -}); |
213 | | -``` |
| 29 | +const lazyClient = createLazyClient(/* chain provider */); |
| 30 | +const accounts = createAccountService('paseo-next-v2', lazyClient); |
214 | 31 |
|
215 | | -### handleStatementStoreCreateProof |
216 | | - |
217 | | -```ts |
218 | | -container.handleStatementStoreCreateProof(async ([[dotnsId, derivationIndex], statement], { ok, err }) => { |
219 | | - try { |
220 | | - const proof = await createStatementProof(dotnsId, derivationIndex, statement); |
221 | | - return ok(proof); |
222 | | - } catch (e) { |
223 | | - return err({ tag: 'UnableToSign' }); |
| 32 | +// Search the off-chain username index for accounts whose username starts with `alice`. |
| 33 | +const search = await accounts.search('alice', 'ASSIGNED'); |
| 34 | +if (search.isOk()) { |
| 35 | + for (const hit of search.value) { |
| 36 | + console.log(hit.candidateAccountId, hit.username); |
224 | 37 | } |
225 | | -}); |
226 | | -``` |
227 | | - |
228 | | -### handleJsonRpcMessageSubscribe |
229 | | - |
230 | | -```ts |
231 | | -import { getWsProvider } from 'polkadot-api/ws-provider'; |
232 | | - |
233 | | -const provider = getWsProvider('wss://rpc.polkadot.io'); |
234 | | -container.handleJsonRpcMessageSubscribe( |
235 | | - { genesisHash: '0x...' }, |
236 | | - provider |
237 | | -); |
238 | | -``` |
239 | | - |
240 | | -### isReady |
| 38 | +} |
241 | 39 |
|
242 | | -```ts |
243 | | -const ready = await container.isReady(); |
244 | | -if (ready) { |
245 | | - console.log('Container is ready'); |
| 40 | +// Resolve a specific account's on-chain identity. |
| 41 | +const identity = await accounts.getConsumerInfo('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'); |
| 42 | +if (identity.isOk() && identity.value) { |
| 43 | + console.log(identity.value.fullUsername, identity.value.credibility); |
246 | 44 | } |
247 | 45 | ``` |
248 | 46 |
|
249 | | -### dispose |
| 47 | +### Networks |
250 | 48 |
|
251 | | -```ts |
252 | | -container.dispose(); |
253 | | -``` |
| 49 | +`createAccountService` accepts one of: |
254 | 50 |
|
255 | | -### subscribeConnectionStatus |
| 51 | +| Network | People chain endpoint | |
| 52 | +| ---------------- | -------------------------------------- | |
| 53 | +| `stable` | Polkadot People | |
| 54 | +| `preview` | Westend People | |
| 55 | +| `paseo-next` | Paseo People (V1) | |
| 56 | +| `paseo-next-v2` | Paseo People (V2 multi-device) | |
256 | 57 |
|
257 | | -```ts |
258 | | -const unsubscribe = container.subscribeConnectionStatus((status) => { |
259 | | - console.log('Connection status:', status); |
260 | | -}); |
261 | | -``` |
| 58 | +Each network entry pins both the People chain WebSocket URL (used via `lazyClient`) and |
| 59 | +the off-chain identity-backend REST endpoint that `search` queries. |
262 | 60 |
|
263 | | -## PAPI provider support |
| 61 | +## API |
264 | 62 |
|
265 | | -Host container supports [PAPI](https://papi.how/) request redirection from product to host container. |
266 | | -It can be useful to deduplicate socket connections or light client instances between multiple dapps. |
| 63 | +### `createAccountService(network, lazyClient)` |
267 | 64 |
|
268 | | -To support this feature, you should add two additional handlers to the container: |
| 65 | +Returns an object with two methods: |
269 | 66 |
|
270 | | -### Chain support check |
271 | | -```ts |
272 | | -const genesisHash = '0x...'; |
| 67 | +- **`search(query, status)`** — query the off-chain username index. `status` is |
| 68 | + `'ASSIGNED' | 'PENDING'`. Resolves to a list of `{ candidateAccountId, username, status, |
| 69 | + onchainData, createdAt, updatedAt }` rows. |
| 70 | +- **`getConsumerInfo(address)`** — resolve a single SS58 address to an `Identity` |
| 71 | + (`{ accountId, fullUsername, liteUsername, credibility }`) by reading |
| 72 | + `Resources.Consumers` from the People chain. Returns `null` if the account has no |
| 73 | + consumer entry. Tolerates both snake_case (V1) and camelCase (V2) runtime field names. |
273 | 74 |
|
274 | | -container.handleFeature(async (feature) => { |
275 | | - return feature.tag === 'Chain' && feature.value === genesisHash; |
276 | | -}); |
277 | | -``` |
| 75 | +Both methods return `ResultAsync<…, Error>`; call `.isOk()` / `.isErr()` to discriminate. |
| 76 | + |
| 77 | +## Codec subpath exports |
278 | 78 |
|
279 | | -### Provider implementation |
| 79 | +The chat wire codecs are exposed under explicit subpaths so they can be tree-shaken |
| 80 | +independently of the main entry point: |
280 | 81 |
|
281 | 82 | ```ts |
282 | | -import { getWsProvider } from 'polkadot-api/ws-provider'; |
| 83 | +import { |
| 84 | + ChatMessage, |
| 85 | + TextContent, |
| 86 | + RichTextContent, |
| 87 | + ChatAcceptedContent, |
| 88 | + DeviceAddedContent, |
| 89 | + DeviceRemovedContent, |
| 90 | +} from '@novasamatech/host-chat/codec/message'; |
283 | 91 |
|
284 | | -const genesisHash = '0x...'; |
285 | | -const provider = getWsProvider('wss://...'); |
| 92 | +import { |
| 93 | + FileMeta, |
| 94 | + FileVariant, |
| 95 | + P2PMixnetFile, |
| 96 | +} from '@novasamatech/host-chat/codec/attachment'; |
286 | 97 |
|
287 | | -container.connectToPapiProvider(genesisHash, provider); |
| 98 | +import type { ChatSession } from '@novasamatech/host-chat/session'; |
288 | 99 | ``` |
289 | 100 |
|
290 | | -## Known pitfalls |
291 | | - |
292 | | -### CSP error on iframe loading |
293 | | -If a dapp is hosted on a different domain than the container and uses HTTPS, you should add this meta tag to your host application HTML: |
294 | | - |
295 | | -```html |
296 | | -<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests"> |
297 | | -``` |
| 101 | +These are byte-compatible with the Android / iOS Polkadot Mobile clients — modify with |
| 102 | +care, the indices are pinned by the protocol. |
0 commit comments