diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 16a60b0..4b94dee 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,17 +1,16 @@ # CodeRabbit Configuration File # Reference: https://docs.coderabbit.ai/spec/configuration +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json language: "en-US" -tone_instruction: "Review code as a senior Go software engineer focusing on library design, modularity interfaces, testing utilities, and decoupling patterns." reviews: profile: "assertive" + tone_instructions: "Review code as a senior Go software engineer focusing on library design, modularity interfaces, testing utilities, and decoupling patterns." auto_review: enabled: true drafts: false high_level_summary: true - reviews_per_file: true - collapse_dependency_reviews: true chat: auto_reply: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8e78571 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.1.0] - 2026-06-05 + +### Added + +- Initial modular commerce kernel design and MDK decoupled integrations. +- Decoupled testing harness and interfaces. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..662d04c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +## Supported Versions + +For pre-1.0 releases we support the latest release or the latest patch series (e.g., 0.1.x), and for 1.0+ we support the latest major release branch. + +## Reporting a Vulnerability + +If you discover a security vulnerability within Hyperrr or any of its modules, please do not disclose it publicly. Report it directly by emailing security@hyperrr.org or opening a draft security advisory on GitHub. + +We will acknowledge receipt of your report within 48 hours and work with you to patch the issue promptly. diff --git a/identity.go b/identity.go index 5ae8aff..33735e1 100644 --- a/identity.go +++ b/identity.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "time" ) // ActorType represents the type of entity performing an action. @@ -46,32 +45,56 @@ func (m *JSONMap) Scan(value interface{}) error { return json.Unmarshal(bytes, m) } -// Actor represents a generic identity in the system. -type Actor struct { - ID string `gorm:"primaryKey" json:"id"` - Type ActorType `gorm:"index" json:"type"` - Name string `json:"name"` - Metadata JSONMap `gorm:"type:text" json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +// Actor represents the minimal interface for any security principal or identity. +type Actor interface { + GetID() string + GetType() ActorType + GetName() string + GetMetadata() map[string]string +} + +// BaseActor is a simple, serializable struct that implements mdk.Actor. +type BaseActor struct { + ID string `json:"id"` + Type ActorType `json:"type"` + Name string `json:"name"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +var _ Actor = (*BaseActor)(nil) + +func (b *BaseActor) GetID() string { + return b.ID +} + +func (b *BaseActor) GetType() ActorType { + return b.Type +} + +func (b *BaseActor) GetName() string { + return b.Name +} + +func (b *BaseActor) GetMetadata() map[string]string { + return b.Metadata } type contextKey struct{} var actorKey = contextKey{} // WithActor stores the Actor in the context. -func WithActor(ctx context.Context, actor *Actor) context.Context { +func WithActor(ctx context.Context, actor Actor) context.Context { return context.WithValue(ctx, actorKey, actor) } // ActorFromContext retrieves the Actor from the context. -func ActorFromContext(ctx context.Context) (*Actor, bool) { - actor, ok := ctx.Value(actorKey).(*Actor) +func ActorFromContext(ctx context.Context) (Actor, bool) { + actor, ok := ctx.Value(actorKey).(Actor) return actor, ok } // TokenValidator defines the interface for validating authentication tokens. type TokenValidator interface { - ValidateToken(ctx context.Context, token string) (*Actor, error) + ValidateToken(ctx context.Context, token string) (Actor, error) } diff --git a/testing.go b/mdktest/testing.go similarity index 71% rename from testing.go rename to mdktest/testing.go index fda80c5..8fa48fc 100644 --- a/testing.go +++ b/mdktest/testing.go @@ -1,13 +1,15 @@ -package mdk +package mdktest import ( "context" "fmt" "log/slog" + "reflect" "sync" "sync/atomic" "time" + "github.com/GoHyperrr/mdk" "gorm.io/gorm" ) @@ -18,7 +20,7 @@ type TestRuntime struct { workflowEngine *TestWorkflowEngine configs map[string]any logger *slog.Logger - modules map[string]Module + modules map[string]mdk.Module mu sync.RWMutex } @@ -28,7 +30,7 @@ func NewTestRuntime(db *gorm.DB) *TestRuntime { db: db, configs: make(map[string]any), logger: slog.Default(), - modules: make(map[string]Module), + modules: make(map[string]mdk.Module), } tr.bus = NewTestEventBus(tr) tr.workflowEngine = NewTestWorkflowEngine(tr) @@ -39,11 +41,11 @@ func (tr *TestRuntime) DB() *gorm.DB { return tr.db } -func (tr *TestRuntime) Bus() EventBus { +func (tr *TestRuntime) Bus() mdk.EventBus { return tr.bus } -func (tr *TestRuntime) Workflows() WorkflowEngine { +func (tr *TestRuntime) Workflows() mdk.WorkflowEngine { return tr.workflowEngine } @@ -67,14 +69,14 @@ func (tr *TestRuntime) SetLogger(l *slog.Logger) { tr.logger = l } -func (tr *TestRuntime) Module(id string) (Module, bool) { +func (tr *TestRuntime) Module(id string) (mdk.Module, bool) { tr.mu.RLock() defer tr.mu.RUnlock() m, ok := tr.modules[id] return m, ok } -func (tr *TestRuntime) SetModule(id string, m Module) { +func (tr *TestRuntime) SetModule(id string, m mdk.Module) { tr.mu.Lock() defer tr.mu.Unlock() tr.modules[id] = m @@ -84,18 +86,18 @@ func (tr *TestRuntime) SetModule(id string, m Module) { type TestEventBus struct { rt *TestRuntime mu sync.RWMutex - handlers map[string][]EventHandler - Published []Event + handlers map[string][]mdk.EventHandler + Published []mdk.Event } func NewTestEventBus(rt *TestRuntime) *TestEventBus { return &TestEventBus{ rt: rt, - handlers: make(map[string][]EventHandler), + handlers: make(map[string][]mdk.EventHandler), } } -func (teb *TestEventBus) Publish(ctx context.Context, e Event) error { +func (teb *TestEventBus) Publish(ctx context.Context, e mdk.Event) error { teb.mu.Lock() if e.OccurredAt.IsZero() { e.OccurredAt = time.Now() @@ -104,10 +106,9 @@ func (teb *TestEventBus) Publish(ctx context.Context, e Event) error { teb.mu.Unlock() teb.mu.RLock() - // Match key "namespace.type" or namespace.* key := e.Namespace + "." + e.Type - handlers := append([]EventHandler{}, teb.handlers[key]...) - wildcardHandlers := append([]EventHandler{}, teb.handlers[e.Namespace+".*"]...) + handlers := append([]mdk.EventHandler{}, teb.handlers[key]...) + wildcardHandlers := append([]mdk.EventHandler{}, teb.handlers[e.Namespace+".*"]...) teb.mu.RUnlock() for _, h := range handlers { @@ -119,7 +120,7 @@ func (teb *TestEventBus) Publish(ctx context.Context, e Event) error { return nil } -func (teb *TestEventBus) Subscribe(namespace, eventType string, handler EventHandler) (func(), error) { +func (teb *TestEventBus) Subscribe(namespace, eventType string, handler mdk.EventHandler) (func(), error) { teb.mu.Lock() defer teb.mu.Unlock() key := namespace + "." + eventType @@ -130,11 +131,10 @@ func (teb *TestEventBus) Subscribe(namespace, eventType string, handler EventHan defer teb.mu.Unlock() handlers := teb.handlers[key] for i, h := range handlers { - // Basic clean up if unsubscribe is needed. - // Since we can't easily compare func pointers in Go directly, - // a simplified unsubscribe is fine for test usage. - _ = h - _ = i + if reflect.ValueOf(h).Pointer() == reflect.ValueOf(handler).Pointer() { + teb.handlers[key] = append(handlers[:i], handlers[i+1:]...) + break + } } }, nil } @@ -145,30 +145,30 @@ var runIDCounter int64 type TestWorkflowEngine struct { rt *TestRuntime mu sync.RWMutex - workflows map[string]Workflow - handlers map[string]StepHandler - runs map[string]StepStatus + workflows map[string]mdk.Workflow + handlers map[string]mdk.StepHandler + runs map[string]mdk.StepStatus outputs map[string]map[string]any } func NewTestWorkflowEngine(rt *TestRuntime) *TestWorkflowEngine { return &TestWorkflowEngine{ rt: rt, - workflows: make(map[string]Workflow), - handlers: make(map[string]StepHandler), - runs: make(map[string]StepStatus), + workflows: make(map[string]mdk.Workflow), + handlers: make(map[string]mdk.StepHandler), + runs: make(map[string]mdk.StepStatus), outputs: make(map[string]map[string]any), } } -func (twe *TestWorkflowEngine) Register(w Workflow) error { +func (twe *TestWorkflowEngine) Register(w mdk.Workflow) error { twe.mu.Lock() defer twe.mu.Unlock() twe.workflows[w.ID] = w return nil } -func (twe *TestWorkflowEngine) RegisterHandler(name string, handler StepHandler) error { +func (twe *TestWorkflowEngine) RegisterHandler(name string, handler mdk.StepHandler) error { twe.mu.Lock() defer twe.mu.Unlock() twe.handlers[name] = handler @@ -179,12 +179,12 @@ func (twe *TestWorkflowEngine) Execute(ctx context.Context, workflowID string, i val := atomic.AddInt64(&runIDCounter, 1) runID := fmt.Sprintf("wf_run_%d_%d", time.Now().UnixNano(), val) go func() { - _, _ = twe.ExecuteSync(context.Background(), runID, workflowID, input) + _, _ = twe.ExecuteSync(ctx, runID, workflowID, input) }() return runID, nil } -func (twe *TestWorkflowEngine) Status(ctx context.Context, runID string) (StepStatus, error) { +func (twe *TestWorkflowEngine) Status(ctx context.Context, runID string) (mdk.StepStatus, error) { twe.mu.RLock() defer twe.mu.RUnlock() return twe.runs[runID], nil @@ -193,7 +193,7 @@ func (twe *TestWorkflowEngine) Status(ctx context.Context, runID string) (StepSt func (twe *TestWorkflowEngine) Cancel(ctx context.Context, runID string) error { twe.mu.Lock() defer twe.mu.Unlock() - twe.runs[runID] = StepFailed + twe.runs[runID] = mdk.StepFailed return nil } @@ -206,7 +206,7 @@ func (twe *TestWorkflowEngine) ExecuteSync(ctx context.Context, runID, workflowI } twe.mu.Lock() - twe.runs[runID] = StepRunning + twe.runs[runID] = mdk.StepRunning twe.mu.Unlock() results := make(map[string]any) @@ -218,10 +218,10 @@ func (twe *TestWorkflowEngine) ExecuteSync(ctx context.Context, runID, workflowI completed := make(map[string]bool) launched := make(map[string]bool) - var history []Step + var history []mdk.Step for len(completed) < len(wf.Steps) { - var ready []Step + var ready []mdk.Step for _, step := range wf.Steps { if launched[step.ID] { continue @@ -240,7 +240,7 @@ func (twe *TestWorkflowEngine) ExecuteSync(ctx context.Context, runID, workflowI if len(ready) == 0 { twe.mu.Lock() - twe.runs[runID] = StepFailed + twe.runs[runID] = mdk.StepFailed twe.mu.Unlock() return results, fmt.Errorf("deadlock detected or unresolved dependencies in test workflow execution") } @@ -248,20 +248,17 @@ func (twe *TestWorkflowEngine) ExecuteSync(ctx context.Context, runID, workflowI for _, step := range ready { launched[step.ID] = true twe.mu.RLock() - handler := step.Handler - if handler == nil && step.Uses != "" { - handler = twe.handlers[step.Uses] - } + handler := twe.handlers[step.Uses] twe.mu.RUnlock() if handler == nil { twe.mu.Lock() - twe.runs[runID] = StepFailed + twe.runs[runID] = mdk.StepFailed twe.mu.Unlock() return results, fmt.Errorf("handler not found for step %s (uses %s)", step.ID, step.Uses) } - sCtx := StepContext{ + sCtx := mdk.StepContext{ Ctx: ctx, Runtime: twe.rt, WorkflowID: workflowID, @@ -273,14 +270,14 @@ func (twe *TestWorkflowEngine) ExecuteSync(ctx context.Context, runID, workflowI res := handler(sCtx) if res.Err != nil { twe.mu.Lock() - twe.runs[runID] = StepFailed + twe.runs[runID] = mdk.StepFailed twe.mu.Unlock() // Run compensations in reverse order for i := len(history) - 1; i >= 0; i-- { hStep := history[i] - compensate := hStep.Compensate - if compensate == nil && hStep.Saga != nil && hStep.Saga.Uses != "" { + var compensate mdk.StepHandler + if hStep.Saga != nil && hStep.Saga.Uses != "" { twe.mu.RLock() h := twe.handlers[hStep.Saga.Uses] twe.mu.RUnlock() @@ -289,7 +286,7 @@ func (twe *TestWorkflowEngine) ExecuteSync(ctx context.Context, runID, workflowI } } if compensate != nil { - sCtxComp := StepContext{ + sCtxComp := mdk.StepContext{ Ctx: ctx, Runtime: twe.rt, WorkflowID: workflowID, @@ -314,7 +311,7 @@ func (twe *TestWorkflowEngine) ExecuteSync(ctx context.Context, runID, workflowI } twe.mu.Lock() - twe.runs[runID] = StepCompleted + twe.runs[runID] = mdk.StepCompleted twe.outputs[runID] = results twe.mu.Unlock() @@ -329,7 +326,7 @@ type TestLineageData struct { Error string StartedAt time.Time EndedAt *time.Time - Events []Event + Events []mdk.Event } func (tld TestLineageData) GetID() string { return tld.ID } @@ -338,19 +335,19 @@ func (tld TestLineageData) GetState() string { return tld.State } func (tld TestLineageData) GetError() string { return tld.Error } func (tld TestLineageData) GetStartedAt() time.Time { return tld.StartedAt } func (tld TestLineageData) GetEndedAt() *time.Time { return tld.EndedAt } -func (tld TestLineageData) GetEvents() []Event { return tld.Events } +func (tld TestLineageData) GetEvents() []mdk.Event { return tld.Events } // TestProjector implements Projector for testing. type TestProjector struct { - Lineages []LineageData + Lineages []mdk.LineageData } -func (tp *TestProjector) ListLineages() []LineageData { +func (tp *TestProjector) ListLineages() []mdk.LineageData { return tp.Lineages } -func (tp *TestProjector) QueryLineages(filter func(LineageData) bool) []LineageData { - var out []LineageData +func (tp *TestProjector) QueryLineages(filter func(mdk.LineageData) bool) []mdk.LineageData { + var out []mdk.LineageData for _, l := range tp.Lineages { if filter(l) { out = append(out, l) @@ -359,31 +356,36 @@ func (tp *TestProjector) QueryLineages(filter func(LineageData) bool) []LineageD return out } -// TestContextModule mock implementation of core.context module. -type TestContextModule struct { - Proj Projector +// ProjectorModule is a generic mock implementation of a module that provides a Projector. +type ProjectorModule struct { + ModuleID string + Proj mdk.Projector } -func (tcm *TestContextModule) ID() string { +// ID returns the module ID, defaulting to "core.context". +func (pm *ProjectorModule) ID() string { + if pm.ModuleID != "" { + return pm.ModuleID + } return "core.context" } -func (tcm *TestContextModule) Models() []any { +func (pm *ProjectorModule) Init(ctx context.Context, rt mdk.Runtime) error { return nil } -func (tcm *TestContextModule) Routes() []Route { +func (pm *ProjectorModule) Shutdown(ctx context.Context) error { return nil } -func (tcm *TestContextModule) Init(ctx context.Context, rt Runtime) error { +func (pm *ProjectorModule) Models() []any { return nil } -func (tcm *TestContextModule) Shutdown(ctx context.Context) error { +func (pm *ProjectorModule) Routes() []mdk.Route { return nil } -func (tcm *TestContextModule) Projector() Projector { - return tcm.Proj +func (pm *ProjectorModule) Projector() mdk.Projector { + return pm.Proj } diff --git a/models.go b/models.go index 0823f2a..2b91453 100644 --- a/models.go +++ b/models.go @@ -1,4 +1,45 @@ package mdk -// This file is intentionally left empty. Domain models are defined within their -// respective packages (e.g. commerce, auth) to preserve modularity. +import ( + "database/sql/driver" + "encoding/json" + "errors" +) + +// Metadata represents custom optional JSON metadata stored as a text/json field. +type Metadata map[string]interface{} + +// Value returns the driver Value. +func (m Metadata) Value() (driver.Value, error) { + if m == nil { + return nil, nil + } + ba, err := json.Marshal(m) + if err != nil { + return nil, err + } + return string(ba), nil +} + +// Scan scans value into Metadata. +func (m *Metadata) Scan(val interface{}) error { + if val == nil { + *m = nil + return nil + } + var ba []byte + switch v := val.(type) { + case []byte: + ba = v + case string: + ba = []byte(v) + default: + return errors.New("failed to scan Metadata: invalid type") + } + t := make(map[string]interface{}) + if err := json.Unmarshal(ba, &t); err != nil { + return err + } + *m = t + return nil +} diff --git a/models_test.go b/models_test.go new file mode 100644 index 0000000..91e425d --- /dev/null +++ b/models_test.go @@ -0,0 +1,82 @@ +package mdk + +import ( + "encoding/json" + "testing" +) + +func TestMetadataValueAndScan(t *testing.T) { + t.Run("Scan valid JSON string", func(t *testing.T) { + var m Metadata + jsonStr := `{"key1":"value1","key2":123.45}` + err := m.Scan(jsonStr) + if err != nil { + t.Fatalf("unexpected scan error: %v", err) + } + if m["key1"] != "value1" || m["key2"] != 123.45 { + t.Errorf("unexpected scan output: %+v", m) + } + }) + + t.Run("Scan valid []byte JSON", func(t *testing.T) { + var m Metadata + jsonBytes := []byte(`{"hello":"world"}`) + err := m.Scan(jsonBytes) + if err != nil { + t.Fatalf("unexpected scan error: %v", err) + } + if m["hello"] != "world" { + t.Errorf("expected world, got %v", m["hello"]) + } + }) + + t.Run("Scan nil value", func(t *testing.T) { + var m Metadata = Metadata{"existing": "data"} + err := m.Scan(nil) + if err != nil { + t.Fatalf("unexpected scan error: %v", err) + } + if m != nil { + t.Errorf("expected m to be nil, got %+v", m) + } + }) + + t.Run("Scan invalid type", func(t *testing.T) { + var m Metadata + err := m.Scan(123) + if err == nil { + t.Error("expected error for non-string/non-byte scan input") + } + }) + + t.Run("Value serialization success", func(t *testing.T) { + m := Metadata{"foo": "bar", "num": 1} + val, err := m.Value() + if err != nil { + t.Fatalf("unexpected Value() error: %v", err) + } + valStr, ok := val.(string) + if !ok { + t.Fatalf("expected string driver value, got %T", val) + } + + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(valStr), &parsed); err != nil { + t.Fatalf("failed to parse Value output: %v", err) + } + if parsed["foo"] != "bar" || parsed["num"] != 1.0 { + t.Errorf("unexpected parsed values: %+v", parsed) + } + }) + + t.Run("Value serialization nil map", func(t *testing.T) { + var m Metadata + val, err := m.Value() + if err != nil { + t.Fatalf("unexpected Value() error: %v", err) + } + if val != nil { + t.Errorf("expected nil value, got %v", val) + } + }) +} diff --git a/module.go b/module.go index 072d84a..20df52b 100644 --- a/module.go +++ b/module.go @@ -38,6 +38,6 @@ type Factory func() Module // HTMLUIProvider can be implemented by modules that want to expose a dashboard UI to MCP. type HTMLUIProvider interface { - RenderHTML(ctx context.Context) (accent, accentGlow, title, content string) + RenderHTML(ctx context.Context) (title, html string, err error) } diff --git a/registry.go b/registry.go index ccf9f11..50342cb 100644 --- a/registry.go +++ b/registry.go @@ -47,43 +47,5 @@ func Registered() map[string]Factory { return out } -// CLICommand represents a dynamic subcommand registered by a module. -type CLICommand struct { - Group string // Command group: "auth", "commerce", etc. - Name string // Subcommand name: "apikey", "product", etc. - Aliases []string // Alternative names - Short string // One-line description - Long string // Detailed description (shown in --help) - Usage string // Args pattern: "generate", " " - Run func(rt Runtime, args []string) error - NeedsDB bool // If true, auto-connect DB before Run - NeedsServer bool // If true, requires running server (validates connectivity) -} - -var ( - commandsMu sync.RWMutex - commands = make(map[string]CLICommand) -) - -// RegisterCommand adds a dynamic CLI subcommand to the global mdk registry. -func RegisterCommand(cmd CLICommand) { - commandsMu.Lock() - defer commandsMu.Unlock() - key := cmd.Name - if cmd.Group != "" { - key = cmd.Group + "/" + cmd.Name - } - commands[key] = cmd -} -// Commands returns a list of all registered custom CLI commands. -func Commands() []CLICommand { - commandsMu.RLock() - defer commandsMu.RUnlock() - res := make([]CLICommand, 0, len(commands)) - for _, cmd := range commands { - res = append(res, cmd) - } - return res -} diff --git a/workflow.go b/workflow.go index c5aa834..7a36171 100644 --- a/workflow.go +++ b/workflow.go @@ -44,14 +44,12 @@ type Saga struct { // Step is a single node in a workflow DAG. type Step struct { - ID string `json:"id" yaml:"id"` - Name string `json:"name" yaml:"name"` - DependsOn []string `json:"depends_on" yaml:"depends_on"` // step IDs this step waits for - Handler StepHandler `json:"-" yaml:"-"` - Compensate StepHandler `json:"-" yaml:"-"` - MaxRetries int `json:"max_retries" yaml:"max_retries"` - Uses string `json:"uses" yaml:"uses"` // For backwards compatibility and string-based resolution - Saga *Saga `json:"saga,omitempty" yaml:"saga,omitempty"` // For backwards compatibility saga rollback + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + DependsOn []string `json:"depends_on" yaml:"depends_on"` // step IDs this step waits for + MaxRetries int `json:"max_retries" yaml:"max_retries"` + Uses string `json:"uses" yaml:"uses"` // String-based resolution key + Saga *Saga `json:"saga,omitempty" yaml:"saga,omitempty"` } // Workflow is a declarative DAG of steps.