Files
7838d00f57 feat: connector workbench (#641)
* wip: connector workbench

* wip: improve workbench

* feat(workbench): improve UI with sidebar, routing, and syntax highlighting

- Replace connector dropdown with Slack-style sidebar for connector selection
- Add URL-based routing with react-router-dom for browser navigation support
- Add JSON syntax highlighting with react-syntax-highlighter
- Rework State tab layout to list/detail view with filtering
- Update terminology from "create" to "instantiate" for connectors
- Fix duplicate selection bug in lists (ID generation collision)
- Unify data table row heights across tabs
- Increase code block font sizes for readability
- Hide navigation tabs on homepage when no connector selected
- Make CW logo clickable to return to home view

Co-authored-by: Cursor <[email protected]>

* fix(workbench): use textarea for PEM/certificate config fields

Fields containing 'privateKey', 'certificate', or 'pem' in their name
now use a textarea instead of single-line input, preserving newlines
in PEM content that would otherwise be stripped.

Co-authored-by: Cursor <[email protected]>

* fix(workbench): address PR review issues

- Fix data race on idCounter using atomic.Int64
- Fix path traversal vulnerability in introspect.go
- Fix pointer comparison for DefaultAsset in baseline.go

Co-authored-by: Cursor <[email protected]>

* feat(workbench): add generic connector server for integration testing

Expose the generic connector REST API from the workbench so staging
services can install a "generic connector" pointing to the local
workbench, enabling end-to-end integration testing.

- Add /generic/* routes (accounts, balances, beneficiaries, transactions)
- Return cached data from storage instead of making fresh API calls
- Add connector selection and optional API key authentication
- Add UI panel in Debug tab for configuration

Co-authored-by: Cursor <[email protected]>

* fix(workbench): use internal registry to avoid pkg dependency

Change imports from pkg/registry to internal/connectors/plugins/registry
so the workbench can be merged before the pkg refactoring. The internal
registry will automatically delegate to pkg/registry once that refactor
lands.

Also adds light/dark mode toggle to the UI with system preference detection.

Co-authored-by: Cursor <[email protected]>

* fix(workbench): address PR review feedback

- Add ctx.Done() checks in fetch loops to exit early on client disconnect
- Refactor generic server to get connector directly instead of via context,
  providing clearer data flow as suggested in review

Co-authored-by: Cursor <[email protected]>

* fix(workbench): include built UI files for embed directive

The //go:embed ui/dist/* directive requires files to exist at build time.
Add exception to .gitignore and commit the built UI distribution files.

Co-authored-by: Cursor <[email protected]>

* fix(workbench): address golangci-lint errors

- Remove unused globalDebugTransport variable
- Remove unused parseIntParam function
- Fix printf format for models.TaskType (use %v instead of %s)
- Add error return value handling for various function calls

Co-authored-by: Cursor <[email protected]>

* fix: remove .vite cache folder from git

Co-authored-by: Cursor <[email protected]>

* fix(workbench): fix remaining errcheck issue

Co-authored-by: Cursor <[email protected]>

* fix(workbench): fix all remaining lint issues

- Add error handling for Body.Close() calls
- Remove unnecessary fmt.Sprintf wrapper
- Simplify hex character check condition

Co-authored-by: Cursor <[email protected]>

* fix(workbench): fix errcheck in cmd/workbench.go

Co-authored-by: Cursor <[email protected]>

* chore: replace ui dist build artifacts with placeholder

The //go:embed directive needs at least one file to exist.
Instead of committing the entire built UI, commit only a
.gitkeep placeholder. The server has fallback code that
displays instructions when the full UI isn't available.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-02-12 18:29:17 +00:00

434 lines
11 KiB
Go

package workbench
import (
"encoding/json"
"sort"
"sync"
"time"
"github.com/formancehq/payments/internal/models"
)
// MemoryStorage provides an in-memory implementation of storage for the workbench.
// It stores only the data needed for connector development and testing.
type MemoryStorage struct {
mu sync.RWMutex
// Core data
accounts map[string]models.PSPAccount
payments map[string]models.PSPPayment
balances map[string]models.PSPBalance
others map[string][]models.PSPOther
// External accounts (beneficiaries, etc.)
externalAccounts map[string]models.PSPAccount
// State management
states map[string]json.RawMessage
// Workflow tree from install
tasksTree *models.ConnectorTasksTree
// Webhook configs
webhookConfigs []models.PSPWebhookConfig
// Statistics
stats StorageStats
}
// StorageStats tracks storage statistics.
type StorageStats struct {
AccountsCount int
PaymentsCount int
BalancesCount int
ExternalAccountsCount int
LastUpdated time.Time
}
// NewMemoryStorage creates a new in-memory storage.
func NewMemoryStorage() *MemoryStorage {
return &MemoryStorage{
accounts: make(map[string]models.PSPAccount),
payments: make(map[string]models.PSPPayment),
balances: make(map[string]models.PSPBalance),
others: make(map[string][]models.PSPOther),
externalAccounts: make(map[string]models.PSPAccount),
states: make(map[string]json.RawMessage),
}
}
// Clear clears all stored data.
func (s *MemoryStorage) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.accounts = make(map[string]models.PSPAccount)
s.payments = make(map[string]models.PSPPayment)
s.balances = make(map[string]models.PSPBalance)
s.others = make(map[string][]models.PSPOther)
s.externalAccounts = make(map[string]models.PSPAccount)
s.states = make(map[string]json.RawMessage)
s.tasksTree = nil
s.webhookConfigs = nil
s.stats = StorageStats{}
}
// === Accounts ===
// StoreAccounts stores accounts.
func (s *MemoryStorage) StoreAccounts(accounts []models.PSPAccount) {
s.mu.Lock()
defer s.mu.Unlock()
for _, acc := range accounts {
s.accounts[acc.Reference] = acc
}
s.stats.AccountsCount = len(s.accounts)
s.stats.LastUpdated = time.Now()
}
// GetAccounts returns all accounts sorted by creation time, then reference.
func (s *MemoryStorage) GetAccounts() []models.PSPAccount {
s.mu.RLock()
defer s.mu.RUnlock()
accounts := make([]models.PSPAccount, 0, len(s.accounts))
for _, acc := range s.accounts {
accounts = append(accounts, acc)
}
sort.Slice(accounts, func(i, j int) bool {
if !accounts[i].CreatedAt.Equal(accounts[j].CreatedAt) {
return accounts[i].CreatedAt.Before(accounts[j].CreatedAt)
}
return accounts[i].Reference < accounts[j].Reference
})
return accounts
}
// GetAccount returns a specific account by reference.
func (s *MemoryStorage) GetAccount(reference string) (models.PSPAccount, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
acc, ok := s.accounts[reference]
return acc, ok
}
// === External Accounts ===
// StoreExternalAccounts stores external accounts.
func (s *MemoryStorage) StoreExternalAccounts(accounts []models.PSPAccount) {
s.mu.Lock()
defer s.mu.Unlock()
for _, acc := range accounts {
s.externalAccounts[acc.Reference] = acc
}
s.stats.ExternalAccountsCount = len(s.externalAccounts)
s.stats.LastUpdated = time.Now()
}
// GetExternalAccounts returns all external accounts sorted by creation time, then reference.
func (s *MemoryStorage) GetExternalAccounts() []models.PSPAccount {
s.mu.RLock()
defer s.mu.RUnlock()
accounts := make([]models.PSPAccount, 0, len(s.externalAccounts))
for _, acc := range s.externalAccounts {
accounts = append(accounts, acc)
}
sort.Slice(accounts, func(i, j int) bool {
if !accounts[i].CreatedAt.Equal(accounts[j].CreatedAt) {
return accounts[i].CreatedAt.Before(accounts[j].CreatedAt)
}
return accounts[i].Reference < accounts[j].Reference
})
return accounts
}
// === Payments ===
// StorePayments stores payments.
func (s *MemoryStorage) StorePayments(payments []models.PSPPayment) {
s.mu.Lock()
defer s.mu.Unlock()
for _, p := range payments {
s.payments[p.Reference] = p
}
s.stats.PaymentsCount = len(s.payments)
s.stats.LastUpdated = time.Now()
}
// GetPayments returns all payments sorted by creation time, then reference.
func (s *MemoryStorage) GetPayments() []models.PSPPayment {
s.mu.RLock()
defer s.mu.RUnlock()
payments := make([]models.PSPPayment, 0, len(s.payments))
for _, p := range s.payments {
payments = append(payments, p)
}
sort.Slice(payments, func(i, j int) bool {
if !payments[i].CreatedAt.Equal(payments[j].CreatedAt) {
return payments[i].CreatedAt.Before(payments[j].CreatedAt)
}
return payments[i].Reference < payments[j].Reference
})
return payments
}
// GetPayment returns a specific payment by reference.
func (s *MemoryStorage) GetPayment(reference string) (models.PSPPayment, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
p, ok := s.payments[reference]
return p, ok
}
// === Balances ===
// StoreBalances stores balances.
func (s *MemoryStorage) StoreBalances(balances []models.PSPBalance) {
s.mu.Lock()
defer s.mu.Unlock()
for _, b := range balances {
key := b.AccountReference + "/" + b.Asset
s.balances[key] = b
}
s.stats.BalancesCount = len(s.balances)
s.stats.LastUpdated = time.Now()
}
// GetBalances returns all balances.
func (s *MemoryStorage) GetBalances() []models.PSPBalance {
s.mu.RLock()
defer s.mu.RUnlock()
balances := make([]models.PSPBalance, 0, len(s.balances))
for _, b := range s.balances {
balances = append(balances, b)
}
sort.Slice(balances, func(i, j int) bool {
if balances[i].AccountReference != balances[j].AccountReference {
return balances[i].AccountReference < balances[j].AccountReference
}
if !balances[i].CreatedAt.Equal(balances[j].CreatedAt) {
return balances[i].CreatedAt.Before(balances[j].CreatedAt)
}
return balances[i].Asset < balances[j].Asset
})
return balances
}
// GetBalancesForAccount returns balances for a specific account.
func (s *MemoryStorage) GetBalancesForAccount(accountRef string) []models.PSPBalance {
s.mu.RLock()
defer s.mu.RUnlock()
var balances []models.PSPBalance
for _, b := range s.balances {
if b.AccountReference == accountRef {
balances = append(balances, b)
}
}
sort.Slice(balances, func(i, j int) bool {
if !balances[i].CreatedAt.Equal(balances[j].CreatedAt) {
return balances[i].CreatedAt.Before(balances[j].CreatedAt)
}
return balances[i].Asset < balances[j].Asset
})
return balances
}
// === Others ===
// StoreOthers stores other data by name.
func (s *MemoryStorage) StoreOthers(name string, others []models.PSPOther) {
s.mu.Lock()
defer s.mu.Unlock()
s.others[name] = append(s.others[name], others...)
s.stats.LastUpdated = time.Now()
}
// GetOthers returns other data by name.
func (s *MemoryStorage) GetOthers(name string) []models.PSPOther {
s.mu.RLock()
defer s.mu.RUnlock()
return s.others[name]
}
// GetAllOthers returns all other data.
func (s *MemoryStorage) GetAllOthers() map[string][]models.PSPOther {
s.mu.RLock()
defer s.mu.RUnlock()
result := make(map[string][]models.PSPOther)
for k, v := range s.others {
result[k] = v
}
return result
}
// === State Management ===
// SaveState saves state for a given key.
func (s *MemoryStorage) SaveState(key string, state json.RawMessage) {
s.mu.Lock()
defer s.mu.Unlock()
s.states[key] = state
}
// GetState returns state for a given key.
func (s *MemoryStorage) GetState(key string) json.RawMessage {
s.mu.RLock()
defer s.mu.RUnlock()
return s.states[key]
}
// GetAllStates returns all states.
func (s *MemoryStorage) GetAllStates() map[string]json.RawMessage {
s.mu.RLock()
defer s.mu.RUnlock()
result := make(map[string]json.RawMessage)
for k, v := range s.states {
result[k] = v
}
return result
}
// ClearState clears state for a given key.
func (s *MemoryStorage) ClearState(key string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.states, key)
}
// ClearAllStates clears all states.
func (s *MemoryStorage) ClearAllStates() {
s.mu.Lock()
defer s.mu.Unlock()
s.states = make(map[string]json.RawMessage)
}
// === Tasks Tree ===
// SetTasksTree sets the connector tasks tree.
func (s *MemoryStorage) SetTasksTree(tree *models.ConnectorTasksTree) {
s.mu.Lock()
defer s.mu.Unlock()
s.tasksTree = tree
}
// GetTasksTree returns the connector tasks tree.
func (s *MemoryStorage) GetTasksTree() *models.ConnectorTasksTree {
s.mu.RLock()
defer s.mu.RUnlock()
return s.tasksTree
}
// === Webhook Configs ===
// SetWebhookConfigs sets webhook configs.
func (s *MemoryStorage) SetWebhookConfigs(configs []models.PSPWebhookConfig) {
s.mu.Lock()
defer s.mu.Unlock()
s.webhookConfigs = configs
}
// GetWebhookConfigs returns webhook configs.
func (s *MemoryStorage) GetWebhookConfigs() []models.PSPWebhookConfig {
s.mu.RLock()
defer s.mu.RUnlock()
return s.webhookConfigs
}
// === Statistics ===
// GetStats returns storage statistics.
func (s *MemoryStorage) GetStats() StorageStats {
s.mu.RLock()
defer s.mu.RUnlock()
return s.stats
}
// === Export/Import ===
// StorageSnapshot represents a snapshot of all storage data.
type StorageSnapshot struct {
Accounts map[string]models.PSPAccount `json:"accounts"`
Payments map[string]models.PSPPayment `json:"payments"`
Balances map[string]models.PSPBalance `json:"balances"`
ExternalAccounts map[string]models.PSPAccount `json:"external_accounts"`
Others map[string][]models.PSPOther `json:"others"`
States map[string]json.RawMessage `json:"states"`
TasksTree *models.ConnectorTasksTree `json:"tasks_tree,omitempty"`
WebhookConfigs []models.PSPWebhookConfig `json:"webhook_configs,omitempty"`
ExportedAt time.Time `json:"exported_at"`
}
// Export exports all storage data as a snapshot.
func (s *MemoryStorage) Export() StorageSnapshot {
s.mu.RLock()
defer s.mu.RUnlock()
return StorageSnapshot{
Accounts: s.accounts,
Payments: s.payments,
Balances: s.balances,
ExternalAccounts: s.externalAccounts,
Others: s.others,
States: s.states,
TasksTree: s.tasksTree,
WebhookConfigs: s.webhookConfigs,
ExportedAt: time.Now(),
}
}
// Import imports storage data from a snapshot.
func (s *MemoryStorage) Import(snapshot StorageSnapshot) {
s.mu.Lock()
defer s.mu.Unlock()
if snapshot.Accounts != nil {
s.accounts = snapshot.Accounts
}
if snapshot.Payments != nil {
s.payments = snapshot.Payments
}
if snapshot.Balances != nil {
s.balances = snapshot.Balances
}
if snapshot.ExternalAccounts != nil {
s.externalAccounts = snapshot.ExternalAccounts
}
if snapshot.Others != nil {
s.others = snapshot.Others
}
if snapshot.States != nil {
s.states = snapshot.States
}
s.tasksTree = snapshot.TasksTree
s.webhookConfigs = snapshot.WebhookConfigs
s.stats.AccountsCount = len(s.accounts)
s.stats.PaymentsCount = len(s.payments)
s.stats.BalancesCount = len(s.balances)
s.stats.ExternalAccountsCount = len(s.externalAccounts)
s.stats.LastUpdated = time.Now()
}