UBNet Browser SDK
The browser SDK is loaded from /ubnet.js. The loader retrieves the tracked runtime modules under /ubnet/ and exposes window.UBNet.
2.2.0
SDK version: 3.1.0
Docs version: 3.1.0
Current publishing model: top-level posts require explicit categories at creation. The SDK does not infer or default a category. Edit calls can change text and visibility, but cannot replace categories, media, or thumbnails.
Code alignment: this page mirrors the public modules loaded by /ubnet.js: core.js, transport.js, events.js, posts.js, lists.js, profiles.js, uploads.js, chats.js, nfts.js, reservedSubdomains.js, domainRegistration.js, support.js, and eventQueries.js.
Load and create a client
<script src="https://my.eth-ub.net/ubnet.js"></script>
<script>
const ubnet = window.UBNet.createClient({
apiBase: "https://my.eth-ub.net",
walletProvider: window.ethereum
});
</script>
apiBase may be an absolute HTTP(S) URL, a same-origin path, or empty for the current origin. Cross-origin sites still require backend CORS permission.
Global SDK
| Call | Use |
|---|---|
window.UBNet.createClient(config) | Create a client. Config supports apiBase/apiBaseUrl/baseUrl, walletProvider, custom fetch, and spasmSubmitPath/submitPath. |
window.UBNet.configure(options) | Persist compatible global runtime settings in browser storage. |
window.UBNet.UbnetError | SDK error class with status, code, and payload. |
window.UBNet.version | SDK compatibility version. |
window.UBNet.versions | { api, sdk, docs } compatibility versions. |
Client object
{
apiBase,
api,
cache,
posts,
lists,
profiles,
uploads,
chats,
nfts,
reservedSubdomains,
domainRegistration,
support,
events,
configure,
version,
versions
}
Low-level transport and cache
| Call | Use |
|---|---|
ubnet.api.get(path, settings) | GET through the configured API base. |
ubnet.api.post(path, body, settings) | JSON POST through the configured API base. |
ubnet.api.request(path, init, settings) | Raw request wrapper with UbnetError handling and optional cache policy. |
ubnet.cache.remember(key, load, options) | Load or reuse a cached value. |
ubnet.cache.invalidate(key) | Remove one entry. |
ubnet.cache.invalidatePrefix(prefix) | Remove matching entries. |
ubnet.cache.invalidateTag(tag) | Remove tagged entries. |
ubnet.cache.clear(options) | Clear all entries or selected tags. |
ubnet.cache.stats() | Read fresh, stale, pending, and maximum-entry counts. |
Posts
| Call | API / action | Use |
|---|---|---|
ubnet.posts.list(filters) | GET /api/posts/feed | Read the public Feed. Server allow lists decide Feed inclusion. |
ubnet.posts.get(id, opts) | GET /api/posts/:id | Read post detail. |
ubnet.posts.replies(id, opts) | GET /api/posts/:id/replies | Read cursor-paged replies. |
ubnet.posts.mine(wallet, opts) | GET /api/posts/wallet/:wallet | Read wallet/owner posts. |
ubnet.posts.create(wallet, input) | Signed post | Create a top-level post with content, optional media, and explicit categories. |
ubnet.posts.edit(wallet, postId, input) | Signed post edit | Edit content or visibility through edit_of. Categories, media, and thumbnails cannot be changed. |
ubnet.posts.update(wallet, postId, input) | Alias of edit | Update a post. |
ubnet.posts.reply(wallet, targetId, content, opts) | Signed reply | Reply to a post. |
ubnet.posts.react(wallet, targetId, type, opts) | Signed react | Submit like, upvote, or downvote. |
ubnet.posts.publication(wallet, targetId, state) | Alias via edit | Set published or private state. |
ubnet.posts.withdraw(wallet, targetId) | Alias of remove | Delete a post. |
ubnet.posts.remove(wallet, targetId) | Signed delete | Delete one post. |
ubnet.posts.deleteThread(wallet, targetId) | Signed delete-thread | Delete the requesting author's owned parts of a thread. |
Validated category input
function normalizePostCategory(raw) {
const clean = String(raw || "").trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9_-]{0,119}$/.test(clean)) {
throw new Error("Choose a category using lowercase letters, numbers, dashes, or underscores.");
}
return { name: clean };
}
async function createTextPost(ubnet, wallet, form) {
const category = normalizePostCategory(form.category.value);
const content = form.content.value.trim();
if (!content) throw new Error("Write some content before posting.");
return ubnet.posts.create(wallet, {
categories: [category],
content,
publicationStatus: form.visibility.value === "private" ? "offline" : "published"
});
}
No category fallback exists. Omitting categories throws post_categories_required. Post edits reject category changes with post_categories_immutable.
Post with URL or CID media
await ubnet.posts.create(wallet, {
categories: [{ name: "project-update" }],
content: "Read the full article.",
mediaValue: "https://example.com/article"
});
await ubnet.posts.create(wallet, {
categories: [{ name: "audio" }],
content: "Listen to the full recording.",
mediaValue: resolved.cid,
thumbnailUrl: customThumbnail,
cidMetadata: resolved
});
cidMetadata should identify audio, video, or image content. An explicit thumbnailUrl overrides a thumbnail included in the CID metadata. Post edits accept content and visibility only.
Lists
| Call | API / action | Use |
|---|---|---|
ubnet.lists.list(filters) | GET /api/collections | Read the public list index. |
ubnet.lists.get(id, opts) | GET /api/collections/:id | Read list metadata. |
ubnet.lists.items(id, opts) | GET /api/collections/:id/items | Read list items. |
ubnet.lists.item(id, itemId, opts) | GET /api/collections/:id/items/:itemId | Read one list item. |
ubnet.lists.mine(wallet, opts) | GET /api/collections/owner/:wallet | Read owner lists. |
ubnet.lists.publicForWallet(wallet, opts) | GET /api/collections/wallet/:wallet | Read public lists for a wallet. |
ubnet.lists.create(wallet, input) | Signed list | Create a list. |
ubnet.lists.update(wallet, listId, input) | Signed list edit | Update list metadata. |
ubnet.lists.addItem(wallet, listId, input) | Signed ubnet-collection-item | Add an audio, video, image, link, or post item. |
ubnet.lists.updateItem(wallet, listId, itemId, input) | Signed item edit | Edit an item owned by the signer. CID identity cannot be changed. |
ubnet.lists.removeItem(wallet, listId, targetId) | Signed remove | Remove an item. |
ubnet.lists.addPost(wallet, listId, postId) | Alias of addItem | Add a post item. |
ubnet.lists.removePost(wallet, listId, postId) | Alias of removeItem | Remove a post item. |
ubnet.lists.react(wallet, targetId, type, opts) | Delegates to post reactions | React to a list or list-item target. |
ubnet.lists.reply(wallet, targetId, content, opts) | Delegates to post replies | Comment on a list or list item. |
ubnet.lists.remove(wallet, targetId) | Signed delete | Delete a list. |
ubnet.lists.unlockChallenge(listId, wallet) | POST /api/collections/:id/unlock-challenge | Request the message the wallet must sign before unlocking a token-gated list. |
ubnet.lists.unlock(listId, wallet, signature, message) | POST /api/collections/:id/unlock | Unlock a token-gated list with the signed challenge. |
ubnet.lists.unlockItemMediaChallenge(listId, itemId, wallet) | POST /api/collections/:id/items/:itemId/unlock-challenge | Request the message the wallet must sign before unlocking item media. |
ubnet.lists.unlockItemMedia(listId, itemId, wallet, signature, message) | POST /api/collections/:id/items/:itemId/unlock | Unlock token-gated item media with the signed challenge. |
Cursor pagination and infinite scrolling
Treat every next_cursor as opaque. Do not decode, modify, combine, or reuse it with another endpoint, wallet, chat, search term, visibility filter, or post type.
Reset the cursor, duplicate set, finished state, and rendered items whenever the query changes. Keep only one request in flight for a cursor, and stop if the API returns no cursor, repeats the requested cursor, or produces no new item IDs.
const postPager = {
cursor: null,
loading: false,
finished: false,
seen: new Set(),
controller: null
};
function postId(post) {
return String(post.root_id || post.latest_id || post.spasm_id || "");
}
async function loadMorePosts(filters = {}) {
if (postPager.loading || postPager.finished) return [];
postPager.loading = true;
const requestedCursor = postPager.cursor;
const controller = new AbortController();
postPager.controller = controller;
try {
const page = await ubnet.api.request(
"/api/posts/feed",
{ signal: controller.signal },
{ query: { ...filters, limit: 10, cursor: requestedCursor } }
);
const added = [];
for (const post of page.feed || []) {
const id = postId(post);
if (!id || postPager.seen.has(id)) continue;
postPager.seen.add(id);
added.push(post);
}
const nextCursor = page.next_cursor || null;
const madeProgress = added.length > 0 && nextCursor !== requestedCursor;
postPager.finished = !madeProgress || !nextCursor || page.has_more === false;
postPager.cursor = postPager.finished ? null : nextCursor;
return added;
} catch (error) {
if (error?.name === "AbortError") return [];
throw error;
} finally {
if (postPager.controller === controller) postPager.controller = null;
postPager.loading = false;
}
}
IntersectionObserver with manual fallback
const sentinel = document.querySelector("#posts-sentinel");
const loadButton = document.querySelector("#load-more-posts");
async function appendNextPostPage() {
const posts = await loadMorePosts({ keyword: searchInput.value });
renderPosts(posts);
loadButton.disabled = postPager.loading || postPager.finished;
}
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
appendNextPostPage().catch(showError);
}
}, { rootMargin: "520px 0px" });
if (sentinel) observer.observe(sentinel);
loadButton?.addEventListener("click", () => {
appendNextPostPage().catch(showError);
});
Keep the button visible for retry and keyboard accessibility even when automatic loading is enabled.
Profiles and follows
| Call | API / action | Use |
|---|---|---|
ubnet.profiles.get(wallet, opts) | GET /api/users/:wallet | Read profile fields, social links, aggregate counts, and messaging metadata. |
ubnet.profiles.search(keyword, limit) | GET /api/users/search | Search profiles. |
ubnet.profiles.followers(wallet, opts) | GET /api/users/:wallet/followers | Read followers. |
ubnet.profiles.following(wallet, opts) | GET /api/users/:wallet/following | Read following. |
ubnet.profiles.followExport(wallet) | GET /api/users/:wallet/follow-export | Export follower/following data. |
ubnet.profiles.uploadAvatar(wallet, file, options) | Challenge + signed upload | Upload an avatar with wallet challenge signing. |
ubnet.profiles.uploadBanner(wallet, file, options) | Challenge + signed upload | Upload a banner with wallet challenge signing. |
ubnet.profiles.save(wallet, input) | Signed profile-update | Save profile fields. |
ubnet.profiles.update(wallet, input) | Alias of save | Update profile fields. |
ubnet.profiles.saveSocialLink(wallet, input) | Signed profile-social-link | Add or update a social link. |
ubnet.profiles.deleteSocialLink(wallet, link) | Signed profile-social-link-delete | Delete a social link. |
ubnet.profiles.follow(wallet, targetWallet) | Signed follow | Follow a wallet. |
ubnet.profiles.unfollow(wallet, targetWallet) | Signed unfollow | Unfollow a wallet. |
Follow button must wait for confirmed state
async function loadConfirmedFollowingSet(viewerWallet) {
let cursor = null;
const followed = new Set();
for (let page = 0; page < 10; page += 1) {
const result = await ubnet.profiles.following(viewerWallet, { limit: 200, cursor });
for (const row of result.following || []) {
if (row.following_wallet) followed.add(row.following_wallet.toLowerCase());
}
cursor = result.next_cursor || null;
if (!cursor || result.has_more === false) {
return { confirmed: true, followed };
}
}
return { confirmed: false, followed };
}
function followButtonState(targetWallet, confirmedState) {
const target = targetWallet.toLowerCase();
if (confirmedState.followed.has(target)) return "following";
if (!confirmedState.confirmed) return "checking";
return "not-following";
}
// Only show Follow when state === "not-following".
// Use a disabled Checking button or hide the control while state === "checking".
Named profiles
await ubnet.profiles.save(wallet, {
profile_name: "business-profile",
display_name: "Acme Corporation",
bio: "Business profile"
});
const business = await ubnet.profiles.get(wallet, {
profile: "business-profile"
});
Profile names are normalized to lowercase, must start with a letter or number, may contain lowercase letters, numbers, dashes, and underscores, and may be at most 64 characters.
Uploads
| Call | API / action | Use |
|---|---|---|
ubnet.uploads.avatar(wallet, file) | POST /api/uploads/avatar/:wallet | Raw low-level call without challenge headers. Prefer ubnet.profiles.uploadAvatar(). |
ubnet.uploads.pinataPresignedUrl(data) | POST /api/uploads/pinata/presigned-url | Open public Pinata URL route. Current backend intentionally returns a disabled error. |
ubnet.uploads.pinata(data) | Public presigned URL + upload helper | Convenience helper; currently fails while open public Pinata uploads remain disabled. |
ubnet.uploads.tokenGatedAccess(data) | POST /api/uploads/pinata/token-gated-access | Check token-gate access. |
ubnet.uploads.tokenGatedChallenge(data) | POST /api/uploads/pinata/token-gated-challenge | Create a wallet challenge. |
ubnet.uploads.tokenGatedPresignedUrl(data) | POST /api/uploads/pinata/token-gated-presigned-url | Create a signed upload URL after wallet and token checks. |
ubnet.uploads.tokenGatedPresignedUrlForTarget(target, data) | Wrapper | Add a configured target to the presigned URL request. |
ubnet.uploads.pinataSignedUrl(uploadUrl, file) | Direct Pinata signed-URL upload | Upload a non-empty file with FormData. |
ubnet.uploads.tokenGatedPinata(data) | Presigned URL + upload helper | Create a token-gated URL and upload data.file. |
ubnet.uploads.tokenGatedPinataForTarget(target, data) | Wrapper | Upload to a configured target. |
Chats and messaging v2
Read this before integrating messaging: the public browser SDK is a signing and transport layer. It reads messaging metadata, signs messaging-key events containing a public key and optional protected recovery data, signs message events, and submits already encrypted payloads. It does not generate private keys, store private keys, restore recovery data, encrypt plaintext, decrypt ciphertext, or accept plaintext message payloads.
Never pass plaintext to ubnet.chats.send(). The only valid call is ubnet.chats.send(wallet, encryptedPayload), where encryptedPayload already follows the v2 contract shown below.
| Layer | Responsibilities |
|---|---|
window.UBNet | Fetch public messaging profiles and protected recovery data; calculate direct chat IDs; publish public keys with optional protected recovery data; create group metadata; sign and submit encrypted message events; hide chats; leave groups; disable messaging; clear the local SDK-managed key handle. |
| Your crypto layer | Generate a P-256 ECDH key pair; persist the private key as a non-extractable CryptoKey in IndexedDB; unlock it; create and restore protected recovery data; derive AES keys; encrypt and decrypt direct messages; create, wrap, unwrap, and use group keys. |
| UBNet server/indexer | Store public keys, protected recovery data, chat metadata, encrypted message envelopes, IVs, and wrapped group keys. It does not receive plaintext or the private messaging key. |
The UBNet web client implements its crypto layer in frontend/src/services/crypto.ts, with usage references in frontend/src/components/MessagingKeyControls.tsx, frontend/src/services/messagingKeySync.ts, and frontend/src/pages/ChatPage.tsx. The helper names below must come from your compatible crypto module; they are not methods on window.UBNet.
Wait until messaging is indexed
async function waitForIndexedMessagingKey(
ubnet,
wallet,
expectedPublicKey,
{ timeoutMs = 60000, intervalMs = 1000, requireBackup = true } = {}
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
const profile = await ubnet.chats.messagingProfile(wallet);
if ((profile.encryption_public_key || "") !== expectedPublicKey) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
continue;
}
if (!requireBackup) return;
const backup = await ubnet.chats.messagingKeyBackup(wallet);
if ((backup.messaging_key_backup?.public_key || "") === expectedPublicKey) return;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error("Messaging key event was accepted but not indexed in time.");
}
One-time setup: enable messaging
// These helper names match frontend/src/services/crypto.ts.
const publicKey = await generateMessagingKeyPair(wallet);
const recoveryBackup = await createWalletRecoveryMessagingKeyBackup(wallet, window.ethereum);
await ubnet.chats.enableMessaging(wallet, {
encryption_public_key: publicKey,
messaging_key_backup: recoveryBackup
});
await waitForIndexedMessagingKey(ubnet, wallet, publicKey);
clearPendingMessagingKeyMaterial(wallet);
Do not send the private JWK to UBNet. enableMessaging() receives only the public key and already protected recovery data.
Restore and unlock on another browser
const backupResponse = await ubnet.chats.messagingKeyBackup(wallet);
const backup = backupResponse.messaging_key_backup;
if (!backup) throw new Error("No messaging recovery backup exists.");
await restoreMessagingKeyFromWalletRecoveryBackup(wallet, backup, window.ethereum);
if (!await hasLocalMessagingPrivateKey(wallet)) {
throw new Error("Restore or replace the messaging key on this browser.");
}
if (!isMessagingUnlocked(wallet)) {
await unlockMessagingKey(wallet, window.ethereum);
}
Send a direct encrypted message
const recipientWallet = "0x...";
const { chat_id } = await ubnet.chats.directId(wallet, recipientWallet);
const recipient = await ubnet.chats.messagingProfile(recipientWallet);
if (!recipient.messaging_enabled || !recipient.encryption_public_key) {
throw new Error("Recipient has not enabled encrypted messaging.");
}
const senderPublicKey = await getLocalMessagingPublicKey(wallet);
const encrypted = await encryptDmMessage(
wallet,
recipient.encryption_public_key,
"Hello"
);
await ubnet.chats.send(wallet, {
messaging_protocol: "ubnet-e2ee-v2",
is_group: false,
chat_id,
participants: [wallet, recipientWallet],
sender_public_key: senderPublicKey,
recipient_public_key: recipient.encryption_public_key,
ciphertext: encrypted.ciphertext,
iv: encrypted.iv,
encoding: "aes-gcm-v2",
encrypted: true
});
Create a group and send a group message
const participants = [wallet, "0x...", "0x..."];
const chatId = `group:${crypto.randomUUID()}`;
await ubnet.chats.createGroup(wallet, {
chat_id: chatId,
title: "Project group",
participants
});
const recipients = await Promise.all(participants.map(async (participantWallet) => {
if (participantWallet.toLowerCase() === wallet.toLowerCase()) {
return { wallet: participantWallet, publicKey: await getLocalMessagingPublicKey(wallet) };
}
const profile = await ubnet.chats.messagingProfile(participantWallet);
return { wallet: participantWallet, publicKey: profile.encryption_public_key };
}));
if (recipients.some((recipient) => !recipient.publicKey)) {
throw new Error("Every group member must enable encrypted messaging.");
}
const senderPublicKey = await getLocalMessagingPublicKey(wallet);
const encrypted = await encryptGroupMessage(wallet, recipients, "Hello group");
await ubnet.chats.send(wallet, {
messaging_protocol: "ubnet-e2ee-v2",
is_group: true,
chat_id: chatId,
participants,
sender_public_key: senderPublicKey,
ciphertext: encrypted.ciphertext,
iv: encrypted.iv,
encoding: "group-aes-gcm-v2",
encrypted_keys: encrypted.encrypted_keys,
encrypted: true
});
Encrypt the group AES key separately for every intended reader, including the sender. Each encrypted_keys entry must contain wallet, ciphertext, iv, and encoding: "aes-key-wrap-ecdh-v2".
Read and decrypt messages
const page = await ubnet.chats.messages(chatId, wallet, { limit: 50, cursor: null });
for (const message of page.messages) {
if (message.encoding === "aes-gcm-v2") {
const otherPublicKey =
message.sender_wallet.toLowerCase() === wallet.toLowerCase()
? message.recipient_public_key
: message.sender_public_key;
const plaintext = await decryptDmMessage(wallet, otherPublicKey, message.ciphertext, message.iv);
}
if (message.encoding === "group-aes-gcm-v2") {
const plaintext = await decryptGroupMessage(
wallet,
message.sender_public_key,
message.encrypted_keys,
message.ciphertext,
message.iv
);
}
}
Messaging calls
| Call | API / action | Use |
|---|---|---|
ubnet.chats.list(wallet, opts) | GET /api/messages/chats/:wallet | Read cursor-paged chats. |
ubnet.chats.get(chatId, wallet, opts) | GET /api/messages/chat/:chat_id | Read chat metadata for a participant. |
ubnet.chats.messages(chatId, wallet, opts) | GET /api/messages/chats/:chat_id/messages | Read cursor-paged encrypted messages. |
ubnet.chats.state(chatId, wallet, opts) | GET /api/messages/chat/:chat_id/state | Read participant and visibility state. |
ubnet.chats.activity(wallet, opts) | GET /api/messages/activity | Read incoming activity after a timestamp and message ID. |
ubnet.chats.directId(wallet, targetWallet) | Local helper | Return { chat_id, wallet, target_wallet } for a direct conversation. |
ubnet.chats.messagingProfile(wallet, opts) | GET /api/messages/profile/:wallet | Read messaging_enabled, the public key, and backup availability metadata. |
ubnet.chats.messagingKeyBackup(wallet, opts) | GET /api/messages/key-backup/:wallet | Fetch the encrypted recovery backup. This does not restore or decrypt it. |
ubnet.chats.createGroup(wallet, input) | Signed group-chat-create | Create group metadata. |
ubnet.chats.publishMessagingKey(wallet, input) | Signed messaging-key | Publish an already generated v2 public key, with protected recovery data when supplied. |
ubnet.chats.enableMessaging(wallet, input) | Alias | Alias of publishMessagingKey(). |
ubnet.chats.signMessage(wallet, payload) | Wallet signing | Sign an already encrypted message payload without submitting it. |
ubnet.chats.submitSignedMessage(signedEvent, body) | POST /api/events/submit | Submit a previously signed message. |
ubnet.chats.send(wallet, payload) | Sign + submit | Sign and submit an already encrypted v2 payload. |
ubnet.chats.remove(wallet, chatId) | Signed delete-chat | Hide the chat for this wallet only. |
ubnet.chats.leaveGroup(wallet, chatId) | Signed group-chat-leave | Leave and hide a group chat for this wallet. |
ubnet.chats.clearBrowserCache(wallet) | Local IndexedDB operation | Delete this browser's local private-key handle and unlock markers. |
ubnet.chats.clearBrowserMessagingCache(wallet) | Alias | Alias of clearBrowserCache(). |
ubnet.chats.destroyMessagingKey(wallet) | Signed messaging-key + local clear | Disable messaging for the wallet and remove this browser's local key. |
NFTs, names, support, and public events
| Call | API / action | Use |
|---|---|---|
ubnet.nfts.publicGallery(wallet, cursor) | GET /api/nft-galleries/profile/:wallet | Read a public NFT gallery. |
ubnet.nfts.editor(wallet) | GET /api/nft-galleries/editor/:wallet | Read editor data. |
ubnet.nfts.inventory(wallet, networkIds) | Local placeholder | Return an empty configured inventory with the supplied networks marked unavailable. |
ubnet.nfts.save(wallet, input) | Signed nft-gallery | Save NFT gallery settings. |
ubnet.reservedSubdomains.check(name) | GET /api/reserved-subdomains/check | Check whether a normalized name is reserved. |
ubnet.domainRegistration.buildMessage(input) | Local helper | Build the exact wallet-authentication message. |
ubnet.domainRegistration.submit(input) | POST /api/registrations/domain_registration | Submit a sponsored request, or pass sponsored: false for a user-paid authorization. |
ubnet.domainRegistration.registerAuthorized(response) | Ethereum Mainnet wallet | Validate and submit a user-paid V3 authorization. |
ubnet.support.contact(input) | POST /api/support/contact | Create a support request. |
ubnet.support.sendMessage(input) | Alias | Alias of contact. |
ubnet.events.list(queryOrLimit) | GET /api/events | List public events using a query object or a numeric limit. |
ubnet.events.get(id) | GET /api/events/:id | Read one public event. |
ubnet.events.summary() | GET /api/events/summary | Read the public event summary. |
ubnet.events.topByCategory(query) | GET /api/events/top-by-category | Rank categorized posts and lists by indexed likes. |
Use ubnet.events.list({ category: "example-category", latest: true, wallet }) for filtered discovery. Supported query fields are limit, cursor, action, author, wallet, category, category_mode, schema_name, schema_version, post_type, source_instance_host, and latest=true.
Generated bundle output
Tracked SDK source modules live under frontend/public/ubnet/. The frontend build generates:
frontend/public/ubnet.bundle.js
frontend/public/sdk/v1/ubnet.bundle.js