@novasamatech/host-container
v0.7.8-2
Published
Host container for hosting and managing products within the Polkadot ecosystem.
Keywords
Readme
@novasamatech/host-container
A robust solution for hosting and managing decentralized applications (dapps) within the Polkadot ecosystem.
Overview
Host container provides the infrastructure layer for securely embedding and communicating with third-party dapps. It handles the isolation boundary, message routing, lifecycle management, and security concerns inherent in hosting untrusted web content.
Installation
npm install @novasamatech/host-container --save -EBasic Container Setup
iframe
import { createContainer, createIframeProvider } from '@novasamatech/host-container';
const iframe = document.createElement('iframe');
const provider = createIframeProvider({
iframe,
url: 'https://dapp.example.com'
});
const container = createContainer(provider);
document.body.appendChild(iframe);webview
import { createContainer, createWebviewProvider } from '@novasamatech/host-container';
const webview = document.createElement('webview');
const provider = createWebviewProvider({
webview,
openDevTools: false,
});
const container = createContainer(provider);
document.body.appendChild(webview);API reference
handleFeatureSupported
container.handleFeatureSupported((params, { ok, err }) => {
if (params.tag === 'Chat') {
return ok(supportedChains.has(params.value));
}
return ok(false);
});handleDevicePermission
The request parameter is one of: 'Notifications', 'Camera', 'Microphone', 'Bluetooth', 'NFC', 'Location', 'Clipboard', 'OpenUrl', 'Biometrics'.
container.handleDevicePermission(async (request, { ok, err }) => {
// request is a string literal: 'Notifications' | 'Camera' | 'Microphone' | ...
const granted = await promptDevicePermission(request);
return ok(granted);
});handlePermission
The request parameter is a single RemotePermission item. Return ok(true) when the permission is granted, ok(false) when denied.
The item has one of these shapes:
{ tag: 'Remote', value: string[] }— HTTP/WS domain patterns (exact or*.wildcard){ tag: 'WebRTC', value: undefined }— WebRTC access (may expose user IP){ tag: 'ChainSubmit', value: undefined }— broadcast transactions viaremote_chain_transaction_broadcast{ tag: 'PreimageSubmit', value: undefined }— submit preimages viaremote_preimage_submit{ tag: 'StatementSubmit', value: undefined }— submit statements viaremote_statement_store_submit
container.handlePermission(async (permission, { ok, err }) => {
switch (permission.tag) {
case 'Remote':
return ok(await checkDomainPermissions(permission.value));
case 'WebRTC':
return ok(await promptWebRTCPermission());
case 'ChainSubmit':
return ok(await promptChainSubmitPermission());
case 'PreimageSubmit':
return ok(await promptPreimageSubmitPermission());
case 'StatementSubmit':
return ok(await promptStatementSubmitPermission());
}
});handlePushNotification
Gated by the Notifications device permission: the container consults handleDevicePermission with 'Notifications' before invoking this handler. If the device permission is denied or errors, the handler is skipped and the request fails.
container.handlePushNotification(async (notification, { ok, err }) => {
await showNotification(notification);
return ok(undefined);
});handleNavigateTo
container.handleNavigateTo(async (url, { ok, err }) => {
await navigate(url);
return ok(undefined);
});handleDeriveEntropy
container.handleDeriveEntropy(async (key, { ok, err }) => {
const entropy = await deriveEntropy(key);
return ok(entropy);
});handleLocalStorageRead
container.handleLocalStorageRead(async (key, { ok, err }) => {
const value = await storage.get(key);
return ok(value ?? null);
});handleLocalStorageWrite
container.handleLocalStorageWrite(async ([key, value], { ok, err }) => {
try {
await storage.set(key, value);
return ok(undefined);
} catch (e) {
return err({ tag: 'Full' });
}
});handleLocalStorageClear
container.handleLocalStorageClear(async (key, { ok, err }) => {
await storage.delete(key);
return ok(undefined);
});handleAccountConnectionStatusSubscribe
container.handleAccountConnectionStatusSubscribe((_, send, interrupt) => {
const listener = (status) => send(status);
accountService.on('connectionStatusChange', listener);
return () => accountService.off('connectionStatusChange', listener);
});handleThemeSubscribe
container.handleThemeSubscribe((_, send, interrupt) => {
const listener = (theme: 'light' | 'dark') => send(theme);
themeService.on('change', listener);
send(themeService.getCurrentTheme());
return () => themeService.off('change', listener);
});handleGetUserId
Called when a product requests the user's primary DotNS username (RFC-0014). Show a disclosure prompt on first call; the host decides what counts as "primary" for the calling product. Return NotConnected without prompting if no user is connected; return PermissionDenied if the user denies disclosure.
import { GetUserIdErr } from '@novasamatech/host-api';
container.handleGetUserId(async (_, { ok, err }) => {
const username = await pickPrimaryUsernameForCallingProduct();
if (!username) {
return err(new GetUserIdErr.NotConnected());
}
const granted = await promptUserForUsernameDisclosure();
if (!granted) {
return err(new GetUserIdErr.PermissionDenied());
}
return ok({ primaryUsername: username });
});handleRequestLogin
Called when a product requests the host login UI. Present the sign-in flow and return the outcome. reason is an optional human-readable string the product provides to explain why login is needed.
import { LoginErr } from '@novasamatech/host-api';
container.handleRequestLogin(async (reason, { ok, err }) => {
const alreadyConnected = await checkIfConnected();
if (alreadyConnected) return ok('alreadyConnected');
const result = await presentLoginUI(reason);
if (!result.success) return ok('rejected');
return ok('success');
});handleAccountGet
container.handleAccountGet(async ([dotnsId, derivationIndex], { ok, err }) => {
const account = await getProductAccount(dotnsId, derivationIndex);
if (account) {
return ok({ publicKey: account.publicKey });
}
return err({ tag: 'NotConnected' });
});handleAccountGetAlias
container.handleAccountGetAlias(async ([dotnsId, derivationIndex], { ok, err }) => {
const alias = await getAccountAlias(dotnsId, derivationIndex);
if (alias) {
return ok({ context: alias.context, alias: alias.alias });
}
return err(new RequestCredentialsErr.NotConnected());
});handleAccountCreateProof
container.handleAccountCreateProof(async ([[dotnsId, derivationIndex], ringLocation, message], { ok, err }) => {
try {
const proof = await createRingProof(dotnsId, derivationIndex, ringLocation, message);
return ok(proof);
} catch (e) {
return err({ tag: 'RingNotFound' });
}
});handleGetLegacyAccounts
container.handleGetLegacyAccounts(async (_, { ok, err }) => {
const accounts = await getLegacyAccounts();
return ok(accounts);
});handleCreateTransaction
container.handleCreateTransaction(async ([productAccountId, payload], { ok, err }) => {
try {
const signedTx = await createTransaction(productAccountId, payload);
return ok(signedTx);
} catch (e) {
return err({ tag: 'Rejected' });
}
});handleCreateTransactionWithLegacyAccount
container.handleCreateTransactionWithLegacyAccount(async (payload, { ok, err }) => {
try {
const signedTx = await createTransactionWithLegacyAccount(payload);
return ok(signedTx);
} catch (e) {
return err({ tag: 'Rejected' });
}
});handleSignRaw
container.handleSignRaw(async (payload, { ok, err }) => {
try {
const result = await signRaw(payload);
return ok({ signature: result.signature, signedTransaction: result.signedTransaction });
} catch (e) {
return err({ tag: 'Rejected' });
}
});handleSignPayload
container.handleSignPayload(async (payload, { ok, err }) => {
try {
const result = await signPayload(payload);
return ok({ signature: result.signature, signedTransaction: result.signedTransaction ?? null });
} catch (e) {
return err({ tag: 'Rejected' });
}
});handleChatCreateRoom
container.handleChatCreateRoom(async (room, { ok, err }) => {
await chatService.registerRoom(room);
return ok(undefined);
});handleChatBotRegistration
container.handleChatBotRegistration(async (bot, { ok, err }) => {
await chatService.registerBot(bot);
return ok(undefined);
});handleChatListSubscribe
container.handleChatListSubscribe((_, send, interrupt) => {
const listener = (rooms) => send(rooms);
chatService.on('roomsUpdate', listener);
return () => chatService.off('roomsUpdate', listener);
});handleChatPostMessage
container.handleChatPostMessage(async (message, { ok, err }) => {
const messageId = await chatService.postMessage(message);
return ok({ messageId });
});handleChatActionSubscribe
container.handleChatActionSubscribe((_, send, interrupt) => {
const listener = (action) => send(action);
chatService.on('action', listener);
return () => chatService.off('action', listener);
});renderChatCustomMessage
const subscription = container.renderChatCustomMessage('my-custom-type', payload, (node) => {
// node is a CustomRendererNode tree describing the UI to render
console.log('Render custom message:', node);
});
// Unsubscribe when done
subscription.unsubscribe();handleStatementStoreSubscribe
container.handleStatementStoreSubscribe((filter, send, interrupt) => {
// filter is { tag: 'MatchAll', value: Uint8Array[] } | { tag: 'MatchAny', value: Uint8Array[] }
const listener = (page) => send(page);
statementStore.subscribe(filter, listener);
return () => statementStore.unsubscribe(filter, listener);
});handleStatementStoreCreateProof
container.handleStatementStoreCreateProof(async ([[dotnsId, derivationIndex], statement], { ok, err }) => {
try {
const proof = await createStatementProof(dotnsId, derivationIndex, statement);
return ok(proof);
} catch (e) {
return err({ tag: 'UnableToSign' });
}
});handleStatementStoreSubmit
container.handleStatementStoreSubmit(async (statement, { ok, err }) => {
try {
await statementStore.submit(statement);
return ok(undefined);
} catch (e) {
return err({ tag: 'Unknown', value: { reason: e.message } });
}
});handlePreimageLookupSubscribe
container.handlePreimageLookupSubscribe((key, send, interrupt) => {
const listener = (value) => send(value);
preimageService.subscribe(key, listener);
return () => preimageService.unsubscribe(key, listener);
});handlePreimageSubmit
container.handlePreimageSubmit(async (preimage, { ok, err }) => {
try {
const key = await preimageService.submit(preimage);
return ok(key);
} catch (e) {
return err({ tag: 'Unknown', value: { reason: e.message } });
}
});handlePaymentBalanceSubscribe
Called when a product subscribes to balance updates. Host should prompt for user consent on the first call; interrupt the subscription to communicate denial.
container.handlePaymentBalanceSubscribe((_params, send, interrupt) => {
const unsubscribe = balanceService.subscribe(balance => {
send({ available: balance.available, pending: balance.pending });
});
return () => unsubscribe();
});handlePaymentTopUp
Called when a product requests a balance top-up from a product-controlled source. Does not require user consent.
container.handlePaymentTopUp(async ({ amount, source }, { ok, err }) => {
if (source.tag === 'ProductAccount') {
const [dotNsIdentifier, derivationIndex] = source.value;
await transferFromProductAccount(dotNsIdentifier, derivationIndex, amount);
return ok(undefined);
}
if (source.tag === 'PrivateKey') {
await transferFromPrivateKey(source.value, amount);
return ok(undefined);
}
return err(new PaymentTopUpErr.InvalidSource());
});handlePaymentRequest
Called when a product requests a payment from the user's balance. Host MUST show a confirmation UI. Returns a receipt immediately; settlement is asynchronous.
container.handlePaymentRequest(async ({ amount, destination }, { ok, err }) => {
const approved = await showPaymentConfirmation({ amount, destination });
if (!approved) return err(new PaymentRequestErr.Denied());
const paymentId = await paymentService.submit(amount, destination);
return ok({ id: paymentId });
});handlePaymentStatusSubscribe
Called when a product subscribes to the status of a previously requested payment.
container.handlePaymentStatusSubscribe((paymentId, send, interrupt) => {
const unsubscribe = paymentService.trackStatus(paymentId, status => {
if (status === 'processing') send({ tag: 'Processing', value: undefined });
if (status === 'completed') send({ tag: 'Completed', value: undefined });
if (status === 'failed') send({ tag: 'Failed', value: 'settlement failed' });
});
return () => unsubscribe();
});handleChainConnection
import { getWsProvider } from 'polkadot-api/ws-provider';
const chains = new Map([
['0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3', 'wss://rpc.polkadot.io'],
['0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe', 'wss://kusama-rpc.polkadot.io'],
]);
container.handleChainConnection({
factory(genesisHash) {
const endpoint = chains.get(genesisHash);
if (!endpoint) return null;
return getWsProvider(endpoint);
}
});isReady
const ready = await container.isReady();
if (ready) {
console.log('Container is ready');
}dispose
container.dispose();subscribeProductConnectionStatus
const unsubscribe = container.subscribeProductConnectionStatus((status) => {
console.log('Connection status:', status);
});Known pitfalls
CSP error on iframe loading
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:
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">