79a3358810
web/handlers/window.go: GET /window (list), GET /window/new (form),
POST /window (open), GET /window/{id} (detail+lifecycle+audit), POST
/window/{id}/activate|revoke|expire (lifecycle transitions). Store
extensions: OpenWindow/ActivateWindow/RevokeWindow/ExpireWindow/
ListWindows/GetWindow/GetAuditLog — all call the REAL x/window/types
Window.Activate/Revoke/Expire methods (not reimplementation). Revoke on
Expired is a no-op (v0.2 terminal-state contract). AuditEntry appended on
each transition. 3 Window templates. window_test.go: lifecycle
correctness (asserts real methods invoked), idempotent revoke, revoke-on-
expired no-op, G-026 error lexicon checks. Coverage: store 99.2%,
handlers 87.7%.
---ci---
project: oy
phase: 3
milestone: v0.6
status: execute
---/ci---
294 lines
9.0 KiB
Go
294 lines
9.0 KiB
Go
// Package store is the in-memory mock data layer for the OpenYield web UI.
|
|
//
|
|
// It instantiates the real x/*/types structs (Reach, Stash, StashActivity)
|
|
// from in-memory fixtures and provides create/get/list methods. This is the
|
|
// app-layer consumption of protocol types (D-070), NOT a cross-x/ production
|
|
// import — web/ is NOT an x/ module. No keeper, no Cosmos runtime, no app.go
|
|
// (G-003 boundary enforced by import_test.go / G-025).
|
|
package store
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
identitytypes "github.com/oy/openyield/x/identity/types"
|
|
stashtypes "github.com/oy/openyield/x/stash/types"
|
|
windowtypes "github.com/oy/openyield/x/window/types"
|
|
)
|
|
|
|
// seedBalanceGrain is the test balance seeded to a new Stash at signup (D-071
|
|
// example: 500000 Grain = 50 Bread per GrainsPerBread=10000).
|
|
const seedBalanceGrain int64 = 500000
|
|
|
|
// Store is the in-memory mock store. All methods are goroutine-safe (mu).
|
|
type Store struct {
|
|
mu sync.Mutex
|
|
reaches map[string]identitytypes.Reach
|
|
stashes map[string]stashtypes.Stash
|
|
stashActivities map[string]stashtypes.StashActivity
|
|
windows map[string]windowtypes.Window
|
|
auditLogs map[string][]windowtypes.AuditEntry
|
|
}
|
|
|
|
// NewStore constructs a Store seeded from fixtures (fixtures.go).
|
|
func NewStore() *Store {
|
|
s := &Store{
|
|
reaches: map[string]identitytypes.Reach{},
|
|
stashes: map[string]stashtypes.Stash{},
|
|
stashActivities: map[string]stashtypes.StashActivity{},
|
|
windows: map[string]windowtypes.Window{},
|
|
auditLogs: map[string][]windowtypes.AuditEntry{},
|
|
}
|
|
s.seed()
|
|
return s
|
|
}
|
|
|
|
// CreateReach atomically creates a Reach (IsNomad=true) + a Stash (D-071).
|
|
// G-027: HolderID and PublicKey are validated (non-empty, <=128 bytes, no
|
|
// path separators, no template syntax) before any map write. Returns the
|
|
// created Reach + Stash.
|
|
func (s *Store) CreateReach(holderID, publicKey string) (identitytypes.Reach, stashtypes.Stash, error) {
|
|
if err := validateReachInput(holderID, publicKey); err != nil {
|
|
return identitytypes.Reach{}, stashtypes.Stash{}, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, dup := s.reaches[holderID]; dup {
|
|
return identitytypes.Reach{}, stashtypes.Stash{}, fmt.Errorf("holder %q already has a Reach", holderID)
|
|
}
|
|
now := time.Now().Unix()
|
|
reachID := "reach-" + holderID
|
|
stashID := "stash-" + holderID
|
|
reach := identitytypes.Reach{
|
|
ReachID: reachID,
|
|
HolderID: holderID,
|
|
CreatedAt: now,
|
|
PublicKey: publicKey,
|
|
IsNomad: true,
|
|
}
|
|
stash := stashtypes.Stash{
|
|
HolderID: holderID,
|
|
StashID: stashID,
|
|
CreatedAt: now,
|
|
LastActive: now,
|
|
BalanceGrain: seedBalanceGrain,
|
|
}
|
|
activity := stashtypes.StashActivity{
|
|
StashID: stashID,
|
|
ActiveDays: 1,
|
|
MaxGapDays: 1,
|
|
LastActivityDay: now,
|
|
}
|
|
s.reaches[holderID] = reach
|
|
s.stashes[holderID] = stash
|
|
s.stashActivities[stashID] = activity
|
|
return reach, stash, nil
|
|
}
|
|
|
|
// ListReaches returns all seeded + created Reaches.
|
|
func (s *Store) ListReaches() []identitytypes.Reach {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]identitytypes.Reach, 0, len(s.reaches))
|
|
for _, r := range s.reaches {
|
|
out = append(out, r)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetReach returns the Reach for a holderID (by HolderID, the stable key).
|
|
func (s *Store) GetReach(holderID string) (identitytypes.Reach, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
r, ok := s.reaches[holderID]
|
|
return r, ok
|
|
}
|
|
|
|
// GetStash returns the Stash for a holderID.
|
|
func (s *Store) GetStash(holderID string) (stashtypes.Stash, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
st, ok := s.stashes[holderID]
|
|
return st, ok
|
|
}
|
|
|
|
// GetStashActivity returns the StashActivity for a stashID.
|
|
func (s *Store) GetStashActivity(stashID string) (stashtypes.StashActivity, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
a, ok := s.stashActivities[stashID]
|
|
return a, ok
|
|
}
|
|
|
|
// OpenWindow creates a new Window in the Open status (REQ-042) with an initial
|
|
// AuditEntry. Returns the created Window. The Window is keyed by a generated
|
|
// windowID derived from the grantor + a counter (mock; not cryptographic).
|
|
func (s *Store) OpenWindow(grantorHolder, grantee string, scope windowtypes.Scope, start, end int64, rateLimit windowtypes.RateLimit) (windowtypes.Window, error) {
|
|
if grantorHolder == "" {
|
|
return windowtypes.Window{}, fmt.Errorf("grantor holder is required")
|
|
}
|
|
if grantee == "" {
|
|
return windowtypes.Window{}, fmt.Errorf("grantee is required")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
windowID := fmt.Sprintf("window-%s-%d", grantorHolder, len(s.windows)+1)
|
|
now := time.Now().Unix()
|
|
w := windowtypes.Window{
|
|
WindowID: windowID,
|
|
GrantorHolder: grantorHolder,
|
|
Grantee: grantee,
|
|
Scope: scope,
|
|
Start: start,
|
|
End: end,
|
|
RateLimit: rateLimit,
|
|
Status: windowtypes.StatusOpen,
|
|
}
|
|
s.windows[windowID] = w
|
|
entry := windowtypes.AuditEntry{
|
|
EntryID: windowID + "-audit-1",
|
|
Timestamp: now,
|
|
Action: "open",
|
|
Result: "created",
|
|
GranterRef: grantorHolder,
|
|
}
|
|
s.auditLogs[windowID] = []windowtypes.AuditEntry{entry}
|
|
w.AuditLogRefs = []string{entry.EntryID}
|
|
s.windows[windowID] = w
|
|
return w, nil
|
|
}
|
|
|
|
// ActivateWindow transitions a Window from Open to Active by calling the real
|
|
// x/window/types.Window.Activate() method (not a reimplementation). Appends an
|
|
// AuditEntry. Returns an error if the Window is not in the Open status.
|
|
func (s *Store) ActivateWindow(windowID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
w, ok := s.windows[windowID]
|
|
if !ok {
|
|
return fmt.Errorf("window %q not found", windowID)
|
|
}
|
|
if err := w.Activate(); err != nil {
|
|
return err
|
|
}
|
|
s.windows[windowID] = w
|
|
s.appendAuditLocked(windowID, "activate", "active", w.GrantorHolder)
|
|
return nil
|
|
}
|
|
|
|
// RevokeWindow transitions a Window to Revoked by calling the real
|
|
// x/window/types.Window.Revoke() method. Idempotent on already-revoked;
|
|
// no-op on Expired (terminal state wins — v0.2 type contract). Appends an
|
|
// AuditEntry only if the status actually changed.
|
|
func (s *Store) RevokeWindow(windowID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
w, ok := s.windows[windowID]
|
|
if !ok {
|
|
return fmt.Errorf("window %q not found", windowID)
|
|
}
|
|
prevStatus := w.Status
|
|
if err := w.Revoke(); err != nil {
|
|
return err
|
|
}
|
|
s.windows[windowID] = w
|
|
if w.Status != prevStatus {
|
|
s.appendAuditLocked(windowID, "revoke", "revoked", w.GrantorHolder)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ExpireWindow transitions a Window to Expired by calling the real
|
|
// x/window/types.Window.Expire() method. Appends an AuditEntry.
|
|
func (s *Store) ExpireWindow(windowID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
w, ok := s.windows[windowID]
|
|
if !ok {
|
|
return fmt.Errorf("window %q not found", windowID)
|
|
}
|
|
prevStatus := w.Status
|
|
w.Expire()
|
|
s.windows[windowID] = w
|
|
if w.Status != prevStatus {
|
|
s.appendAuditLocked(windowID, "expire", "expired", w.GrantorHolder)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListWindows returns all Windows for a grantor holder.
|
|
func (s *Store) ListWindows(grantorHolder string) []windowtypes.Window {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := []windowtypes.Window{}
|
|
for _, w := range s.windows {
|
|
if w.GrantorHolder == grantorHolder {
|
|
out = append(out, w)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetWindow returns the Window for a windowID.
|
|
func (s *Store) GetWindow(windowID string) (windowtypes.Window, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
w, ok := s.windows[windowID]
|
|
return w, ok
|
|
}
|
|
|
|
// GetAuditLog returns the audit-log entries for a windowID.
|
|
func (s *Store) GetAuditLog(windowID string) []windowtypes.AuditEntry {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.auditLogs[windowID]
|
|
}
|
|
|
|
// appendAuditLocked appends an AuditEntry to the window's audit log. Caller
|
|
// MUST hold s.mu.
|
|
func (s *Store) appendAuditLocked(windowID, action, result, granterRef string) {
|
|
logs := s.auditLogs[windowID]
|
|
now := time.Now().Unix()
|
|
entry := windowtypes.AuditEntry{
|
|
EntryID: fmt.Sprintf("%s-audit-%d", windowID, len(logs)+1),
|
|
Timestamp: now,
|
|
Action: action,
|
|
Result: result,
|
|
GranterRef: granterRef,
|
|
}
|
|
s.auditLogs[windowID] = append(logs, entry)
|
|
w := s.windows[windowID]
|
|
w.AuditLogRefs = append(w.AuditLogRefs, entry.EntryID)
|
|
s.windows[windowID] = w
|
|
}
|
|
|
|
// validateReachInput enforces G-027: HolderID and PublicKey must be non-empty,
|
|
// <=128 bytes, and contain no path separators or template syntax. This is a
|
|
// prototype-robustness gate (the mock store uses holderID as a map key).
|
|
func validateReachInput(holderID, publicKey string) error {
|
|
if holderID == "" {
|
|
return fmt.Errorf("holder id is required")
|
|
}
|
|
if len(holderID) > 128 {
|
|
return fmt.Errorf("holder id too long (max 128)")
|
|
}
|
|
if strings.ContainsAny(holderID, "/\\") {
|
|
return fmt.Errorf("holder id must not contain path separators")
|
|
}
|
|
if strings.Contains(holderID, "{{") {
|
|
return fmt.Errorf("holder id must not contain template syntax")
|
|
}
|
|
if publicKey == "" {
|
|
return fmt.Errorf("public key is required")
|
|
}
|
|
if len(publicKey) > 128 {
|
|
return fmt.Errorf("public key too long (max 128)")
|
|
}
|
|
if strings.ContainsAny(publicKey, "/\\") {
|
|
return fmt.Errorf("public key must not contain path separators")
|
|
}
|
|
return nil
|
|
}
|