Extension System Architecture
Deep dive into how extensions are sandboxed, communicate with haex-vault, and integrate with the sync system.
Extension Overview
Extensions are sandboxed web applications that run within haex-vault. They can access vault data through the SDK while being isolated from the host system and each other.
Sandboxed Isolation
Extensions run in isolated environments with explicit permissions for each action.
Unified SDK
The @haex-space/vault-sdk provides a consistent API regardless of the display mode.
Own Database Tables
Extensions can create and manage their own tables with automatic CRDT sync.
Automatic Sync
Extension data is automatically synced across devices using the same CRDT system.
Display Modes
Extensions render either as a Tauri WebviewWindow (`window`) or an embedded HTML iframe (`iframe`). The `auto` mode picks per platform - window on desktop, iframe on mobile.
| Aspect | window | iframe |
|---|---|---|
| Runtime | Tauri WebviewWindow | HTML iframe |
| Platform | Desktop (auto-picked) | Mobile (auto-picked) |
| Isolation | Native window + Webview | Browser sandbox (parent origin checked) |
| Communication | Tauri invoke / events | postMessage |
| Debugging | Webview DevTools | Host DevTools |
Configure in Manifest
// manifest.json
{
"displayMode": "auto" // "auto" | "window" | "iframe"
//
// auto: desktop → "window" (Tauri WebviewWindow),
// mobile → "iframe"
// window: always open in a native window
// iframe: always render embedded inside haex-vault
}
Extension Lifecycle
Extensions go through a defined lifecycle from installation to runtime.
Cross-Device Installation:When you install an extension, the metadata is synced to other devices. However, the extension files must be downloaded separately on each device from the marketplace.
Permission System
Extensions must declare permissions in their manifest. Users grant or deny these permissions during installation.
// Internal representation of a granted permission
// (ts-rs binding: ExtensionPermission)
{
id: string,
extensionId: string,
resourceType: 'db' | 'fs' | 'web' | 'shell'
| 'filesync' | 'spaces' | 'identities' | 'passwords' | 'mail',
action: { Database: 'read' | 'readWrite' | 'create' | 'delete' | 'alterDrop' }
| { Filesystem: 'read' | 'readWrite' }
| { Web: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | '*' /* parsed but not enforced */ }
| { Shell: 'execute' }
| /* ... other resource types ... */,
target: string,
constraints: Record<string, unknown> | null,
status: 'ask' | 'granted' | 'denied',
}
// Note: for Web permissions, only `target` (URL / domain pattern)
// is consulted in the runtime check today. The HTTP method action is
// parsed from the manifest but not enforced.
Permission Types
Database
read- SELECT onlyreadWrite- SELECT + INSERT/UPDATEcreate- CREATE TABLEdelete- DELETE rowsalterDrop- ALTER / DROP TABLE
Filesystem
read- Read filesreadWrite- Read + Write files
Web (HTTP)
(URL pattern)- HTTP method parsed but not enforced
Shell
execute- Run a configured shell command
Permission Enforcement
// Rust - Permission Check Flow (simplified)
//
// 1. Frontend sends an extension request through Tauri IPC.
// 2. The relevant command handler resolves the resource and action,
// looks up a matching ExtensionPermission in haex_extension_permissions,
// and routes by status.
fn handle_db_request(extension_id: &str, sql: &str) -> Result<...> {
let target = parse_table_target(sql)?; // e.g. "pkA__ext__items"
let action = derive_db_action(sql)?; // Read | ReadWrite | Create | Delete | AlterDrop
let permission = permission_manager.lookup(
extension_id,
ResourceType::Db,
action,
&target,
)?;
match permission.status {
PermissionStatus::Granted => run_sql(sql),
PermissionStatus::Denied => Err(PermissionDenied),
PermissionStatus::Ask => prompt_user_and_cache(permission),
}
}
Extension Communication
Extensions communicate with the host through different transports depending on the display mode, but the SDK exposes the same API in both cases.
iframe Communication (postMessage)
// Iframe extensions talk to haex-vault via postMessage.
// The SDK wraps this. As an extension developer you use the client API
// (client.query, client.permissions.*, ...) - the protocol is internal.
// Conceptually, every SDK call becomes a request like this:
window.parent.postMessage({
type: 'haextension:invoke',
id: 'request-123',
method: 'database.query',
args: ['SELECT * FROM haex_passwords', []],
}, '*')
// And haex-vault answers with a response (event = MessageEvent from the iframe):
event.source.postMessage({
type: 'haextension:response',
id: 'request-123',
success: true,
data: [{ id: 'abc', title: 'Gmail' }],
}, '*')
Window Communication (Tauri WebviewWindow)
// Window/WebView extensions on desktop and mobile use the same
// SDK and the same `haextension:invoke` / `haextension:response`
// protocol. On native windows the messages are bridged through
// the Tauri WebviewWindow rather than a browser iframe.
//
// As an extension developer you never write this code by hand -
// the SDK handles it. The example below is illustrative only.
import { invoke } from '@tauri-apps/api/core'
// Issued by the SDK under the hood:
await invoke('extension_request', {
method: 'database.query',
args: ['SELECT * FROM haex_passwords', []],
})
Development Mode
During development, extensions can be loaded from a local dev server with hot reload support.
Hot Reload
Changes to your extension code are reflected immediately without reinstalling.
Local-Only Data
Dev extension tables are NOT synced to other devices. They use CREATE TABLE IF NOT EXISTS for idempotency.
No CRDT Sync:Dev extension tables are local-only and don't push to haex-sync-server. Once you sign + install the extension, full sync support kicks in.
Receiving Sync Updates
Extensions can listen for sync events to reload their data when changes arrive from other devices.
// Inside an extension: listen for relevant sync updates
import { HAEXTENSION_EVENTS } from '@haex-space/vault-sdk'
const { client } = useHaexVaultSdk()
client.on(HAEXTENSION_EVENTS.SYNC_TABLES_UPDATED, async (event) => {
const tables = event.data?.tables ?? []
const ourPrefix = client.getTableName('') // '<pubKey>__<extName>__'
if (tables.some((t) => t.startsWith(ourPrefix))) {
await reloadDataAsync()
}
})
Automatic Table Prefix:Extension tables are automatically prefixed with the extension's public key and name, so you only receive events for your own tables.