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

246 lines
6.2 KiB
Go

package workbench
import (
"bytes"
"io"
"net/http"
"strings"
"sync/atomic"
"time"
)
// DebugTransport is an http.RoundTripper that captures all HTTP traffic
// for debugging purposes.
type DebugTransport struct {
// Base is the underlying transport. If nil, http.DefaultTransport is used.
Base http.RoundTripper
// Debug is the debug store to record requests to
Debug *DebugStore
// Schemas is the schema manager for auto-inference (optional)
Schemas *SchemaManager
// MaxBodySize is the maximum size of request/response bodies to capture.
// Bodies larger than this will be truncated. Default is 64KB.
MaxBodySize int
// Enabled controls whether capture is active
enabled atomic.Bool
// SensitiveHeaders are headers that should be redacted in logs
SensitiveHeaders []string
}
// NewDebugTransport creates a new debug transport.
func NewDebugTransport(debug *DebugStore) *DebugTransport {
t := &DebugTransport{
Base: http.DefaultTransport,
Debug: debug,
MaxBodySize: 64 * 1024, // 64KB
SensitiveHeaders: []string{
"Authorization",
"X-Api-Key",
"X-API-Key",
"Api-Key",
"Apikey",
"Cookie",
"Set-Cookie",
"X-Auth-Token",
"X-Access-Token",
},
}
t.enabled.Store(true)
return t
}
// Enable enables HTTP capture.
func (t *DebugTransport) Enable() {
t.enabled.Store(true)
}
// Disable disables HTTP capture.
func (t *DebugTransport) Disable() {
t.enabled.Store(false)
}
// IsEnabled returns whether capture is enabled.
func (t *DebugTransport) IsEnabled() bool {
return t.enabled.Load()
}
// RoundTrip implements http.RoundTripper.
func (t *DebugTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if !t.enabled.Load() || t.Debug == nil {
return t.base().RoundTrip(req)
}
entry := HTTPRequestEntry{
Timestamp: time.Now(),
Method: req.Method,
URL: req.URL.String(),
}
// Capture request headers (redacting sensitive ones)
entry.RequestHeaders = t.captureHeaders(req.Header)
// Capture request body
if req.Body != nil && req.Body != http.NoBody {
bodyBytes, err := io.ReadAll(req.Body)
if err == nil {
_ = req.Body.Close()
// Restore the body for the actual request
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
entry.RequestBody = t.truncateBody(bodyBytes)
}
}
// Make the actual request
start := time.Now()
resp, err := t.base().RoundTrip(req)
entry.Duration = time.Since(start)
if err != nil {
entry.Error = err.Error()
t.Debug.LogHTTPRequest(entry)
return nil, err
}
// Capture response
entry.ResponseStatus = resp.StatusCode
entry.ResponseHeaders = t.captureHeaders(resp.Header)
// Capture response body
if resp.Body != nil {
bodyBytes, err := io.ReadAll(resp.Body)
if err == nil {
_ = resp.Body.Close()
// Restore the body for the caller
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
entry.ResponseBody = t.truncateBody(bodyBytes)
}
}
t.Debug.LogHTTPRequest(entry)
// Auto-infer schema from JSON responses
if t.Schemas != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 {
contentType := resp.Header.Get("Content-Type")
if strings.Contains(contentType, "application/json") && entry.ResponseBody != "" {
// Extract operation from URL path
operation := extractOperationFromURL(req.URL.Path)
_, _ = t.Schemas.InferFromJSON(operation, req.URL.Path, req.Method, []byte(entry.ResponseBody))
}
}
return resp, nil
}
// extractOperationFromURL extracts a reasonable operation name from a URL path.
func extractOperationFromURL(path string) string {
// Remove leading slash and split
path = strings.TrimPrefix(path, "/")
parts := strings.Split(path, "/")
// Try to find meaningful segment (skip version numbers, IDs)
for _, part := range parts {
// Skip version prefixes like v1, v2
if len(part) == 2 && part[0] == 'v' && part[1] >= '0' && part[1] <= '9' {
continue
}
// Skip empty or very short segments
if len(part) < 2 {
continue
}
// Skip segments that look like IDs (all hex, numbers, or UUIDs)
if looksLikeID(part) {
continue
}
return part
}
// Fallback to full path
return strings.ReplaceAll(path, "/", "_")
}
// looksLikeID checks if a string looks like an ID (UUID, hex, numeric).
func looksLikeID(s string) bool {
if len(s) > 30 {
return true // Long strings are likely IDs
}
// Check if it's all hex characters (with optional dashes for UUIDs)
allHex := true
for _, c := range s {
isHexDigit := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
if !isHexDigit && c != '-' && c != '_' {
allHex = false
break
}
}
if allHex && len(s) >= 8 {
return true
}
return false
}
func (t *DebugTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return http.DefaultTransport
}
func (t *DebugTransport) captureHeaders(h http.Header) map[string]string {
result := make(map[string]string)
for key, values := range h {
if t.isSensitiveHeader(key) {
result[key] = "[REDACTED]"
} else {
result[key] = strings.Join(values, ", ")
}
}
return result
}
func (t *DebugTransport) isSensitiveHeader(key string) bool {
keyLower := strings.ToLower(key)
for _, sensitive := range t.SensitiveHeaders {
if strings.ToLower(sensitive) == keyLower {
return true
}
}
return false
}
func (t *DebugTransport) truncateBody(body []byte) string {
if len(body) > t.MaxBodySize {
return string(body[:t.MaxBodySize]) + "\n... [truncated]"
}
return string(body)
}
// InstallGlobalTransport installs the debug transport as the default HTTP transport.
// This affects all HTTP clients that use http.DefaultTransport.
// Returns the previous default transport so it can be restored later.
func InstallGlobalTransport(debug *DebugStore) (*DebugTransport, http.RoundTripper) {
previous := http.DefaultTransport
transport := NewDebugTransport(debug)
transport.Base = previous
http.DefaultTransport = transport
return transport, previous
}
// RestoreGlobalTransport restores the default HTTP transport.
func RestoreGlobalTransport(original http.RoundTripper) {
http.DefaultTransport = original
}
// WrapTransport wraps an existing transport with debug capture.
func WrapTransport(base http.RoundTripper, debug *DebugStore) *DebugTransport {
transport := NewDebugTransport(debug)
transport.Base = base
return transport
}