diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index b1f9705..cda3144 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -1,27 +1,19 @@
-
+
- {#if view === 'plugin-manager'}
+ {#if currentView === 'plugin-manager'}
+ {:else}
+
{/if}
@@ -32,53 +24,10 @@
height: 100vh;
background: #1a1a2e;
}
-
- nav {
- width: 220px;
- background: #16213e;
- border-right: 1px solid #0f3460;
- padding: 1rem;
- display: flex;
- flex-direction: column;
- }
-
- nav h1 {
- font-size: 1.2rem;
- color: #e94560;
- margin-bottom: 1.5rem;
- }
-
- .nav-items {
- display: flex;
- flex-direction: column;
- gap: 0.25rem;
- }
-
- .nav-item {
- background: transparent;
- border: none;
- color: #a0a0b8;
- padding: 0.5rem 0.75rem;
- border-radius: 6px;
- cursor: pointer;
- text-align: left;
- font-size: 0.9rem;
- transition: all 0.15s;
- }
-
- .nav-item:hover {
- background: #0f3460;
- color: #e0e0e0;
- }
-
- .nav-item.active {
- background: #e94560;
- color: #fff;
- }
-
.content {
flex: 1;
- padding: 1.5rem;
- overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
}
diff --git a/frontend/src/lib/plugin-manager/PluginCard.svelte b/frontend/src/lib/plugin-manager/PluginCard.svelte
index 5c2addc..45c7ab0 100644
--- a/frontend/src/lib/plugin-manager/PluginCard.svelte
+++ b/frontend/src/lib/plugin-manager/PluginCard.svelte
@@ -2,9 +2,12 @@
export let p = {};
export let capabilities = [];
export let permissions = [];
- export let contributions = {};
+ export let onSettings = () => {};
- $: m = p.manifest || {};
+ $: hasSettingsPanel = (contributions.settingsPanels || []).some(sp => sp.pluginId === pluginId);
+ $: hasUIPermission = (m.permissions || []).includes('ui.register');
+ $: hasStoragePermission = (m.permissions || []).includes('storage.namespace');
+ $: hasCommandsPermission = (m.permissions || []).includes('commands.register');
$: statusColor = ({
loaded: '#4ecca3',
@@ -160,6 +163,20 @@
{#if p.error}
{p.error}
{/if}
+
+
+
+ {#if hasSettingsPanel}
+
+ {/if}
+
+
+
+ {#if !hasUIPermission && (m.contributes && (m.contributes.views || m.contributes.sidebarItems || m.contributes.settingsPanels).length > 0)}
+ ⚠ Plugin has UI contributions but lacks ui.register permission
+ {/if}
diff --git a/frontend/src/lib/plugin-manager/PluginManager.svelte b/frontend/src/lib/plugin-manager/PluginManager.svelte
index 65ca3cf..7015275 100644
--- a/frontend/src/lib/plugin-manager/PluginManager.svelte
+++ b/frontend/src/lib/plugin-manager/PluginManager.svelte
@@ -10,6 +10,9 @@
let loading = true;
let error = '';
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
+ let settingsPanel = null;
+ let settingsData = {};
+ let settingsPluginId = '';
async function loadAll() {
error = '';
@@ -49,6 +52,34 @@
$: totalPlugins = plugins.length;
$: totalCaps = capabilities.length;
$: totalPerms = permissions.length;
+
+ function openSettings(pluginId) {
+ const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId);
+ if (panel) {
+ settingsPanel = panel;
+ settingsPluginId = pluginId;
+ // Load existing settings
+ try {
+ const data = JSON.parse(localStorage.getItem('verstak-settings-' + pluginId) || '{}');
+ settingsData = data;
+ } catch (e) {
+ settingsData = {};
+ }
+ }
+ }
+
+ function saveSettings() {
+ try {
+ localStorage.setItem('verstak-settings-' + settingsPluginId, JSON.stringify(settingsData));
+ // Also try Wails backend
+ const { WritePluginSettings } = require('../../../wailsjs/go/api/App');
+ WritePluginSettings(settingsPluginId, settingsData).then(err => {
+ if (err) console.error('WritePluginSettings:', err);
+ }).catch(() => {});
+ } catch (e) {
+ console.error('saveSettings:', e);
+ }
+ }
@@ -98,7 +129,7 @@
{:else}
{#each plugins as p}
-
+
{/each}
{/if}
@@ -123,11 +154,47 @@
{/if}
- {/if}
-
+ {/if}
-
diff --git a/frontend/src/lib/shell/Sidebar.svelte b/frontend/src/lib/shell/Sidebar.svelte
new file mode 100644
index 0000000..7238ff7
--- /dev/null
+++ b/frontend/src/lib/shell/Sidebar.svelte
@@ -0,0 +1,75 @@
+
+
+
+
+
diff --git a/frontend/src/lib/shell/ViewContainer.svelte b/frontend/src/lib/shell/ViewContainer.svelte
new file mode 100644
index 0000000..0d1f321
--- /dev/null
+++ b/frontend/src/lib/shell/ViewContainer.svelte
@@ -0,0 +1,137 @@
+
+
+
+ {#if activeView}
+ {#each views.filter(v => v.item.id === activeView) as view}
+
+
+
+
+
+ Plugin view: {view.item.component}
+
+ from {view.pluginId}
+
+
+
+
+ {:else}
+
View "{activeView}" not found in contributions
+ {/each}
+ {:else}
+
+
Select an item from the sidebar
+
Plugin views will appear here
+
+ {/if}
+
+
+
diff --git a/internal/api/app.go b/internal/api/app.go
index 8dde2ef..558e89a 100644
--- a/internal/api/app.go
+++ b/internal/api/app.go
@@ -13,6 +13,7 @@ import (
"github.com/verstak/verstak-desktop/internal/core/events"
"github.com/verstak/verstak-desktop/internal/core/permissions"
"github.com/verstak/verstak-desktop/internal/core/plugin"
+ "github.com/verstak/verstak-desktop/internal/core/storage"
"github.com/verstak/verstak-desktop/internal/core/vault"
)
@@ -24,6 +25,7 @@ type App struct {
eventBus *events.Bus
plugins []plugin.Plugin
vault *vault.Vault
+ storage *storage.Storage
}
// NewApp creates a new App instance.
@@ -34,6 +36,7 @@ func NewApp(
bus *events.Bus,
plugins []plugin.Plugin,
vaultService *vault.Vault,
+ storageService *storage.Storage,
) *App {
return &App{
capRegistry: capReg,
@@ -42,6 +45,7 @@ func NewApp(
eventBus: bus,
plugins: plugins,
vault: vaultService,
+ storage: storageService,
}
}
@@ -248,6 +252,83 @@ func (a *App) CloseVault() error {
return nil
}
+// ─── Storage API ────────────────────────────────────────────
+
+// ReadPluginSettings returns all settings for a plugin.
+func (a *App) ReadPluginSettings(pluginID string) map[string]interface{} {
+ if a.storage == nil {
+ return make(map[string]interface{})
+ }
+ data, err := a.storage.ReadPluginSettings(pluginID)
+ if err != nil {
+ log.Printf("[api] ReadPluginSettings(%s): %v", pluginID, err)
+ return make(map[string]interface{})
+ }
+ return data
+}
+
+// WritePluginSettings writes all settings for a plugin.
+func (a *App) WritePluginSettings(pluginID string, data map[string]interface{}) string {
+ if a.storage == nil {
+ return "storage not initialized"
+ }
+ if err := a.storage.WritePluginSettings(pluginID, data); err != nil {
+ log.Printf("[api] WritePluginSettings(%s): %v", pluginID, err)
+ return err.Error()
+ }
+ return ""
+}
+
+// ReadPluginSetting returns a single setting value.
+func (a *App) ReadPluginSetting(pluginID, key string) interface{} {
+ if a.storage == nil {
+ return nil
+ }
+ val, err := a.storage.ReadPluginSetting(pluginID, key)
+ if err != nil {
+ log.Printf("[api] ReadPluginSetting(%s, %s): %v", pluginID, key, err)
+ return nil
+ }
+ return val
+}
+
+// WritePluginSetting writes a single setting value.
+func (a *App) WritePluginSetting(pluginID, key string, value interface{}) string {
+ if a.storage == nil {
+ return "storage not initialized"
+ }
+ if err := a.storage.WritePluginSetting(pluginID, key, value); err != nil {
+ log.Printf("[api] WritePluginSetting(%s, %s): %v", pluginID, key, err)
+ return err.Error()
+ }
+ return ""
+}
+
+// ReadPluginDataJSON reads a named JSON data file for a plugin.
+func (a *App) ReadPluginDataJSON(pluginID, name string) map[string]interface{} {
+ if a.storage == nil {
+ return make(map[string]interface{})
+ }
+ data, err := a.storage.ReadPluginDataJSON(pluginID, name)
+ if err != nil {
+ log.Printf("[api] ReadPluginDataJSON(%s, %s): %v", pluginID, name, err)
+ return make(map[string]interface{})
+ }
+ return data
+}
+
+// WritePluginDataJSON writes a named JSON data file for a plugin.
+func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) string {
+ if a.storage == nil {
+ return "storage not initialized"
+ }
+ if err := a.storage.WritePluginDataJSON(pluginID, name, data); err != nil {
+ log.Printf("[api] WritePluginDataJSON(%s, %s): %v", pluginID, name, err)
+ return err.Error()
+ }
+ return ""
+}
+
// ContributionSummary aggregates all contribution types for the frontend.
type ContributionSummary struct {
Views []contribution.ContributionView `json:"views"`
diff --git a/internal/core/storage/api.go b/internal/core/storage/api.go
new file mode 100644
index 0000000..fca557e
--- /dev/null
+++ b/internal/core/storage/api.go
@@ -0,0 +1,225 @@
+// Package storage provides a safe, namespace-isolated JSON storage API for plugins.
+// All data is stored within the vault's .verstak directory, scoped per plugin.
+package storage
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/verstak/verstak-desktop/internal/core/vault"
+)
+
+// Storage provides plugin-scoped JSON storage (settings, data, cache).
+type Storage struct {
+ mu sync.RWMutex
+ vault *vault.Vault
+}
+
+// New creates a new Storage instance backed by the given vault.
+func New(v *vault.Vault) *Storage {
+ return &Storage{vault: v}
+}
+
+// ─── Plugin ID validation ─────────────────────────────────
+
+func validatePluginID(pluginID string) error {
+ if pluginID == "" {
+ return fmt.Errorf("plugin ID is empty")
+ }
+ if strings.ContainsAny(pluginID, `/\`) {
+ return fmt.Errorf("plugin ID %q contains path separators", pluginID)
+ }
+ if pluginID == "." || pluginID == ".." {
+ return fmt.Errorf("plugin ID %q is a path traversal reference", pluginID)
+ }
+ cleaned := filepath.Clean(pluginID)
+ if cleaned != pluginID {
+ return fmt.Errorf("plugin ID %q contains path traversal", pluginID)
+ }
+ return nil
+}
+
+// ─── Atomic write helper ──────────────────────────────────
+
+func atomicWrite(path string, data []byte) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("failed to create dir %s: %w", dir, err)
+ }
+ tmpFile := filepath.Join(dir, fmt.Sprintf(".tmp.%d", time.Now().UnixNano()))
+ if err := os.WriteFile(tmpFile, data, 0o644); err != nil {
+ return fmt.Errorf("failed to write temp file: %w", err)
+ }
+ if err := os.Rename(tmpFile, path); err != nil {
+ os.Remove(tmpFile) // best-effort cleanup
+ return fmt.Errorf("failed to rename temp file: %w", err)
+ }
+ return nil
+}
+
+// ─── Settings API ─────────────────────────────────────────
+
+// ReadPluginSettings reads all settings for a plugin.
+// Returns empty map if settings.json does not exist.
+func (s *Storage) ReadPluginSettings(pluginID string) (map[string]interface{}, error) {
+ if err := validatePluginID(pluginID); err != nil {
+ return nil, err
+ }
+
+ dir := s.vault.GetPluginSettingsPath(pluginID)
+ path := filepath.Join(dir, "settings.json")
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return make(map[string]interface{}), nil
+ }
+ return nil, fmt.Errorf("failed to read settings for plugin %s: %w", pluginID, err)
+ }
+
+ var result map[string]interface{}
+ if err := json.Unmarshal(data, &result); err != nil {
+ return nil, fmt.Errorf("corrupt settings.json for plugin %s: %w", pluginID, err)
+ }
+ return result, nil
+}
+
+// WritePluginSettings writes all settings for a plugin atomically.
+func (s *Storage) WritePluginSettings(pluginID string, data map[string]interface{}) error {
+ if err := validatePluginID(pluginID); err != nil {
+ return err
+ }
+
+ dir := s.vault.GetPluginSettingsPath(pluginID)
+ path := filepath.Join(dir, "settings.json")
+
+ encoded, err := json.MarshalIndent(data, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal settings for plugin %s: %w", pluginID, err)
+ }
+ return atomicWrite(path, encoded)
+}
+
+// ReadPluginSetting reads a single setting key.
+func (s *Storage) ReadPluginSetting(pluginID, key string) (interface{}, error) {
+ settings, err := s.ReadPluginSettings(pluginID)
+ if err != nil {
+ return nil, err
+ }
+ val, ok := settings[key]
+ if !ok {
+ return nil, nil
+ }
+ return val, nil
+}
+
+// WritePluginSetting writes a single setting key.
+func (s *Storage) WritePluginSetting(pluginID, key string, value interface{}) error {
+ settings, err := s.ReadPluginSettings(pluginID)
+ if err != nil {
+ return err
+ }
+ settings[key] = value
+ return s.WritePluginSettings(pluginID, settings)
+}
+
+// ─── Data JSON API ────────────────────────────────────────
+
+// ReadPluginDataJSON reads a named JSON data file for a plugin.
+func (s *Storage) ReadPluginDataJSON(pluginID, name string) (map[string]interface{}, error) {
+ if err := validatePluginID(pluginID); err != nil {
+ return nil, err
+ }
+ if name == "" {
+ return nil, fmt.Errorf("data name is empty")
+ }
+
+ dir := s.vault.GetPluginDataPath(pluginID)
+ path := filepath.Join(dir, name+".json")
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return make(map[string]interface{}), nil
+ }
+ return nil, fmt.Errorf("failed to read data %s for plugin %s: %w", name, pluginID, err)
+ }
+
+ var result map[string]interface{}
+ if err := json.Unmarshal(data, &result); err != nil {
+ return nil, fmt.Errorf("corrupt data file %s.json for plugin %s: %w", name, pluginID, err)
+ }
+ return result, nil
+}
+
+// WritePluginDataJSON writes a named JSON data file for a plugin atomically.
+func (s *Storage) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) error {
+ if err := validatePluginID(pluginID); err != nil {
+ return err
+ }
+ if name == "" {
+ return fmt.Errorf("data name is empty")
+ }
+
+ dir := s.vault.GetPluginDataPath(pluginID)
+ path := filepath.Join(dir, name+".json")
+
+ encoded, err := json.MarshalIndent(data, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal data %s for plugin %s: %w", name, pluginID, err)
+ }
+ return atomicWrite(path, encoded)
+}
+
+// ─── Cache JSON API ───────────────────────────────────────
+
+// ReadPluginCacheJSON reads a named JSON cache file for a plugin.
+func (s *Storage) ReadPluginCacheJSON(pluginID, name string) (map[string]interface{}, error) {
+ if err := validatePluginID(pluginID); err != nil {
+ return nil, err
+ }
+ if name == "" {
+ return nil, fmt.Errorf("cache name is empty")
+ }
+
+ dir := s.vault.GetPluginCachePath(pluginID)
+ path := filepath.Join(dir, name+".json")
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return make(map[string]interface{}), nil
+ }
+ return nil, fmt.Errorf("failed to read cache %s for plugin %s: %w", name, pluginID, err)
+ }
+
+ var result map[string]interface{}
+ if err := json.Unmarshal(data, &result); err != nil {
+ return nil, fmt.Errorf("corrupt cache file %s.json for plugin %s: %w", name, pluginID, err)
+ }
+ return result, nil
+}
+
+// WritePluginCacheJSON writes a named JSON cache file for a plugin atomically.
+func (s *Storage) WritePluginCacheJSON(pluginID, name string, data map[string]interface{}) error {
+ if err := validatePluginID(pluginID); err != nil {
+ return err
+ }
+ if name == "" {
+ return fmt.Errorf("cache name is empty")
+ }
+
+ dir := s.vault.GetPluginCachePath(pluginID)
+ path := filepath.Join(dir, name+".json")
+
+ encoded, err := json.MarshalIndent(data, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal cache %s for plugin %s: %w", name, pluginID, err)
+ }
+ return atomicWrite(path, encoded)
+}
diff --git a/internal/core/storage/api_test.go b/internal/core/storage/api_test.go
new file mode 100644
index 0000000..1f9ecf0
--- /dev/null
+++ b/internal/core/storage/api_test.go
@@ -0,0 +1,257 @@
+package storage
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/verstak/verstak-desktop/internal/core/vault"
+)
+
+// newTestVault creates a vault in a temp directory for testing.
+func newTestVault(t *testing.T) (*vault.Vault, string) {
+ t.Helper()
+ tmpDir := t.TempDir()
+ v := vault.NewVault(nil)
+ if err := v.CreateVault(tmpDir); err != nil {
+ t.Fatalf("failed to create test vault: %v", err)
+ }
+ return v, tmpDir
+}
+
+func newTestStorage(t *testing.T) (*Storage, string) {
+ t.Helper()
+ v, dir := newTestVault(t)
+ return New(v), dir
+}
+
+// ─── Settings tests ──────────────────────────────────────────
+
+func TestWriteReadPluginSettings(t *testing.T) {
+ s, _ := newTestStorage(t)
+
+ data := map[string]interface{}{
+ "theme": "dark",
+ "lang": "en",
+ "count": float64(42),
+ }
+
+ if err := s.WritePluginSettings("my-plugin", data); err != nil {
+ t.Fatalf("WritePluginSettings: %v", err)
+ }
+
+ got, err := s.ReadPluginSettings("my-plugin")
+ if err != nil {
+ t.Fatalf("ReadPluginSettings: %v", err)
+ }
+
+ if got["theme"] != "dark" {
+ t.Errorf("theme = %v, want dark", got["theme"])
+ }
+ if got["lang"] != "en" {
+ t.Errorf("lang = %v, want en", got["lang"])
+ }
+ if got["count"] != float64(42) {
+ t.Errorf("count = %v, want 42", got["count"])
+ }
+}
+
+func TestReadPluginSettings_NotFound(t *testing.T) {
+ s, _ := newTestStorage(t)
+
+ got, err := s.ReadPluginSettings("unknown-plugin")
+ if err != nil {
+ t.Fatalf("ReadPluginSettings: %v", err)
+ }
+ if len(got) != 0 {
+ t.Errorf("expected empty map, got %v", got)
+ }
+}
+
+func TestReadPluginSettings_Corrupt(t *testing.T) {
+ s, dir := newTestStorage(t)
+
+ // Write corrupt JSON into the settings file
+ settingsDir := filepath.Join(dir, "VerstakVault", ".verstak", "plugin-settings", "bad-plugin")
+ os.MkdirAll(settingsDir, 0o755)
+ os.WriteFile(filepath.Join(settingsDir, "settings.json"), []byte("{not json!!"), 0o644)
+
+ _, err := s.ReadPluginSettings("bad-plugin")
+ if err == nil {
+ t.Fatal("expected error for corrupt settings.json, got nil")
+ }
+}
+
+func TestWritePluginSetting_SingleKey(t *testing.T) {
+ s, _ := newTestStorage(t)
+
+ // Write a single key
+ if err := s.WritePluginSetting("my-plugin", "key1", "value1"); err != nil {
+ t.Fatalf("WritePluginSetting: %v", err)
+ }
+
+ // Read it back
+ val, err := s.ReadPluginSetting("my-plugin", "key1")
+ if err != nil {
+ t.Fatalf("ReadPluginSetting: %v", err)
+ }
+ if val != "value1" {
+ t.Errorf("key1 = %v, want value1", val)
+ }
+
+ // Write another key, first should be preserved
+ if err := s.WritePluginSetting("my-plugin", "key2", float64(99)); err != nil {
+ t.Fatalf("WritePluginSetting: %v", err)
+ }
+
+ settings, err := s.ReadPluginSettings("my-plugin")
+ if err != nil {
+ t.Fatalf("ReadPluginSettings: %v", err)
+ }
+ if settings["key1"] != "value1" {
+ t.Errorf("key1 after second write = %v, want value1", settings["key1"])
+ }
+ if settings["key2"] != float64(99) {
+ t.Errorf("key2 = %v, want 99", settings["key2"])
+ }
+
+ // Reading a missing key returns nil
+ val, err = s.ReadPluginSetting("my-plugin", "missing")
+ if err != nil {
+ t.Fatalf("ReadPluginSetting: %v", err)
+ }
+ if val != nil {
+ t.Errorf("missing key = %v, want nil", val)
+ }
+}
+
+// ─── Data JSON tests ─────────────────────────────────────────
+
+func TestPluginDataJSON_WriteRead(t *testing.T) {
+ s, _ := newTestStorage(t)
+
+ data := map[string]interface{}{
+ "items": []interface{}{"a", "b", "c"},
+ "meta": map[string]interface{}{"version": float64(1)},
+ }
+
+ if err := s.WritePluginDataJSON("data-plugin", "mydata", data); err != nil {
+ t.Fatalf("WritePluginDataJSON: %v", err)
+ }
+
+ got, err := s.ReadPluginDataJSON("data-plugin", "mydata")
+ if err != nil {
+ t.Fatalf("ReadPluginDataJSON: %v", err)
+ }
+
+ items, ok := got["items"].([]interface{})
+ if !ok {
+ t.Fatalf("items is not []interface{}, it's %T", got["items"])
+ }
+ if len(items) != 3 {
+ t.Errorf("items len = %d, want 3", len(items))
+ }
+
+ // Ensure separate names don't collide
+ got2, err := s.ReadPluginDataJSON("data-plugin", "other")
+ if err != nil {
+ t.Fatalf("ReadPluginDataJSON other: %v", err)
+ }
+ if len(got2) != 0 {
+ t.Errorf("expected empty map for other, got %v", got2)
+ }
+}
+
+// ─── Cache JSON tests ────────────────────────────────────────
+
+func TestPluginCacheJSON_WriteRead(t *testing.T) {
+ s, _ := newTestStorage(t)
+
+ data := map[string]interface{}{
+ "lastSync": "2025-01-01T00:00:00Z",
+ "hitRate": 0.95,
+ }
+
+ if err := s.WritePluginCacheJSON("cache-plugin", "sync-state", data); err != nil {
+ t.Fatalf("WritePluginCacheJSON: %v", err)
+ }
+
+ got, err := s.ReadPluginCacheJSON("cache-plugin", "sync-state")
+ if err != nil {
+ t.Fatalf("ReadPluginCacheJSON: %v", err)
+ }
+
+ if got["lastSync"] != "2025-01-01T00:00:00Z" {
+ t.Errorf("lastSync = %v", got["lastSync"])
+ }
+
+ // Empty read for missing cache
+ got2, err := s.ReadPluginCacheJSON("cache-plugin", "nope")
+ if err != nil {
+ t.Fatalf("ReadPluginCacheJSON nope: %v", err)
+ }
+ if len(got2) != 0 {
+ t.Errorf("expected empty map, got %v", got2)
+ }
+}
+
+// ─── Path traversal tests ────────────────────────────────────
+
+func TestPathTraversal_Blocked(t *testing.T) {
+ s, _ := newTestStorage(t)
+
+ traversalIDs := []string{
+ "..",
+ "../evil",
+ "foo/../../bar",
+ "/absolute",
+ `backslash\traverse`,
+ }
+
+ for _, id := range traversalIDs {
+ t.Run(id, func(t *testing.T) {
+ err := s.WritePluginSettings(id, map[string]interface{}{"x": 1})
+ if err == nil {
+ t.Errorf("WritePluginSettings(%q): expected error, got nil", id)
+ }
+
+ _, err = s.ReadPluginSettings(id)
+ if err == nil {
+ t.Errorf("ReadPluginSettings(%q): expected error, got nil", id)
+ }
+ })
+ }
+
+ // Empty pluginID should also be rejected
+ err := s.WritePluginSettings("", map[string]interface{}{})
+ if err == nil {
+ t.Error("WritePluginSettings(\"\"): expected error, got nil")
+ }
+}
+
+// ─── Atomic write tests ──────────────────────────────────────
+
+func TestAtomicWrite(t *testing.T) {
+ s, dir := newTestStorage(t)
+
+ data := map[string]interface{}{
+ "key": "value",
+ "n": float64(123),
+ }
+
+ if err := s.WritePluginSettings("atomic-plugin", data); err != nil {
+ t.Fatalf("WritePluginSettings: %v", err)
+ }
+
+ // Verify no .tmp files remain in the settings directory
+ settingsDir := filepath.Join(dir, "VerstakVault", ".verstak", "plugin-settings", "atomic-plugin")
+ entries, err := os.ReadDir(settingsDir)
+ if err != nil {
+ t.Fatalf("ReadDir: %v", err)
+ }
+ for _, e := range entries {
+ if filepath.Ext(e.Name()) == ".tmp" || (len(e.Name()) > 4 && e.Name()[:4] == ".tmp") {
+ t.Errorf("leftover temp file found: %s", e.Name())
+ }
+ }
+}
diff --git a/main.go b/main.go
index 69a02ac..6ccc2ff 100644
--- a/main.go
+++ b/main.go
@@ -18,6 +18,7 @@ import (
"github.com/verstak/verstak-desktop/internal/core/events"
"github.com/verstak/verstak-desktop/internal/core/permissions"
"github.com/verstak/verstak-desktop/internal/core/plugin"
+ "github.com/verstak/verstak-desktop/internal/core/storage"
"github.com/verstak/verstak-desktop/internal/core/vault"
)
@@ -155,7 +156,8 @@ func main() {
loaded, degraded, failed, vaultService.GetVaultStatus())
// Create the App struct
- app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService)
+ storageService := storage.New(vaultService)
+ app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService)
// ─── Wails App ───────────────────────────────────────────
err := wails.Run(&options.App{