Build an Extension for haex-vault
Learn how to create powerful extensions using the official SDK. This guide walks you through the complete process from setup to publishing.
Overview
The haex-vault SDK provides a complete framework for building secure, cryptographically-signed extensions. Extensions run in sandboxed environments with granular permissions, ensuring user data stays protected while enabling powerful functionality.
Database Access
Full SQLite database with migrations, CRDT sync support, and Drizzle ORM integration.
Cryptographic Security
Ed25519 signing, permission-based access control, and sandboxed execution.
Framework Support
Native adapters for Vue, React, Svelte, and vanilla JavaScript.
Easy Distribution
Sign, package, and publish to the marketplace with simple CLI commands.
Prerequisites
- Node.js 18+ - JavaScript runtime for building your extension
- npm, pnpm or yarn - Package manager of your choice
- Basic web development knowledge - HTML, CSS, JavaScript/TypeScript
Quick Start
Get a new extension up and running in just a few minutes. We'll use Vite with Vue, but you can use any framework.
1 Create Project & Install SDK
npm create vite@latest my-extension -- --template vue-ts
cd my-extension
npm install @haex-space/vault-sdk
npx haex init --name "my-extension" --author "Your Name"
This creates a new Vite project, installs the SDK, and initializes the extension structure including manifest and cryptographic keys.
2 Protect Your Private Key
The haex init command creates a private.key file in the haextension folder. This key is used to sign your extension.
Security Warning:Never commit your private.key to version control. Add it to .gitignore immediately. If you lose this key, you'll need to generate a new one and your extension's identity will change.
3 Start Development
npm run dev
Start the Vite dev server and load your extension in haex-vault for live development.
Project Structure
After initialization, your project will have the following structure:
my-extension/
├── haextension/
│ ├── manifest.json # Extension metadata and permissions
│ ├── public.key # Your extension's public identity
│ ├── private.key # Signing key (keep secret!)
│ └── icon.png # Extension icon
├── src/
│ ├── main.ts # Application entry point
│ ├── App.vue # Root component
│ └── ...
├── haextension.config.json # Build configuration
├── package.json
└── vite.config.tsExtension Manifest
The manifest.json file defines your extension's metadata, permissions, and configuration. It's the heart of your extension.
Basic Manifest
{
"name": "my-extension",
"version": "1.0.0",
"author": "Your Name",
"description": "A brief description of your extension",
"entry": "index.html",
"icon": "icon.png",
"publicKey": "MCowBQYDK2VwAyEA...",
"signature": "",
"displayMode": "auto",
"singleInstance": false,
"permissions": {
"database": [],
"filesystem": [],
"http": [],
"shell": []
}
}
Field Reference
nameUnique identifier for your extension (lowercase, hyphens allowed)versionSemantic version (major.minor.patch)publicKeyYour Ed25519 public key (generated by haex init)signatureCryptographic signature (added during build)entryHTML entry point relative to the build outputdisplayModeHow the extension displays: 'auto', 'window', or 'iframe'singleInstanceIf true, only one instance can run at a timeSDK Integration
The SDK provides framework-specific adapters for seamless integration. Choose your framework below to see the setup code.
<script setup lang="ts">
import { useHaexHub } from '@haex-space/vault-sdk/vue'
import manifest from '../haextension/manifest.json'
const {
client, // HaexVaultClient instance
extensionInfo, // Ref<ExtensionInfo | null>
context, // Ref<ApplicationContext | null>
isSetupComplete, // Ref<boolean>
db, // Drizzle ORM instance
storage, // StorageAPI
getTableName // (name: string) => string
} = useHaexHub({ manifest, debug: true })
// Wait for initialization
onMounted(async () => {
await client.ready()
console.log('Extension loaded:', extensionInfo.value?.name)
})
</script>
<template>
<div v-if="!isSetupComplete">Loading...</div>
<div v-else>
<p>Theme: {{ context?.theme }}</p>
<p>Platform: {{ context?.platform }}</p>
</div>
</template>
Tip:The SDK automatically handles communication with haex-vault, context changes, and error handling. Just import and use the composable/hook.
Database
Extensions have access to a SQLite database with automatic table namespacing. Your tables are prefixed with your public key to prevent conflicts with other extensions.
Migrations
Define your database schema using migrations. Migrations run automatically and are tracked to prevent duplicate execution.
// Define your migrations
const migrations = [
{
name: '0001_create_passwords_table',
sql: `
CREATE TABLE IF NOT EXISTS ${getTableName('passwords')} (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
username TEXT,
password TEXT NOT NULL,
url TEXT,
notes TEXT,
category_id TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`
},
{
name: '0002_create_categories_table',
sql: `
CREATE TABLE IF NOT EXISTS ${getTableName('categories')} (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
color TEXT
)
`
}
]
// Register migrations during setup
client.onSetup(async () => {
const result = await client.registerMigrationsAsync('1.0.0', migrations)
console.log(`Applied ${result.appliedCount} migrations`)
})
await client.setupComplete()
Reserved Columns:haex-vault automatically adds CRDT sync columns (haex_timestamp, haex_column_hlcs, haex_deleted) to all tables. Do not create columns starting with 'haex_' as they will be overwritten.
Queries & Mutations
// Query data
const passwords = await client.query<Password>(
`SELECT * FROM ${getTableName('passwords')}`
)
// Query with parameters
const password = await client.query<Password>(
`SELECT * FROM ${getTableName('passwords')} WHERE id = ?`,
[passwordId]
)
// Insert data
await client.execute(
`INSERT INTO ${getTableName('passwords')} (id, title, password) VALUES (?, ?, ?)`,
[crypto.randomUUID(), 'My Login', 'secret123']
)
// Update data
await client.execute(
`UPDATE ${getTableName('passwords')} SET title = ?, updated_at = datetime('now') WHERE id = ?`,
['New Title', passwordId]
)
// Delete data
await client.execute(
`DELETE FROM ${getTableName('passwords')} WHERE id = ?`,
[passwordId]
)
Drizzle ORM Integration
For type-safe queries, you can use Drizzle ORM with the SDK's built-in integration.
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
// Define schema (table names will be auto-prefixed)
const passwords = sqliteTable('passwords', {
id: text('id').primaryKey(),
title: text('title').notNull(),
username: text('username'),
password: text('password').notNull(),
})
// Initialize Drizzle with your schema
const db = client.initializeDatabase({ passwords })
// Use Drizzle query builder
const allPasswords = await db
.select()
.from(passwords)
// Insert with Drizzle
await db.insert(passwords).values({
id: crypto.randomUUID(),
title: 'My Login',
password: 'secret123',
})
Key-Value Storage
For simple data that doesn't need database features, use the Storage API. It provides a localStorage-like interface scoped to your extension.
// Store simple key-value data
await storage.setItem('lastSync', new Date().toISOString())
await storage.setItem('settings', JSON.stringify({ theme: 'dark' }))
// Retrieve data
const lastSync = await storage.getItem('lastSync')
const settings = JSON.parse(await storage.getItem('settings') || '{}')
// List all keys
const keys = await storage.keys()
// Remove specific key
await storage.removeItem('lastSync')
// Clear all storage for this extension
await storage.clear()
Filesystem Access
Extensions can save and open files through the Filesystem API. All operations show native dialogs to the user for security.
// Save a file (opens save dialog)
const data = new TextEncoder().encode(JSON.stringify(exportData))
const result = await client.filesystem.saveFileAsync(data, {
defaultPath: 'passwords-backup.json',
title: 'Export Passwords',
filters: [
{ name: 'JSON Files', extensions: ['json'] },
{ name: 'All Files', extensions: ['*'] }
]
})
if (result?.success) {
console.log('Saved to:', result.path)
}
// Open a file with system viewer
const pdfData = await generatePDF()
await client.filesystem.openFileAsync(pdfData, {
fileName: 'report.pdf',
mimeType: 'application/pdf'
})
// Show an image
await client.filesystem.showImageAsync({
dataUrl: 'data:image/png;base64,...'
})
HTTP Requests
Make HTTP requests to external APIs using the Web API. All requests must be to URLs declared in your manifest permissions.
// Simple GET request
const data = await client.web.fetchJsonAsync<ApiResponse>(
'https://api.example.com/data'
)
// POST request with body
const response = await client.web.fetchAsync(
'https://api.example.com/submit',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({ name: 'Test' })
}
)
// Download binary data
const blob = await client.web.fetchBlobAsync(
'https://cdn.example.com/file.zip'
)
// Open URL in browser
await client.web.openAsync('https://example.com/help')
Permissions
Extensions must declare all required permissions in the manifest. Users review and approve these permissions during installation.
Declaring Permissions
{
"permissions": {
"database": [
{
"target": "MCowBQYDK2Vw...__password-manager__credentials",
"operation": "read"
},
{
"target": "MCowBQYDK2Vw...__password-manager__categories",
"operation": "read_write"
}
],
"filesystem": [
{
"target": "/exports",
"operation": "read_write"
}
],
"http": [
{
"target": "https://api.example.com/**"
},
{
"target": "https://cdn.example.com/*"
}
],
"shell": null
}
}
Permission Types
Database Operations
read- SELECT onlyreadWrite- SELECT + INSERT/UPDATEcreate- CREATE TABLEdelete- DELETE rowsalterDrop- ALTER/DROP operations
Filesystem Operations
read- Read files from specified pathsreadWrite- Read and write filesdelete- Delete files and directories
HTTP Requests
Use glob patterns to specify allowed URLs. Use ** for path wildcards and * for single segment wildcards.
Checking Permissions at Runtime
// Check database permission before query
const canRead = await client.permissions.checkDatabaseAsync(
'other_extension_table',
'read'
)
if (canRead) {
const data = await client.query('SELECT * FROM other_extension_table')
}
// Check web permission
const canFetch = await client.permissions.checkWebAsync(
'https://api.external.com'
)
// Check filesystem permission
const canWrite = await client.permissions.checkFilesystemAsync(
'/exports/backup.json',
'write'
)
Application Context
Access the current theme, locale, and platform information. The context updates automatically when the user changes settings.
// Get current context
const context = await client.request('haextension:context:get')
console.log('Theme:', context.theme) // 'light' | 'dark' | 'system'
console.log('Locale:', context.locale) // 'en' | 'de' | ...
console.log('Platform:', context.platform) // 'linux' | 'macos' | 'windows' | 'android' | 'ios'
// Listen for context changes
client.on('haextension:context:changed', (event) => {
const { context } = event.data
// Update your UI theme, locale, etc.
applyTheme(context.theme)
})
Build & Sign
When you're ready to distribute your extension, build it and sign it with your private key.
# 1. Build your extension
npm run build
# 2. Sign and package the extension
npx haex sign dist -k haextension/private.key -o my-extension-1.0.0.haextension
# The output file is ready for distribution!
Important:Keep your private key safe and backed up. If you lose it, you cannot update your extension and will need to publish as a new extension with a different identity.
Publishing
Share your extension with the haex.space community through the marketplace.
- 1Test Thoroughly
Test your extension on all platforms you want to support. Check permissions work correctly.
- 2Prepare Assets
Create screenshots, write a compelling description, and prepare your extension icon.
- 3Submit to Marketplace
Upload your .haextension file to the marketplace with your listing details.
- 4Review Process
Our team reviews extensions for security and quality. Typical review time is 2-3 business days.
Complete Example
Here's a complete password manager extension showing all the concepts together:
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useHaexHub } from '@haex-space/vault-sdk/vue'
import manifest from '../haextension/manifest.json'
interface Password {
id: string
title: string
username: string | null
password: string
url: string | null
created_at: string
}
const { client, isSetupComplete, context, getTableName, storage } = useHaexHub({
manifest,
debug: globalThis._importMeta_.env.DEV
})
const passwords = ref<Password[]>([])
const newPassword = ref({ title: '', username: '', password: '', url: '' })
const loading = ref(true)
// Migrations
const migrations = [
{
name: '0001_initial',
sql: `
CREATE TABLE IF NOT EXISTS ${getTableName('passwords')} (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
username TEXT,
password TEXT NOT NULL,
url TEXT,
created_at TEXT DEFAULT (datetime('now'))
)
`
}
]
// Setup hook - runs once after initialization
client.onSetup(async () => {
await client.registerMigrationsAsync(manifest.version, migrations)
})
// Load data after setup complete
onMounted(async () => {
await client.setupComplete()
await loadPasswords()
loading.value = false
})
async function loadPasswords() {
passwords.value = await client.query<Password>(
`SELECT * FROM ${getTableName('passwords')} ORDER BY created_at DESC`
)
}
async function addPassword() {
if (!newPassword.value.title || !newPassword.value.password) return
await client.execute(
`INSERT INTO ${getTableName('passwords')} (id, title, username, password, url) VALUES (?, ?, ?, ?, ?)`,
[
crypto.randomUUID(),
newPassword.value.title,
newPassword.value.username || null,
newPassword.value.password,
newPassword.value.url || null
]
)
newPassword.value = { title: '', username: '', password: '', url: '' }
await loadPasswords()
}
async function deletePassword(id: string) {
await client.execute(
`DELETE FROM ${getTableName('passwords')} WHERE id = ?`,
[id]
)
await loadPasswords()
}
async function exportPasswords() {
const data = new TextEncoder().encode(JSON.stringify(passwords.value, null, 2))
await client.filesystem.saveFileAsync(data, {
defaultPath: 'passwords-export.json',
filters: [{ name: 'JSON', extensions: ['json'] }]
})
}
const isDark = computed(() => context.value?.theme === 'dark')
</script>
<template>
<div :class="{ 'dark': isDark }" class="min-h-screen p-4">
<div v-if="loading" class="flex items-center justify-center h-64">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<div v-else>
<header class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">My Password Manager</h1>
<button @click="exportPasswords" class="btn btn-secondary">
Export
</button>
</header>
<!-- Add Password Form -->
<form @submit.prevent="addPassword" class="card mb-6 p-4 space-y-4">
<input v-model="newPassword.title" placeholder="Title" required class="input" />
<input v-model="newPassword.username" placeholder="Username" class="input" />
<input v-model="newPassword.password" type="password" placeholder="Password" required class="input" />
<input v-model="newPassword.url" placeholder="URL" class="input" />
<button type="submit" class="btn btn-primary w-full">Add Password</button>
</form>
<!-- Password List -->
<div class="space-y-2">
<div v-for="pwd in passwords" :key="pwd.id" class="card p-4 flex justify-between items-center">
<div>
<h3 class="font-semibold">{{ pwd.title }}</h3>
<p class="text-sm text-muted">{{ pwd.username || 'No username' }}</p>
</div>
<button @click="deletePassword(pwd.id)" class="btn btn-danger btn-sm">
Delete
</button>
</div>
</div>
</div>
</div>
</template>