feat: add core vault layer with capability registration

This commit is contained in:
mirivlad 2026-06-16 20:37:48 +08:00
parent 6832b01b23
commit d6793e8695
8 changed files with 658 additions and 7 deletions

View File

@ -167,6 +167,14 @@ func main() {
fmt.Printf(" ✅ registered %d core capabilities\n", len(coreCaps))
}
// Register vault capability (core service)
if err := reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
fmt.Printf(" ❌ register vault capability: %v\n", err)
allGood = false
} else {
fmt.Printf(" ✅ registered vault capability\n")
}
// Register plugin capabilities
for _, p := range m.Provides {
if err := reg.Register(m.ID, []string{p}); err != nil {
@ -232,10 +240,10 @@ func main() {
fmt.Printf("\n[capability count]\n")
totalCaps := len(reg.List())
fmt.Printf(" total capabilities: %d\n", totalCaps)
if totalCaps >= 7 {
fmt.Printf(" ✅ total capabilities >= 7 (%d)\n", totalCaps)
if totalCaps >= 8 {
fmt.Printf(" ✅ total capabilities >= 8 (%d)\n", totalCaps)
} else {
fmt.Printf(" ❌ total capabilities < 7 (got %d, expected >= 7)\n", totalCaps)
fmt.Printf(" ❌ total capabilities < 8 (got %d, expected >= 8)\n", totalCaps)
allGood = false
}

View File

@ -5,6 +5,10 @@ import {api} from '../models';
import {permissions} from '../models';
import {plugin} from '../models';
export function CloseVault():Promise<void>;
export function CreateVault(arg1:string):Promise<void>;
export function GetCapabilities():Promise<Array<capability.Entry>>;
export function GetContributions():Promise<api.ContributionSummary>;
@ -13,6 +17,10 @@ export function GetPermissions():Promise<Array<permissions.Entry>>;
export function GetPlugins():Promise<Array<plugin.Plugin>>;
export function GetVaultStatus():Promise<Record<string, string>>;
export function OpenVault(arg1:string):Promise<void>;
export function ReloadPlugins():Promise<number|string>;
export function Startup():Promise<void>;

View File

@ -2,6 +2,14 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function CloseVault() {
return window['go']['api']['App']['CloseVault']();
}
export function CreateVault(arg1) {
return window['go']['api']['App']['CreateVault'](arg1);
}
export function GetCapabilities() {
return window['go']['api']['App']['GetCapabilities']();
}
@ -18,6 +26,14 @@ export function GetPlugins() {
return window['go']['api']['App']['GetPlugins']();
}
export function GetVaultStatus() {
return window['go']['api']['App']['GetVaultStatus']();
}
export function OpenVault(arg1) {
return window['go']['api']['App']['OpenVault'](arg1);
}
export function ReloadPlugins() {
return window['go']['api']['App']['ReloadPlugins']();
}

6
go.mod
View File

@ -2,14 +2,16 @@ module github.com/verstak/verstak-desktop
go 1.24.4
require github.com/wailsapp/wails/v2 v2.12.0
require (
github.com/google/uuid v1.6.0
github.com/wailsapp/wails/v2 v2.12.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect

View File

@ -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/vault"
)
// App is the main application struct exposed to the Wails frontend.
@ -22,6 +23,7 @@ type App struct {
permRegistry *permissions.Registry
eventBus *events.Bus
plugins []plugin.Plugin
vault *vault.Vault
}
// NewApp creates a new App instance.
@ -31,6 +33,7 @@ func NewApp(
permReg *permissions.Registry,
bus *events.Bus,
plugins []plugin.Plugin,
vaultService *vault.Vault,
) *App {
return &App{
capRegistry: capReg,
@ -38,6 +41,7 @@ func NewApp(
permRegistry: permReg,
eventBus: bus,
plugins: plugins,
vault: vaultService,
}
}
@ -124,6 +128,13 @@ func (a *App) ReloadPlugins() (int, string) {
log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err)
}
// Re-register vault capability if vault is open
if a.vault != nil && a.vault.GetVaultStatus() == vault.StatusOpen {
if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register vault capability: %v", err)
}
}
plugins, errs := plugin.DiscoverPlugins(discoveryDirs)
// Plugin lifecycle: register capabilities + contributions
@ -184,6 +195,55 @@ func (a *App) ReloadPlugins() (int, string) {
return len(plugins), summary
}
// ─── Vault API ──────────────────────────────────────────────
// GetVaultStatus returns the current vault status, path, and vault ID.
func (a *App) GetVaultStatus() map[string]string {
status := "not-created"
path := ""
vaultID := ""
if a.vault != nil {
status = string(a.vault.GetVaultStatus())
path = a.vault.GetVaultPath()
meta := a.vault.GetVaultMeta()
if meta != nil {
vaultID = meta.VaultID
}
}
return map[string]string{
"status": status,
"path": path,
"vaultId": vaultID,
}
}
// CreateVault creates a new vault at the given path.
func (a *App) CreateVault(path string) error {
if a.vault == nil {
return fmt.Errorf("vault service not initialized")
}
return a.vault.CreateVault(path)
}
// OpenVault opens an existing vault at the given path.
func (a *App) OpenVault(path string) error {
if a.vault == nil {
return fmt.Errorf("vault service not initialized")
}
return a.vault.OpenVault(path)
}
// CloseVault closes the current vault.
func (a *App) CloseVault() error {
if a.vault == nil {
return fmt.Errorf("vault service not initialized")
}
a.vault.CloseVault()
return nil
}
// ContributionSummary aggregates all contribution types for the frontend.
type ContributionSummary struct {
Views []contribution.ContributionView `json:"views"`

View File

@ -0,0 +1,297 @@
// Package vault provides the core vault service for managing Verstak vaults.
// A vault is a directory that stores plugin data, settings, cache, and metadata.
package vault
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/verstak/verstak-desktop/internal/core/events"
)
// VaultStatus represents the current state of a vault.
type VaultStatus string
const (
StatusNotCreated VaultStatus = "not-created"
StatusClosed VaultStatus = "closed"
StatusOpen VaultStatus = "open"
StatusError VaultStatus = "error"
)
// VaultMeta stores metadata about a vault, persisted in .verstak/vault.json.
type VaultMeta struct {
SchemaVersion int `json:"schemaVersion"`
VaultID string `json:"vaultId"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
App string `json:"app"`
}
// Vault manages a Verstak vault directory and its layout.
type Vault struct {
mu sync.RWMutex
status VaultStatus
path string
meta *VaultMeta
eventBus *events.Bus
}
// NewVault creates a new Vault instance with the given event bus.
func NewVault(bus *events.Bus) *Vault {
return &Vault{
status: StatusNotCreated,
path: "",
meta: nil,
eventBus: bus,
}
}
// GetVaultStatus returns the current vault status.
func (v *Vault) GetVaultStatus() VaultStatus {
v.mu.RLock()
defer v.mu.RUnlock()
return v.status
}
// GetVaultPath returns the current vault path.
func (v *Vault) GetVaultPath() string {
v.mu.RLock()
defer v.mu.RUnlock()
return v.path
}
// GetVaultMeta returns the current vault metadata.
func (v *Vault) GetVaultMeta() *VaultMeta {
v.mu.RLock()
defer v.mu.RUnlock()
return v.meta
}
// CreateVault creates a new vault at the given path.
func (v *Vault) CreateVault(path string) error {
if err := ValidateVaultPath(path); err != nil {
return fmt.Errorf("invalid vault path: %w", err)
}
vaultDir := filepath.Join(path, "VerstakVault")
// Create VerstakVault directory
if err := os.MkdirAll(vaultDir, 0o755); err != nil {
return fmt.Errorf("failed to create vault directory: %w", err)
}
// Ensure .verstak layout
if err := EnsureVaultLayout(vaultDir); err != nil {
return fmt.Errorf("failed to create vault layout: %w", err)
}
// Generate metadata
now := time.Now().UTC().Format(time.RFC3339)
meta := &VaultMeta{
SchemaVersion: 1,
VaultID: uuid.New().String(),
CreatedAt: now,
UpdatedAt: now,
App: "verstak",
}
// Write vault.json
metaPath := filepath.Join(vaultDir, ".verstak", "vault.json")
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal vault meta: %w", err)
}
if err := os.WriteFile(metaPath, data, 0o644); err != nil {
return fmt.Errorf("failed to write vault.json: %w", err)
}
v.mu.Lock()
v.status = StatusOpen
v.path = vaultDir
v.meta = meta
v.mu.Unlock()
// Publish event
if v.eventBus != nil {
v.eventBus.Publish(events.Event{
Name: "vault.created",
Payload: map[string]string{"path": v.path, "vaultId": v.meta.VaultID},
})
}
return nil
}
// OpenVault opens an existing vault at the given path.
func (v *Vault) OpenVault(path string) error {
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("failed to resolve path: %w", err)
}
metaPath := filepath.Join(absPath, ".verstak", "vault.json")
data, err := os.ReadFile(metaPath)
if err != nil {
return fmt.Errorf("failed to read vault.json: %w", err)
}
var meta VaultMeta
if err := json.Unmarshal(data, &meta); err != nil {
return fmt.Errorf("failed to parse vault.json: %w", err)
}
// Validate metadata
if meta.SchemaVersion != 1 {
return fmt.Errorf("unsupported schema version: %d", meta.SchemaVersion)
}
if meta.VaultID == "" {
return errors.New("vault ID is empty")
}
// Ensure layout exists
if err := EnsureVaultLayout(absPath); err != nil {
return fmt.Errorf("failed to ensure vault layout: %w", err)
}
v.mu.Lock()
v.status = StatusOpen
v.path = absPath
v.meta = &meta
v.mu.Unlock()
// Publish event
if v.eventBus != nil {
v.eventBus.Publish(events.Event{
Name: "vault.opened",
Payload: map[string]string{"path": v.path, "vaultId": v.meta.VaultID},
})
}
return nil
}
// CloseVault closes the current vault.
func (v *Vault) CloseVault() {
v.mu.Lock()
defer v.mu.Unlock()
if v.status == StatusClosed {
return
}
vaultID := ""
if v.meta != nil {
vaultID = v.meta.VaultID
}
v.status = StatusClosed
v.path = ""
v.meta = nil
if v.eventBus != nil {
v.eventBus.Publish(events.Event{
Name: "vault.closed",
Payload: map[string]string{"vaultId": vaultID},
})
}
}
// EnsureVaultLayout creates the .verstak directory and standard subdirectories
// if they do not already exist.
func EnsureVaultLayout(basePath string) error {
subdirs := []string{
".verstak/plugin-data",
".verstak/plugin-settings",
".verstak/plugin-cache",
".verstak/trash",
".verstak/logs",
}
for _, sub := range subdirs {
dir := filepath.Join(basePath, sub)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create %s: %w", sub, err)
}
}
return nil
}
// ValidateVaultPath checks that the given path is a valid, safe vault path.
func ValidateVaultPath(path string) error {
if path == "" {
return errors.New("path is empty")
}
cleaned := filepath.Clean(path)
if !filepath.IsAbs(cleaned) {
return errors.New("path must be absolute")
}
// Check for null bytes
if strings.Contains(cleaned, "\x00") {
return errors.New("path contains null bytes")
}
return nil
}
// ResolveSafePath resolves a relative path within the vault, preventing
// path traversal attacks.
func (v *Vault) ResolveSafePath(relative string) (string, error) {
v.mu.RLock()
defer v.mu.RUnlock()
if v.status != StatusOpen || v.path == "" {
return "", errors.New("vault is not open")
}
result := filepath.Join(v.path, relative)
result = filepath.Clean(result)
if !strings.HasPrefix(result, v.path) {
return "", errors.New("path traversal detected")
}
return result, nil
}
// GetPluginDataPath returns the data directory for a plugin, creating it if needed.
func (v *Vault) GetPluginDataPath(pluginID string) string {
v.mu.RLock()
defer v.mu.RUnlock()
dir := filepath.Join(v.path, ".verstak", "plugin-data", pluginID)
os.MkdirAll(dir, 0o755)
return dir
}
// GetPluginSettingsPath returns the settings directory for a plugin, creating it if needed.
func (v *Vault) GetPluginSettingsPath(pluginID string) string {
v.mu.RLock()
defer v.mu.RUnlock()
dir := filepath.Join(v.path, ".verstak", "plugin-settings", pluginID)
os.MkdirAll(dir, 0o755)
return dir
}
// GetPluginCachePath returns the cache directory for a plugin, creating it if needed.
func (v *Vault) GetPluginCachePath(pluginID string) string {
v.mu.RLock()
defer v.mu.RUnlock()
dir := filepath.Join(v.path, ".verstak", "plugin-cache", pluginID)
os.MkdirAll(dir, 0o755)
return dir
}

View File

@ -0,0 +1,249 @@
package vault
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/verstak/verstak-desktop/internal/core/events"
)
func TestCreateVault_CreatesLayoutAndMeta(t *testing.T) {
base := t.TempDir()
bus := events.NewBus()
v := NewVault(bus)
err := v.CreateVault(base)
if err != nil {
t.Fatalf("CreateVault failed: %v", err)
}
vaultDir := filepath.Join(base, "VerstakVault")
// Check vault.json exists
metaPath := filepath.Join(vaultDir, ".verstak", "vault.json")
data, err := os.ReadFile(metaPath)
if err != nil {
t.Fatalf("vault.json not found: %v", err)
}
var meta VaultMeta
if err := json.Unmarshal(data, &meta); err != nil {
t.Fatalf("failed to parse vault.json: %v", err)
}
if meta.SchemaVersion != 1 {
t.Errorf("schemaVersion: got %d, want 1", meta.SchemaVersion)
}
if meta.VaultID == "" {
t.Error("vaultId is empty")
}
if meta.App != "verstak" {
t.Errorf("app: got %q, want %q", meta.App, "verstak")
}
// Check subdirectories
expectedDirs := []string{
".verstak/plugin-data",
".verstak/plugin-settings",
".verstak/plugin-cache",
".verstak/trash",
".verstak/logs",
}
for _, dir := range expectedDirs {
full := filepath.Join(vaultDir, dir)
info, err := os.Stat(full)
if err != nil {
t.Errorf("directory %s not found: %v", dir, err)
continue
}
if !info.IsDir() {
t.Errorf("%s is not a directory", dir)
}
}
}
func TestOpenVault_ReadsExistingVaultId(t *testing.T) {
base := t.TempDir()
bus := events.NewBus()
v := NewVault(bus)
// Create vault
if err := v.CreateVault(base); err != nil {
t.Fatalf("CreateVault failed: %v", err)
}
// Remember the vault ID
meta := v.GetVaultMeta()
if meta == nil {
t.Fatal("meta is nil after CreateVault")
}
originalID := meta.VaultID
// Close vault
v.CloseVault()
// Open vault
vaultDir := filepath.Join(base, "VerstakVault")
if err := v.OpenVault(vaultDir); err != nil {
t.Fatalf("OpenVault failed: %v", err)
}
// Verify vault ID matches
newMeta := v.GetVaultMeta()
if newMeta == nil {
t.Fatal("meta is nil after OpenVault")
}
if newMeta.VaultID != originalID {
t.Errorf("vault ID mismatch: got %q, want %q", newMeta.VaultID, originalID)
}
}
func TestOpenVault_CorruptJSON_Error(t *testing.T) {
base := t.TempDir()
vaultDir := filepath.Join(base, "VerstakVault")
// Create vault directory with corrupt vault.json
if err := os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755); err != nil {
t.Fatalf("failed to create .verstak dir: %v", err)
}
if err := os.WriteFile(filepath.Join(vaultDir, ".verstak", "vault.json"), []byte("{corrupt"), 0o644); err != nil {
t.Fatalf("failed to write corrupt vault.json: %v", err)
}
bus := events.NewBus()
v := NewVault(bus)
err := v.OpenVault(vaultDir)
if err == nil {
t.Fatal("expected error for corrupt vault.json, got nil")
}
if !strings.Contains(err.Error(), "failed to parse vault.json") {
t.Errorf("unexpected error: %v", err)
}
}
func TestResolveSafePath_BlocksTraversal(t *testing.T) {
base := t.TempDir()
bus := events.NewBus()
v := NewVault(bus)
if err := v.CreateVault(base); err != nil {
t.Fatalf("CreateVault failed: %v", err)
}
// Path traversal should be blocked
_, err := v.ResolveSafePath("../../etc/passwd")
if err == nil {
t.Fatal("expected error for path traversal, got nil")
}
if !strings.Contains(err.Error(), "path traversal detected") {
t.Errorf("unexpected error: %v", err)
}
// Normal path should work
result, err := v.ResolveSafePath("normal/path")
if err != nil {
t.Fatalf("ResolveSafePath failed for normal path: %v", err)
}
if !strings.Contains(result, "normal") {
t.Errorf("unexpected result path: %s", result)
}
}
func TestGetPluginDataPath_CreatesNamespace(t *testing.T) {
base := t.TempDir()
bus := events.NewBus()
v := NewVault(bus)
if err := v.CreateVault(base); err != nil {
t.Fatalf("CreateVault failed: %v", err)
}
path := v.GetPluginDataPath("test-plugin")
if !strings.Contains(path, "plugin-data") {
t.Errorf("path does not contain plugin-data: %s", path)
}
if !strings.Contains(path, "test-plugin") {
t.Errorf("path does not contain test-plugin: %s", path)
}
// Verify directory was created
info, err := os.Stat(path)
if err != nil {
t.Fatalf("plugin data directory not created: %v", err)
}
if !info.IsDir() {
t.Error("plugin data path is not a directory")
}
}
func TestVaultStatus_Transitions(t *testing.T) {
base := t.TempDir()
bus := events.NewBus()
v := NewVault(bus)
// New vault → not-created
if status := v.GetVaultStatus(); status != StatusNotCreated {
t.Errorf("initial status: got %q, want %q", status, StatusNotCreated)
}
// Create → open
if err := v.CreateVault(base); err != nil {
t.Fatalf("CreateVault failed: %v", err)
}
if status := v.GetVaultStatus(); status != StatusOpen {
t.Errorf("after create: got %q, want %q", status, StatusOpen)
}
// Close → closed
v.CloseVault()
if status := v.GetVaultStatus(); status != StatusClosed {
t.Errorf("after close: got %q, want %q", status, StatusClosed)
}
}
func TestVaultEvents_Published(t *testing.T) {
base := t.TempDir()
bus := events.NewBus()
v := NewVault(bus)
// Collect events
var published []string
bus.Subscribe("vault.created", func(e events.Event) {
published = append(published, e.Name)
})
bus.Subscribe("vault.opened", func(e events.Event) {
published = append(published, e.Name)
})
bus.Subscribe("vault.closed", func(e events.Event) {
published = append(published, e.Name)
})
// Create
if err := v.CreateVault(base); err != nil {
t.Fatalf("CreateVault failed: %v", err)
}
// Close
v.CloseVault()
// Open
vaultDir := filepath.Join(base, "VerstakVault")
if err := v.OpenVault(vaultDir); err != nil {
t.Fatalf("OpenVault failed: %v", err)
}
// Verify events
expected := []string{"vault.created", "vault.closed", "vault.opened"}
if len(published) != len(expected) {
t.Fatalf("expected %d events, got %d: %v", len(expected), len(published), published)
}
for i, name := range expected {
if published[i] != name {
t.Errorf("event %d: got %q, want %q", i, published[i], name)
}
}
}

15
main.go
View File

@ -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/vault"
)
//go:embed frontend/dist
@ -43,6 +44,9 @@ func main() {
permRegistry := permissions.NewRegistry()
eventBus := events.NewBus()
// ─── Initialize Vault ────────────────────────────────────
vaultService := vault.NewVault(eventBus)
// ─── Register Core Capabilities ─────────────────────────
// These are provided by the desktop core itself, not by plugins.
// Registered before plugin discovery so that plugins can resolve
@ -60,6 +64,12 @@ func main() {
}
log.Printf("[main] registered %d core capabilities", len(coreCaps))
// Register vault capability (vault is available as a core service)
if err := capRegistry.Register(corePluginID, []string{"verstak/core/vault/v1"}); err != nil {
log.Fatalf("[main] failed to register vault capability: %v", err)
}
log.Printf("[main] registered vault capability")
// ─── Plugin Discovery ───────────────────────────────────
discoveryDirs := []string{
"~/.config/verstak/plugins",
@ -136,10 +146,11 @@ func main() {
failed++
}
}
log.Printf("[main] lifecycle summary: loaded=%d degraded=%d failed=%d", loaded, degraded, failed)
log.Printf("[main] lifecycle summary: loaded=%d degraded=%d failed=%d vault=%s",
loaded, degraded, failed, vaultService.GetVaultStatus())
// Create the App struct
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins)
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService)
// ─── Wails App ───────────────────────────────────────────
err := wails.Run(&options.App{