@novasamatech/host-container
v0.9.4
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
The derivation index is an Enum (RFC 0022): Index
carries a plain index, Raw a raw 32-byte index. Expand it with
derivationIndexBytes — past this boundary only
the 32-byte form exists.
import { derivationIndexBytes } from '@novasamatech/host-container';
container.handleAccountGet(async ([dotnsId, derivationIndex], { ok, err }) => {
// `//product//{dotnsId}/{index}` — hard, hard, soft junctions.
const account = await getProductAccount(dotnsId, derivationIndexBytes(derivationIndex));
if (account) {
return ok({ publicKey: account.publicKey });
}
return err({ tag: 'NotConnected' });
});handleAccountRegisterRingVrfKey
A product registers a ring VRF key it owns against the ring it intends it for
(RFC-0024). Ownership is the calling product id and is never a parameter, so this
needs no capability gate and no prompt. Registration is idempotent — registering
an already-registered index for an additional ring extends that entry rather
than creating a second one — and it declares intent, not membership, so it must
never be treated as a personhood oracle.
Registration always reaches the Account Holder, which is the authoritative registry, but need not block on it: with the product's ring VRF domain entropy the host can answer immediately and mirror the registration fire-and-forget.
A host MUST NOT derive a member secret for a
(product, index)pair absent from its registry. Domain entropy makes derivation unconditional arithmetic — only registration brings a key into existence.
container.handleAccountRegisterRingVrfKey(async ([index, ring], { ok, err }) => {
if (!isConnected()) {
return err(new RegisterRingVrfKeyErr.NotConnected());
}
if (!(await isKnownRing(ring))) {
return err(new RegisterRingVrfKeyErr.RingNotFound());
}
// `productId` comes from the container's own context — never from the caller.
return ok(await registry.register(productId, index, ring));
});On a successful registration, match ring against the well-known ring table
(People, People-Lite) by structural equality and record the handle as the
corresponding person key — that is what replaces a compiled-in key selection for
the host's own personhood-dependent features. If two products register for the
same well-known ring, do not pick silently: resolve to the product the user
designated as their personhood provider (defaulting to the first registrar), so a
second product cannot displace the first.
handleAccountListRingVrfKeys
Answer from the registry snapshot when it is current. Listing the caller's own
keys is permissionless; a foreign owner needs a grant or a prompt, and
'PublicKey' disclosure is separately permissioned because a member public key
is linkable across every ring it appears in. Omit publicKey under
'Anonymized'.
container.handleAccountListRingVrfKeys(async ([owner, disclosure], { ok, err }) => {
if (owner !== productId && !(await hasGrantFor(productId, owner))) {
return err(new ListRingVrfKeysErr.Rejected());
}
const entries = await registry.list(owner);
return ok(
entries.map(entry => ({
handle: entry.handle,
rings: entry.rings,
publicKey: disclosure === 'PublicKey' ? entry.publicKey : undefined,
})),
);
});handleAccountGetAlias
keyHandle names the ring VRF key explicitly (RFC-0024) — the host no longer
defines a PoP collection, infers correspondence, or falls back to a compiled-in
key. context is [productId, suffix], where suffix is the same selector as
an account's derivation index and expands to the same 32-byte value (RFC 0022),
so the alias ↔ account mapping is the identity on it.
ring stays a separate argument because a key may be registered for several;
verify it appears among the handle's declared rings and return KeyNotInRing
otherwise, and KeyNotRegistered when the handle has no entry at all.
container.handleAccountGetAlias(async ([keyHandle, context, ring], { ok, err }) => {
const entry = await registry.lookup(keyHandle);
if (!entry) {
return err(new GetAliasErr.KeyNotRegistered());
}
if (!entry.rings.some(declared => sameRing(declared, ring))) {
return err(new GetAliasErr.KeyNotInRing());
}
const alias = await getContextualAlias(entry, context, ring);
if (alias) {
return ok({ context: alias.context, alias: alias.alias });
}
return err(new GetAliasErr.RingNotFound());
});Reading an alias authorizes nothing, so a foreign keyHandle here is governed by
the ordinary grant-or-prompt model — unlike handleAccountCreateProof below.
handleAccountCreateProof
Same handle checks as handleAccountGetAlias, plus the allowlist gate. A proof is
a bearer token for its context's alias, and message is opaque — for an
extrinsic it is a hash of the inherited implication — so nothing at call time can
tell what the result will authorize. A host MUST therefore reject a foreign
keyHandle unless the key's owning product allowlisted the caller in its
manifest, and MUST NOT offer a user prompt as a fallback: consenting to an opaque
message is not meaningful consent, and only the key's owner is positioned to
evaluate the risk.
container.handleAccountCreateProof(async ([keyHandle, context, ring, message], { ok, err }) => {
const [owner] = keyHandle;
const entry = await registry.lookup(keyHandle);
if (!entry) {
return err(new CreateProofErr.KeyNotRegistered());
}
// The owner's manifest allowlist is the ONLY authorization here — no prompt.
if (owner !== productId && !(await ownerAllowlists(owner, productId))) {
return err(new CreateProofErr.NotAllowlisted());
}
if (!entry.rings.some(declared => sameRing(declared, ring))) {
return err(new CreateProofErr.KeyNotInRing());
}
if (!(await isMemberOfRing(entry, ring))) {
return err(new CreateProofErr.NotMember());
}
const { proof, contextualAlias, ringIndex, ringRevision } = await createRingProof(entry, context, ring, message);
return ok({ proof, contextualAlias, ringIndex, ringRevision });
});handleAccountRingVrfSign
Signs with the member key itself instead of producing an anonymous ring proof (RFC-0024). It carries no context and no ring, so there is nothing to scope what the signature is good for — it is the wider version of the bearer-token problem above, gated by the same allowlist with the same no-prompt rule. The result is verified against the member public key and is linkable to every other use of that key.
container.handleAccountRingVrfSign(async ([keyHandle, message], { ok, err }) => {
if (!isConnected()) {
return err(new RingVrfSignErr.NotConnected());
}
const [owner] = keyHandle;
if (!(await registry.lookup(keyHandle))) {
return err(new RingVrfSignErr.KeyNotRegistered());
}
if (owner !== productId && !(await ownerAllowlists(owner, productId))) {
return err(new RingVrfSignErr.NotAllowlisted());
}
return ok(await signWithMemberKey(keyHandle, message));
});handleAccountSignVrf
Produces an sr25519 (schnorrkel) VRF signature over a transcript the product supplies as a
recipe (RFC-0023). Replay it verbatim — no interpretation of labels or values — so one
method serves any consuming runtime. Authorize it exactly like handleSignRaw: reject with
NotConnected when there is no session (never auto-prompt login), sign locally when
AutoSigning covers the account, otherwise ask the user and return Rejected on decline.
Bound items.length and the total transcript size against a hostile caller.
container.handleAccountSignVrf(async ({ account, transcriptLabel, items }, { ok, err }) => {
if (!isConnected()) {
return err(new SignVrfErr.NotConnected());
}
if (!(await confirmVrfSigning(account))) {
return err(new SignVrfErr.Rejected());
}
const transcript = newTranscript(transcriptLabel);
for (const item of items) {
transcript.appendMessage(item.label, item.value);
}
const { preOutput, proof } = await vrfSign(account, transcript);
return ok({ preOutput, proof });
});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, derivationIndexBytes(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') {
// Account of the calling product, addressed by the RFC-0022 selector.
await transferFromProductAccount(derivationIndexBytes(source.value), 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);
});Derivation index helpers
Product accounts live at //product//{productId}/{index} (RFC 0022): two hard
junctions and a soft one whose chain code is a 32-byte derivation index. The
wire selector — ProductAccountId's index, ProductProofContext's suffix, the
ProductAccount top-up source and SmartContractAllowance — carries either a
plain u32 or those 32 bytes directly; these helpers expand it.
import { INDEX_MAGIC, derivationIndexBytes, indexBytes } from '@novasamatech/host-container';
// blake2b256("product-account-index")[..28] — keeps the plain-index space and
// the raw-index space disjoint.
INDEX_MAGIC;
// u32 little-endian ++ INDEX_MAGIC. A product's default account is index 0.
indexBytes(0);
// Wire selector → the 32 bytes used as the soft junction's chain code.
derivationIndexBytes({ tag: 'Index', value: 5 }); // === indexBytes(5)
derivationIndexBytes({ tag: 'Raw', value: raw32 }); // === raw32Note that stock tooling (polkadot-js, subkey) cannot express this path: the
32-byte index is not a typeable path segment, so //product//browse.dot/5 there
does not derive index 5.
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">