A straightforward explanation of how Wealthfolio's addon system works.
Addons are TypeScript modules that extend Wealthfolio's functionality. Each
addon is a JavaScript function that receives an AddonContext object and can
register UI components, add navigation items, and access financial data through
APIs.
┌─────────────────────────────────────────────────────────────────┐
│ Wealthfolio Host Application │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Addon Runtime │ │ Permission │ │ API Bridge │ │
│ │ │ │ System │ │ │ │
│ │ • Load/Unload │ │ • Detection │ │ • Type Bridge │ │
│ │ • Lifecycle │ │ • Validation │ │ • Domain APIs │ │
│ │ • Context Mgmt │ │ • Enforcement │ │ • Scoped Access │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Individual Addons │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Addon A │ │ Addon B │ │ Addon C │ │ Addon D │ │
│ │ │ │ │ │ │ │ │ │
│ │ enable() │ │ enable() │ │ enable() │ │ enable() │ │
│ │ disable() │ │ disable() │ │ disable() │ │ disable() │ │
│ │ UI/Routes │ │ UI/Routes │ │ UI/Routes │ │ UI/Routes │ │
│ │ API Calls │ │ API Calls │ │ API Calls │ │ API Calls │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
The system has two main parts:
- Host Application: Manages addon lifecycle, enforces permissions, provides APIs
- Addons: JavaScript functions that receive context and register functionality
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ │ │ │ │ │ │ │
│ ZIP File │───▶│ Extract │───▶│ Validate │───▶│ Analyze │
│ │ │ │ │ │ │ Permissions │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ │ │ │ │ │
│ Running │◀───│ Enable │◀───│ Load │◀─────────────┘
│ │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
- Extract: Unzip addon package and read files
- Validate: Check manifest.json structure and compatibility
- Analyze Permissions: Scan code for API usage patterns
- Load: Create isolated context with scoped APIs
- Enable: Call addon's enable function
- Running: Addon functionality is active
Each addon receives an isolated context:
interface AddonContext {
sidebar: {
addItem(config: SidebarItemConfig): SidebarItemHandle;
};
router: {
add(route: RouteConfig): void;
};
onDisable(callback: () => void): void;
api: HostAPI; // Financial data and operations
}The context provides:
- Sidebar: Add navigation items
- Router: Register new routes/pages
- onDisable: Register cleanup functions
- API: Access to financial data and operations
The system scans addon code during installation to detect API usage:
// This code pattern would be detected:
const accounts = await ctx.api.accounts.getAll();
// Detected: accounts.getAllThe Rust backend scans for patterns like:
ctx.api.accounts.getAll(api.accounts.getAll(.api.accounts.getAll(
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ │ │ │ │ │
│ Static Analysis │───▶│ Declaration │───▶│ Runtime │
│ │ │ Matching │ │ Validation │
│ • Scan code │ │ │ │ │
│ • Detect APIs │ │ • Compare with │ │ • Check perms │
│ • Build list │ │ manifest │ │ • Allow/Block │
│ │ │ • Show dialog │ │ • Log calls │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Based on the actual code, these are the permission categories:
| Category | Functions | Risk Level |
|---|---|---|
accounts |
getAll, create | High |
portfolio |
getHoldings, update, recalculate | High |
activities |
getAll, search, create, update, import | High |
market-data |
searchTicker, sync, getProviders | Low |
assets |
getProfile, updateProfile, updateDataSource | Medium |
quotes |
update, getHistory | Low |
performance |
calculateHistory, calculateSummary | Medium |
currency |
getAll, update, add | Low |
goals |
getAll, create, update, updateAllocations | Medium |
contribution-limits |
getAll, create, update, calculateDeposits | Medium |
settings |
get, update, backupDatabase | Medium |
files |
openCsvDialog, openSaveDialog | Medium |
events |
onDrop, onUpdateComplete, onSyncStart | Low |
ui |
sidebar.addItem, router.add | Low |
secrets |
set, get, delete | High |
The permission system works in three stages:
- Static Analysis: Code is scanned for API patterns during installation
- Declaration Matching: Detected usage is compared with manifest declarations
- Runtime Validation: API calls are checked against approved permissions
Each addon gets isolated secret storage:
// Addon "my-addon" accessing secrets
await ctx.api.secrets.set("api-key", "value");
// Stored as: "addon_my-addon_api-key"┌─────────────────────────────────────────────────────────────────┐
│ Secret Storage │
├─────────────────────────────────────────────────────────────────┤
│ addon_analytics_api-key = "sk-1234..." │
│ addon_analytics_token = "token-5678..." │
├─────────────────────────────────────────────────────────────────┤
│ addon_importer_database = "postgres://..." │
│ addon_importer_username = "user123" │
├─────────────────────────────────────────────────────────────────┤
│ addon_tracker_webhook = "https://..." │
│ addon_tracker_secret = "secret-key" │
└─────────────────────────────────────────────────────────────────┘
The scoping prevents addons from accessing each other's secrets.
The API is organized by financial domain:
┌─────────────────────────────────────────────────────────────────┐
│ HostAPI │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ accounts │ │ portfolio │ │ activities │ │ market │ │
│ │ │ │ │ │ │ │ │ │
│ │ • getAll │ │ • holdings │ │ • getAll │ │ • search │ │
│ │ • create │ │ • update │ │ • create │ │ • sync │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ assets │ │ quotes │ │performance │ │exchangeRates│ │
│ │ │ │ │ │ │ │ │ │
│ │ • profile │ │ • update │ │ • calculate │ │ • getAll │ │
│ │ • update │ │ • history │ │ • summary │ │ • update │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ goals │ │contribution │ │ settings │ │ files │ │
│ │ │ │ Limits │ │ │ │ │ │
│ │ • getAll │ │ • getAll │ │ • get │ │ • openCsv │ │
│ │ • create │ │ • calculate │ │ • update │ │ • openSave │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ │
│ │ events │ │ secrets │ │
│ │ │ │ │ │
│ │ • onDrop │ │ • set │ │
│ │ • onUpdate │ │ • get │ │
│ │ • onSync │ │ • delete │ │
│ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
interface HostAPI {
accounts: AccountsAPI;
portfolio: PortfolioAPI;
activities: ActivitiesAPI;
market: MarketAPI;
assets: AssetsAPI;
quotes: QuotesAPI;
performance: PerformanceAPI;
exchangeRates: ExchangeRatesAPI;
goals: GoalsAPI;
contributionLimits: ContributionLimitsAPI;
settings: SettingsAPI;
files: FilesAPI;
events: EventsAPI;
secrets: SecretsAPI;
}The system uses a type bridge to convert between internal types and SDK types:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ │ │ │ │ │
│ Internal Types │───▶│ Type Bridge │───▶│ SDK Types │
│ │ │ │ │ │
│ getHoldings(id) │ │ • Convert args │ │ api.portfolio. │
│ → Holding[] │ │ • Map returns │ │ getHoldings() │
│ │ │ • Type safety │ │ → Holding[] │
└─────────────────┘ └─────────────────┘ └─────────────────┘
// Internal command function
getHoldings(accountId: string): Promise<Holding[]>
// SDK API method
api.portfolio.getHoldings(accountId: string): Promise<Holding[]>This allows the internal implementation to change without breaking addon compatibility.
Development addons run from local servers:
┌─────────────────────────────────────────────────────────────────┐
│ Development Environment │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Wealthfolio App │◀─ discover ─▶│ Dev Server │ │
│ │ │ │ localhost:3001 │ │
│ │ • Auto-discover │ │ │ │
│ │ • Load addons │ │ /health ✓ │ │
│ │ • Hot reload │ │ /status ✓ │ │
│ └─────────────────┘ │ /manifest.json │ │
│ │ │ /addon.js │ │
│ │ └─────────────────┘ │
│ │ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Port Scan │ │ More Dev Servers│ │
│ │ │ │ │ │
│ │ • Check 3001 │ │ localhost:3002 │ │
│ │ • Check 3002 │ │ localhost:3003 │ │
│ │ • Check 3003 │ │ ... │ │
│ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Development Server (localhost:3001)
├─ /health # Health check
├─ /status # Build status
├─ /manifest.json # Addon manifest
└─ /addon.js # Built addon code
The host application discovers running dev servers by checking common ports (3001, 3002, 3003) for health endpoints.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ │ │ │ │ │ │ │
│ Source Code │───▶│ TypeScript │───▶│ Vite Bundle │───▶│ Single File │
│ │ │ Compiler │ │ │ │ │
│ .tsx/.ts │ │ │ │ │ │ addon.js │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
The addon is bundled into a single JavaScript file that exports an enable function.
The addon loader tries multiple export patterns:
// 1. ES module default export is the function
export default function enable(ctx) { ... }
// 2. ES module default export object with enable
export default { enable: function(ctx) { ... } }
// 3. Named export
export function enable(ctx) { ... }
// 4. UMD/Constructor pattern
export function AddonNameAddon(ctx) { ... }Each addon gets its own isolated context:
┌─────────────────────────────────────────────────────────────────┐
│ Context Creation │
├─────────────────────────────────────────────────────────────────┤
│ │
│ createAddonContext(addonId) ──┐ │
│ │ │
│ ┌──────────────────────────▼──────────────────────────────┐ │
│ │ AddonContext │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ sidebar: { addItem: ... } │ │
│ │ router: { add: ... } │ │
│ │ onDisable: (cb) => callbacks.add(cb) │ │
│ │ api: createScopedAPI(addonId) ─┐ │ │
│ └─────────────────────────────────┼───────────────────────┘ │
│ │ │
│ ┌────────────────────────────────▼──────────────────────┐ │
│ │ Scoped API │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ accounts: AccountsAPI │ │
│ │ portfolio: PortfolioAPI │ │
│ │ ... │ │
│ │ secrets: createAddonScopedSecrets(addonId) │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
function createAddonContext(addonId: string): AddonContext {
return {
sidebar: { addItem: ... },
router: { add: ... },
onDisable: (cb) => callbacks.add(cb),
api: createScopedAPI(addonId)
};
}The API is scoped to the addon ID for secret storage isolation.
If an addon fails to load or crashes:
- Error is logged
- Host application continues normally
- Other addons are unaffected
- User sees error notification
If an addon tries to call an unauthorized API:
PermissionErroris thrown- API call is blocked
- Error is logged
- Addon can handle the error gracefully
┌─────────────────────────────────────────────────────────────────┐
│ Security Boundaries │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Addon A │ │ Addon B │ │ Addon C │ │ Addon D │ │
│ │ │ │ │ │ │ │ │ │
│ │ Context A │ │ Context B │ │ Context C │ │ Context D │ │
│ │ Secrets A │ │ Secrets B │ │ Secrets C │ │ Secrets D │ │
│ │ │ │ │ │ │ │ │ │
│ │ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │ │
│ │ │ API │ │ │ │ API │ │ │ │ API │ │ │ │ API │ │ │
│ │ │ Perms│ │ │ │ Perms│ │ │ │ Perms│ │ │ │ Perms│ │ │
│ │ └─────┘ │ │ └─────┘ │ │ └─────┘ │ │ └─────┘ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │ │
│ └───────────────┼───────────────┼───────────────┘ │
│ │ │ │
│ ┌─────────▼───────────────▼─────────┐ │
│ │ Permission Validator │ │
│ │ Runtime Enforcement │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
- Each addon runs in its own context
- Secrets are scoped by addon ID
- No cross-addon communication
- No access to host application internals
- Code is analyzed during installation
- User approves detected permissions
- Runtime validation on every API call
- Detailed audit logging
Permissions are categorized by risk:
- High: Can modify financial data (accounts, activities)
- Medium: Can read sensitive data (portfolio, goals)
- Low: Read-only market data and UI operations
Every addon exports an enable function:
export default function enable(ctx: AddonContext) {
// Register UI elements
const sidebar = ctx.sidebar.addItem({
id: "my-feature",
label: "My Feature",
route: "/my-feature",
});
// Register route
ctx.router.add({
path: "/my-feature",
component: React.lazy(() => import("./MyComponent")),
});
// Return cleanup function
return {
disable() {
sidebar.remove();
},
};
}Addons are loaded dynamically using JavaScript's import() function:
// Create blob URL from addon code
const blob = new Blob([addonCode], { type: "text/javascript" });
const blobUrl = URL.createObjectURL(blob);
// Dynamic import
const mod = await import(blobUrl);
const enableFunction = mod.default || mod.enable;
// Execute with isolated context
const result = enableFunction(createAddonContext(addonId));When addons are disabled:
- Their disable function is called
- UI elements are removed
- Event listeners are unregistered
- Context is destroyed
Each addon includes a manifest.json file:
{
"id": "my-addon",
"name": "My Addon",
"version": "1.0.0",
"description": "Does something useful",
"main": "addon.js",
"sdkVersion": "1.0.0",
"permissions": {
"portfolio": ["read"],
"market": ["read"]
}
}Required fields:
id: Unique identifiername: Display nameversion: Semantic versionmain: Entry point file
Optional fields:
description: What the addon doesauthor: Creator informationpermissions: Required API accesssdkVersion: Compatible SDK version
addon-package.zip
├─ manifest.json # Addon metadata
├─ addon.js # Main entry point
└─ assets/ # Optional assets
└─ icon.png
For development:
my-addon/
├─ src/
│ └─ addon.tsx # Source code
├─ dist/ # Built files
├─ manifest.json # Metadata
├─ package.json # Dependencies
├─ vite.config.ts # Build config
└─ tsconfig.json # TypeScript config
┌─────────────────────────────────────────────────────────────────┐
│ Addon Package │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────────┐ │
│ │ manifest.json │ ← Metadata, permissions, entry point │
│ │ │ │
│ │ { │ │
│ │ "id": "...", │ │
│ │ "name": "...",│ │
│ │ "main": "..." │ │
│ │ } │ │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ addon.js │ ← Bundled JavaScript with enable() │
│ │ │ │
│ │ export default │ │
│ │ function enable │ │
│ │ (ctx) { ... } │ │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ assets/ │ ← Optional static assets │
│ │ ├─ icon.png │ │
│ │ ├─ logo.svg │ │
│ │ └─ styles.css │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘