diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js index 66525d8..e242f9b 100644 --- a/frontend/src/lib/plugin-host/VerstakPluginAPI.js +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -439,6 +439,21 @@ export function createPluginAPI(pluginId) { } }, + browserReceiver: { + pairing: function() { + assertActive('browserReceiver.pairing'); + return callBackend(pluginId, 'browserReceiver.pairing', function() { + return App.PluginBrowserReceiverPairing(pluginId); + }); + }, + rotateToken: function() { + assertActive('browserReceiver.rotateToken'); + return callBackend(pluginId, 'browserReceiver.rotateToken', function() { + return App.PluginRotateBrowserReceiverToken(pluginId); + }); + } + }, + workbench: { openResource: async function(request) { assertActive('workbench.openResource'); diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index efdcb52..32398a5 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -94,6 +94,10 @@ export function PluginSecretsUnlock(arg1:string,arg2:string):Promise; export function PluginSecretsWrite(arg1:string,arg2:Record):Promise|string>; +export function PluginBrowserReceiverPairing(arg1:string):Promise|string>; + +export function PluginRotateBrowserReceiverToken(arg1:string):Promise|string>; + export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string):Promise; export function PluginSyncDisconnect(arg1:string):Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index 5a8a35b..6d50289 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -174,6 +174,14 @@ export function PluginSecretsWrite(arg1, arg2) { return window['go']['api']['App']['PluginSecretsWrite'](arg1, arg2); } +export function PluginBrowserReceiverPairing(arg1) { + return window['go']['api']['App']['PluginBrowserReceiverPairing'](arg1); +} + +export function PluginRotateBrowserReceiverToken(arg1) { + return window['go']['api']['App']['PluginRotateBrowserReceiverToken'](arg1); +} + export function PluginSyncConfigure(arg1, arg2, arg3, arg4) { return window['go']['api']['App']['PluginSyncConfigure'](arg1, arg2, arg3, arg4); } diff --git a/internal/api/app.go b/internal/api/app.go index 4e5599f..cf3b163 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -15,6 +15,7 @@ import ( "github.com/wailsapp/wails/v2/pkg/runtime" "github.com/verstak/verstak-desktop/internal/core/appsettings" + "github.com/verstak/verstak-desktop/internal/core/browserreceiver" "github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/events" @@ -62,6 +63,7 @@ type App struct { workbench *coreworkbench.Router workspace *workspace.Manager syncSvc *syncsvc.Service + browserReceiver *browserreceiver.Receiver secretsSession *coresecrets.VaultSession fileWatcher *filewatcher.Service debug bool @@ -87,6 +89,7 @@ func NewApp( pluginStateMgr *pluginstate.Manager, workspaceMgr *workspace.Manager, syncService *syncsvc.Service, + browserReceiverService *browserreceiver.Receiver, debugEnabled bool, ) *App { app := &App{ @@ -104,6 +107,7 @@ func NewApp( workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)), workspace: workspaceMgr, syncSvc: syncService, + browserReceiver: browserReceiverService, fileWatcher: filewatcher.NewService(bus, 0), debug: debugEnabled, activityEvents: make(map[string]bool), @@ -2105,6 +2109,62 @@ func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string) return string(data), "" } +// ─── Browser Receiver API ────────────────────────────────── + +func (a *App) requirePluginBrowserReceiverAccess(pluginID string) error { + _, err := a.requirePluginAccess(pluginID, "browser.receiver.manage") + return err +} + +func (a *App) browserReceiverPairing() (map[string]string, error) { + if a.browserReceiver == nil { + return nil, fmt.Errorf("browser receiver is unavailable") + } + if a.appSettings == nil { + return nil, fmt.Errorf("app settings not initialized") + } + token := strings.TrimSpace(a.appSettings.Get().BrowserReceiver.Token) + if token == "" { + return nil, fmt.Errorf("browser receiver token is unavailable") + } + return map[string]string{ + "receiverUrl": browserreceiver.DefaultCaptureURL, + "receiverToken": token, + }, nil +} + +// PluginBrowserReceiverPairing returns the local receiver settings for authorized plugins. +func (a *App) PluginBrowserReceiverPairing(pluginID string) (map[string]string, string) { + if err := a.requirePluginBrowserReceiverAccess(pluginID); err != nil { + return nil, err.Error() + } + pairing, err := a.browserReceiverPairing() + if err != nil { + return nil, err.Error() + } + return pairing, "" +} + +// PluginRotateBrowserReceiverToken invalidates previous extension pairings. +func (a *App) PluginRotateBrowserReceiverToken(pluginID string) (map[string]string, string) { + if err := a.requirePluginBrowserReceiverAccess(pluginID); err != nil { + return nil, err.Error() + } + if _, err := a.browserReceiverPairing(); err != nil { + return nil, err.Error() + } + token, err := a.appSettings.RotateBrowserReceiverToken() + if err != nil { + return nil, err.Error() + } + a.browserReceiver.SetReceiverToken(token) + pairing, err := a.browserReceiverPairing() + if err != nil { + return nil, err.Error() + } + return pairing, "" +} + // ─── Sync API ────────────────────────────────────────────── func (a *App) requirePluginSyncAccess(pluginID string, remote bool) error { diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 5274bf8..d4bc3c7 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/verstak/verstak-desktop/internal/core/appsettings" + "github.com/verstak/verstak-desktop/internal/core/browserreceiver" "github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/events" @@ -2000,6 +2001,54 @@ func TestPluginSyncBridgeRequiresDeclaredPermissions(t *testing.T) { } } +func TestPluginBrowserReceiverPairingRequiresPermissionAndRotatesToken(t *testing.T) { + app := newBridgeTestApp(t) + app.appSettings = appsettings.NewManager(filepath.Join(t.TempDir(), "config.json")) + if err := app.appSettings.Load(); err != nil { + t.Fatalf("settings Load: %v", err) + } + initialToken, err := app.appSettings.EnsureBrowserReceiverToken() + if err != nil { + t.Fatalf("EnsureBrowserReceiverToken: %v", err) + } + app.browserReceiver = browserreceiver.NewWithOptions(app.eventBus, browserreceiver.Options{ + RequireToken: true, + ReceiverToken: initialToken, + }) + app.plugins = append(app.plugins, plugin.Plugin{ + Manifest: plugin.Manifest{ + ID: "browser.local", + Name: "Browser Local", + Version: "1.0.0", + Permissions: []string{"browser.receiver.manage"}, + }, + Status: plugin.StatusLoaded, + Enabled: true, + }) + + pairing, errStr := app.PluginBrowserReceiverPairing("browser.local") + if errStr != "" { + t.Fatalf("PluginBrowserReceiverPairing: %s", errStr) + } + if pairing["receiverToken"] != initialToken { + t.Fatalf("pairing token = %q, want %q", pairing["receiverToken"], initialToken) + } + if pairing["receiverUrl"] != browserreceiver.DefaultCaptureURL { + t.Fatalf("pairing receiver URL = %q, want %q", pairing["receiverUrl"], browserreceiver.DefaultCaptureURL) + } + if _, errStr := app.PluginBrowserReceiverPairing("no.storage"); !strings.Contains(errStr, "browser.receiver.manage") { + t.Fatalf("missing permission error = %q", errStr) + } + + rotated, errStr := app.PluginRotateBrowserReceiverToken("browser.local") + if errStr != "" { + t.Fatalf("PluginRotateBrowserReceiverToken: %s", errStr) + } + if rotated["receiverToken"] == "" || rotated["receiverToken"] == initialToken { + t.Fatalf("rotated pairing token = %q, want new non-empty token", rotated["receiverToken"]) + } +} + func TestPluginSyncStatusReportsPersistedError(t *testing.T) { app := newBridgeTestApp(t) app.plugins = append(app.plugins, diff --git a/internal/core/appsettings/manager.go b/internal/core/appsettings/manager.go index 68ce713..3c1a685 100644 --- a/internal/core/appsettings/manager.go +++ b/internal/core/appsettings/manager.go @@ -4,26 +4,30 @@ package appsettings import ( + "crypto/rand" + "encoding/base64" "encoding/json" "fmt" "os" "path/filepath" + "strings" "sync" "time" ) // Config represents the application settings stored in ~/.config/verstak/config.json. type Config struct { - SchemaVersion int `json:"schemaVersion"` - CurrentVaultPath string `json:"currentVaultPath"` - RecentVaults []string `json:"recentVaults"` - Theme string `json:"theme"` - DevMode bool `json:"devMode"` - UserPluginsDir string `json:"userPluginsDir"` - Workbench WorkbenchPreferences `json:"workbench,omitempty"` - Sync SyncSettings `json:"sync,omitempty"` - WindowState *WindowState `json:"windowState,omitempty"` - LastOpenedAt string `json:"lastOpenedAt"` + SchemaVersion int `json:"schemaVersion"` + CurrentVaultPath string `json:"currentVaultPath"` + RecentVaults []string `json:"recentVaults"` + Theme string `json:"theme"` + DevMode bool `json:"devMode"` + UserPluginsDir string `json:"userPluginsDir"` + Workbench WorkbenchPreferences `json:"workbench,omitempty"` + Sync SyncSettings `json:"sync,omitempty"` + BrowserReceiver BrowserReceiverSettings `json:"browserReceiver,omitempty"` + WindowState *WindowState `json:"windowState,omitempty"` + LastOpenedAt string `json:"lastOpenedAt"` } type WorkbenchPreferences struct { @@ -44,6 +48,11 @@ type SyncSettings struct { LastError string `json:"lastError,omitempty"` } +// BrowserReceiverSettings holds the installation-local browser capture pairing secret. +type BrowserReceiverSettings struct { + Token string `json:"token,omitempty"` +} + // WindowState stores the last window position and size. type WindowState struct { Width int `json:"width"` @@ -205,6 +214,42 @@ func (m *Manager) UpdateSync(syncSettings SyncSettings) error { return m.saveLocked() } +// EnsureBrowserReceiverToken returns the persisted pairing token, creating it when absent. +func (m *Manager) EnsureBrowserReceiverToken() (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.updateBrowserReceiverTokenLocked(false) +} + +// RotateBrowserReceiverToken replaces the persisted pairing token. +func (m *Manager) RotateBrowserReceiverToken() (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.updateBrowserReceiverTokenLocked(true) +} + +func (m *Manager) updateBrowserReceiverTokenLocked(force bool) (string, error) { + if m.config == nil { + m.config = defaultConfig() + } + current := strings.TrimSpace(m.config.BrowserReceiver.Token) + if current != "" && !force { + return current, nil + } + + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("generate browser receiver token: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(bytes) + m.config.BrowserReceiver.Token = token + if err := m.saveLocked(); err != nil { + m.config.BrowserReceiver.Token = current + return "", err + } + return token, nil +} + // SetCurrentVault updates the current vault path and adds to recents. func (m *Manager) SetCurrentVault(path string) error { m.mu.Lock() @@ -262,6 +307,7 @@ func copyConfig(c *Config) *Config { UserPluginsDir: c.UserPluginsDir, Workbench: c.Workbench, Sync: c.Sync, + BrowserReceiver: c.BrowserReceiver, LastOpenedAt: c.LastOpenedAt, } if c.WindowState != nil { diff --git a/internal/core/appsettings/manager_test.go b/internal/core/appsettings/manager_test.go index d36e5c2..3ed8726 100644 --- a/internal/core/appsettings/manager_test.go +++ b/internal/core/appsettings/manager_test.go @@ -52,6 +52,43 @@ func TestLoad_CorruptConfig(t *testing.T) { // Just verify no panic } +func TestBrowserReceiverTokenPersistsAndRotates(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + manager := NewManager(path) + if err := manager.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + firstToken, err := manager.EnsureBrowserReceiverToken() + if err != nil { + t.Fatalf("EnsureBrowserReceiverToken: %v", err) + } + if firstToken == "" { + t.Fatal("EnsureBrowserReceiverToken returned an empty token") + } + + reloaded := NewManager(path) + if err := reloaded.Load(); err != nil { + t.Fatalf("reload settings: %v", err) + } + persistedToken, err := reloaded.EnsureBrowserReceiverToken() + if err != nil { + t.Fatalf("EnsureBrowserReceiverToken after reload: %v", err) + } + if persistedToken != firstToken { + t.Fatalf("persisted token = %q, want %q", persistedToken, firstToken) + } + + rotatedToken, err := reloaded.RotateBrowserReceiverToken() + if err != nil { + t.Fatalf("RotateBrowserReceiverToken: %v", err) + } + if rotatedToken == "" || rotatedToken == firstToken { + t.Fatalf("rotated token = %q, want new non-empty token", rotatedToken) + } +} + func TestSetCurrentVault(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.json") diff --git a/internal/core/browserreceiver/receiver.go b/internal/core/browserreceiver/receiver.go index 5d25874..3be6a1b 100644 --- a/internal/core/browserreceiver/receiver.go +++ b/internal/core/browserreceiver/receiver.go @@ -14,14 +14,18 @@ import ( "net/http" "net/url" "strings" + "sync" "time" "github.com/verstak/verstak-desktop/internal/core/events" ) -const capturePath = "/api/browser-inbox/v1/captures" -const DefaultAddr = "127.0.0.1:47731" -const receiverTokenHeader = "X-Verstak-Receiver-Token" +const ( + capturePath = "/api/browser-inbox/v1/captures" + DefaultAddr = "127.0.0.1:47731" + DefaultCaptureURL = "http://" + DefaultAddr + capturePath + receiverTokenHeader = "X-Verstak-Receiver-Token" +) const ( maxCaptureBodyBytes = 12 * 1024 * 1024 @@ -44,6 +48,7 @@ const ( type Receiver struct { bus *events.Bus workspaceProvider WorkspaceProvider + optionsMu sync.RWMutex options Options } @@ -112,6 +117,17 @@ func NewWithOptions(bus *events.Bus, options Options, providers ...WorkspaceProv return &Receiver{bus: bus, workspaceProvider: provider, options: options} } +// SetReceiverToken updates the active token without restarting the local server. +func (r *Receiver) SetReceiverToken(token string) { + if r == nil { + return + } + r.optionsMu.Lock() + defer r.optionsMu.Unlock() + r.options.RequireToken = true + r.options.ReceiverToken = strings.TrimSpace(token) +} + func Start(addr string, receiver *Receiver) (*Server, error) { if receiver == nil { return nil, fmt.Errorf("receiver is required") @@ -213,10 +229,16 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) { } func (r *Receiver) validateReceiverToken(req *http.Request) error { - if r == nil || !r.options.RequireToken { + if r == nil { return nil } + r.optionsMu.RLock() + requireToken := r.options.RequireToken expected := strings.TrimSpace(r.options.ReceiverToken) + r.optionsMu.RUnlock() + if !requireToken { + return nil + } if expected == "" { return fmt.Errorf("receiver token required") } diff --git a/internal/core/browserreceiver/receiver_test.go b/internal/core/browserreceiver/receiver_test.go index 1e90701..15a4c54 100644 --- a/internal/core/browserreceiver/receiver_test.go +++ b/internal/core/browserreceiver/receiver_test.go @@ -281,6 +281,38 @@ func TestReceiverAcceptsPairedToken(t *testing.T) { } } +func TestReceiverRotatesPairedToken(t *testing.T) { + bus := events.NewBus() + bus.Subscribe("browser.capture.page", func(event events.Event) {}) + receiver := NewWithOptions(bus, Options{RequireToken: true, ReceiverToken: "old-token"}) + body := `{ + "schemaVersion": 1, + "captureId": "capture-rotated-token", + "capturedAt": "2026-06-27T00:00:00.000Z", + "kind": "page", + "page": {"url": "https://example.com"} + }` + + request := func(token string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, capturePath, bytes.NewBufferString(body)) + req.Header.Set(receiverTokenHeader, token) + res := httptest.NewRecorder() + receiver.ServeHTTP(res, req) + return res + } + + if res := request("old-token"); res.Code != http.StatusAccepted { + t.Fatalf("old token before rotation status = %d, want %d", res.Code, http.StatusAccepted) + } + receiver.SetReceiverToken("new-token") + if res := request("old-token"); res.Code != http.StatusUnauthorized { + t.Fatalf("old token after rotation status = %d, want %d", res.Code, http.StatusUnauthorized) + } + if res := request("new-token"); res.Code != http.StatusAccepted { + t.Fatalf("new token after rotation status = %d, want %d", res.Code, http.StatusAccepted) + } +} + func TestServerStartsOnLocalAddressAndAcceptsCapture(t *testing.T) { bus := events.NewBus() bus.Subscribe("browser.capture.page", func(event events.Event) {}) diff --git a/internal/core/permissions/registry.go b/internal/core/permissions/registry.go index d3deb8d..b2807f5 100644 --- a/internal/core/permissions/registry.go +++ b/internal/core/permissions/registry.go @@ -50,6 +50,7 @@ func (r *Registry) registerDefaults() { {Name: "secrets.read", Description: "Read secrets from the secret store", Dangerous: true}, {Name: "secrets.write", Description: "Write secrets to the secret store", Dangerous: true}, {Name: "sync.participate", Description: "Participate in vault sync", Dangerous: true}, + {Name: "browser.receiver.manage", Description: "View and rotate the local browser receiver pairing token", Dangerous: true}, } for _, e := range defaults { r.permissions[e.Name] = e diff --git a/main.go b/main.go index dfb3e19..646f02b 100644 --- a/main.go +++ b/main.go @@ -251,20 +251,36 @@ func main() { if vaultService.GetVaultStatus() == vault.StatusOpen { syncService = syncsvc.NewService(vaultService.GetVaultPath(), "") } - app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, debugEnabled) - browserReceiver := browserreceiver.New(eventBus, func() string { - current := app.GetCurrentWorkspace() - if root, ok := current["rootPath"].(string); ok { - return root - } - return "" - }) - browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver) + receiverToken, err := appSettingsMgr.EnsureBrowserReceiverToken() if err != nil { log.Printf("[browserreceiver] local receiver disabled: %v", err) - } else { - defer browserReceiverServer.Close() - log.Printf("[browserreceiver] local receiver listening at %s", browserReceiverServer.URL()) + } + var app *api.App + var browserReceiver *browserreceiver.Receiver + if receiverToken != "" { + browserReceiver = browserreceiver.NewWithOptions(eventBus, browserreceiver.Options{ + RequireToken: true, + ReceiverToken: receiverToken, + }, func() string { + if app == nil { + return "" + } + current := app.GetCurrentWorkspace() + if root, ok := current["rootPath"].(string); ok { + return root + } + return "" + }) + } + app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled) + if browserReceiver != nil { + browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver) + if err != nil { + log.Printf("[browserreceiver] local receiver disabled: %v", err) + } else { + defer browserReceiverServer.Close() + log.Printf("[browserreceiver] paired local receiver listening at %s", browserReceiverServer.URL()) + } } // ─── Wails App ───────────────────────────────────────────