diff --git a/web/handlers/server.go b/web/handlers/server.go
index deb6c39..b52a7ad 100644
--- a/web/handlers/server.go
+++ b/web/handlers/server.go
@@ -15,6 +15,7 @@ import (
"path/filepath"
"github.com/oy/openyield/web/store"
+ windowtypes "github.com/oy/openyield/x/window/types"
)
// Server bundles the mock store + per-page templates + route registration.
@@ -40,6 +41,16 @@ func New(s *store.Store, templatesDir string) (*Server, error) {
}
return grain / unit
},
+ "string": func(v any) string {
+ switch t := v.(type) {
+ case string:
+ return t
+ case windowtypes.WindowStatus:
+ return string(t)
+ default:
+ return ""
+ }
+ },
}
basePath := filepath.Join(templatesDir, "base.html")
base, err := template.New("base.html").Funcs(funcs).ParseFiles(basePath)
@@ -75,7 +86,8 @@ func New(s *store.Store, templatesDir string) (*Server, error) {
func (s *Server) Register(mux *http.ServeMux) {
s.registerReach(mux)
s.registerStash(mux)
- // P3..P5 register their own routes (window, standing, bloom).
+ s.registerWindow(mux)
+ // P4..P5 register their own routes (standing, bloom).
}
// render executes the named page template with the given data, writing HTML
diff --git a/web/handlers/window.go b/web/handlers/window.go
new file mode 100644
index 0000000..1d1d177
--- /dev/null
+++ b/web/handlers/window.go
@@ -0,0 +1,119 @@
+package handlers
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+
+ windowtypes "github.com/oy/openyield/x/window/types"
+)
+
+// registerWindow wires the Window authorization routes (REQ-042) into the mux.
+func (s *Server) registerWindow(mux *http.ServeMux) {
+ mux.HandleFunc("GET /window", s.handleWindowList)
+ mux.HandleFunc("GET /window/new", s.handleWindowNew)
+ mux.HandleFunc("POST /window", s.handleWindowOpen)
+ mux.HandleFunc("GET /window/{id}", s.handleWindowDetail)
+ mux.HandleFunc("POST /window/{id}/activate", s.handleWindowActivate)
+ mux.HandleFunc("POST /window/{id}/revoke", s.handleWindowRevoke)
+ mux.HandleFunc("POST /window/{id}/expire", s.handleWindowExpire)
+}
+
+// handleWindowList renders all Windows for a grantor holder (defaults to
+// holder-alia if no query param, so the list view has something to show).
+func (s *Server) handleWindowList(w http.ResponseWriter, r *http.Request) {
+ grantor := r.URL.Query().Get("grantor")
+ if grantor == "" {
+ grantor = "holder-alia"
+ }
+ windows := s.Store.ListWindows(grantor)
+ s.render(w, "window_list.html", map[string]any{"Windows": windows, "Grantor": grantor})
+}
+
+// handleWindowNew renders the "Open a Window" form.
+func (s *Server) handleWindowNew(w http.ResponseWriter, r *http.Request) {
+ s.render(w, "window_new.html", nil)
+}
+
+// handleWindowOpen handles the POST from the "Open a Window" form. Calls
+// store.OpenWindow (creates a Window status=Open + an initial AuditEntry).
+func (s *Server) handleWindowOpen(w http.ResponseWriter, r *http.Request) {
+ grantor := r.FormValue("grantor_holder")
+ grantee := r.FormValue("grantee")
+ scopeKind := windowtypes.ScopeKind(r.FormValue("scope_kind"))
+ resourceID := r.FormValue("resource_id")
+ startStr := r.FormValue("start_unix")
+ endStr := r.FormValue("end_unix")
+ maxActionsStr := r.FormValue("max_actions")
+
+ if grantor == "" {
+ http.Error(w, "grantor holder is required", http.StatusBadRequest)
+ return
+ }
+ if grantee == "" {
+ http.Error(w, "grantee is required", http.StatusBadRequest)
+ return
+ }
+ start, _ := strconv.ParseInt(startStr, 10, 64)
+ end, _ := strconv.ParseInt(endStr, 10, 64)
+ if start == 0 {
+ start = time.Now().Unix()
+ }
+ if end == 0 {
+ end = start + 3600
+ }
+ maxActions, _ := strconv.ParseUint(maxActionsStr, 10, 32)
+ if maxActions == 0 {
+ maxActions = 10
+ }
+ scope := windowtypes.Scope{Kind: scopeKind, ResourceID: resourceID}
+ rateLimit := windowtypes.RateLimit{MaxActions: uint32(maxActions), PerDurationSeconds: 3600}
+ win, err := s.Store.OpenWindow(grantor, grantee, scope, start, end, rateLimit)
+ if err != nil {
+ http.Error(w, "could not open a Window: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ http.Redirect(w, r, "/window/"+win.WindowID, http.StatusFound)
+}
+
+// handleWindowDetail renders one Window + its lifecycle state + audit log.
+func (s *Server) handleWindowDetail(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ win, ok := s.Store.GetWindow(id)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ auditLog := s.Store.GetAuditLog(id)
+ s.render(w, "window_detail.html", map[string]any{"Window": win, "AuditLog": auditLog})
+}
+
+// handleWindowActivate transitions Open → Active (calls Window.Activate).
+func (s *Server) handleWindowActivate(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if err := s.Store.ActivateWindow(id); err != nil {
+ http.Error(w, "could not activate: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ http.Redirect(w, r, "/window/"+id, http.StatusSeeOther)
+}
+
+// handleWindowRevoke transitions to Revoked (calls Window.Revoke; idempotent).
+func (s *Server) handleWindowRevoke(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if err := s.Store.RevokeWindow(id); err != nil {
+ http.Error(w, "could not revoke: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ http.Redirect(w, r, "/window/"+id, http.StatusSeeOther)
+}
+
+// handleWindowExpire transitions to Expired (calls Window.Expire).
+func (s *Server) handleWindowExpire(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if err := s.Store.ExpireWindow(id); err != nil {
+ http.Error(w, "could not expire: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ http.Redirect(w, r, "/window/"+id, http.StatusSeeOther)
+}
diff --git a/web/handlers/window_test.go b/web/handlers/window_test.go
new file mode 100644
index 0000000..5a3fc83
--- /dev/null
+++ b/web/handlers/window_test.go
@@ -0,0 +1,363 @@
+package handlers
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ windowtypes "github.com/oy/openyield/x/window/types"
+)
+
+func TestWindowOpenCreatesWindowStatusOpen(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ body := "grantor_holder=holder-alia&grantee=service-1&scope_kind=ReadStash&resource_id=stash-holder-alia&max_actions=5"
+ req := httptest.NewRequest("POST", "/window", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound {
+ t.Fatalf("POST /window: status %d, want 302", rec.Code)
+ }
+ loc := rec.Header().Get("Location")
+ if !strings.HasPrefix(loc, "/window/window-") {
+ t.Errorf("POST /window: Location %q, want /window/window-...", loc)
+ }
+ // Extract the windowID and verify it exists with Status=Open + an initial AuditEntry.
+ windowID := strings.TrimPrefix(loc, "/window/")
+ win, ok := srv.Store.GetWindow(windowID)
+ if !ok {
+ t.Fatalf("POST /window: GetWindow(%q) miss", windowID)
+ }
+ if win.Status != windowtypes.StatusOpen {
+ t.Errorf("POST /window: created Window status %q, want Open", win.Status)
+ }
+ audit := srv.Store.GetAuditLog(windowID)
+ if len(audit) != 1 {
+ t.Errorf("POST /window: audit log len %d, want 1 (initial entry)", len(audit))
+ }
+ if audit[0].Action != "open" {
+ t.Errorf("POST /window: initial audit action %q, want open", audit[0].Action)
+ }
+ assertNoBannedTerms(t, rec.Body.String())
+}
+
+func TestWindowActivateTransitionsOpenToActive(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
+ rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
+ win, err := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
+ if err != nil {
+ t.Fatalf("OpenWindow: %v", err)
+ }
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("POST", "/window/"+win.WindowID+"/activate", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST activate: status %d, want 303", rec.Code)
+ }
+ // Lifecycle correctness: assert the real Window.Activate() was invoked
+ // (the handler calls store.ActivateWindow which calls w.Activate()).
+ updated, ok := srv.Store.GetWindow(win.WindowID)
+ if !ok {
+ t.Fatal("window missing after activate")
+ }
+ if updated.Status != windowtypes.StatusActive {
+ t.Errorf("after activate: status %q, want Active (Window.Activate was NOT invoked)", updated.Status)
+ }
+ audit := srv.Store.GetAuditLog(win.WindowID)
+ if len(audit) != 2 {
+ t.Errorf("after activate: audit log len %d, want 2 (initial + activate)", len(audit))
+ }
+ if audit[1].Action != "activate" {
+ t.Errorf("after activate: audit[1].Action %q, want activate", audit[1].Action)
+ }
+ assertNoBannedTerms(t, rec.Body.String())
+}
+
+func TestWindowRevokeTransitionsToRevoked(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
+ rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
+ win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
+ _ = srv.Store.ActivateWindow(win.WindowID)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("POST", "/window/"+win.WindowID+"/revoke", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST revoke: status %d, want 303", rec.Code)
+ }
+ updated, _ := srv.Store.GetWindow(win.WindowID)
+ if updated.Status != windowtypes.StatusRevoked {
+ t.Errorf("after revoke: status %q, want Revoked (Window.Revoke was NOT invoked)", updated.Status)
+ }
+ if !updated.Revoked {
+ t.Errorf("after revoke: Revoked flag false, want true")
+ }
+ assertNoBannedTerms(t, rec.Body.String())
+}
+
+func TestWindowRevokeIdempotentOnAlreadyRevoked(t *testing.T) {
+ srv := newTestServer(t)
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
+ rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
+ win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
+ _ = srv.Store.ActivateWindow(win.WindowID)
+ _ = srv.Store.RevokeWindow(win.WindowID)
+ auditBefore := len(srv.Store.GetAuditLog(win.WindowID))
+
+ // Second revoke is a no-op (idempotent): no new AuditEntry.
+ _ = srv.Store.RevokeWindow(win.WindowID)
+ auditAfter := len(srv.Store.GetAuditLog(win.WindowID))
+ if auditAfter != auditBefore {
+ t.Errorf("idempotent revoke: audit log grew %d -> %d (revoke on already-revoked must be a no-op)", auditBefore, auditAfter)
+ }
+}
+
+func TestWindowRevokeOnExpiredIsNoOp(t *testing.T) {
+ srv := newTestServer(t)
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
+ rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
+ win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
+ _ = srv.Store.ActivateWindow(win.WindowID)
+ _ = srv.Store.ExpireWindow(win.WindowID)
+ auditBefore := len(srv.Store.GetAuditLog(win.WindowID))
+
+ // Revoke on an Expired window is a no-op (Expired is terminal — v0.2 contract).
+ _ = srv.Store.RevokeWindow(win.WindowID)
+ updated, _ := srv.Store.GetWindow(win.WindowID)
+ if updated.Status != windowtypes.StatusExpired {
+ t.Errorf("revoke-on-expired: status %q, want Expired (terminal state must win)", updated.Status)
+ }
+ auditAfter := len(srv.Store.GetAuditLog(win.WindowID))
+ if auditAfter != auditBefore {
+ t.Errorf("revoke-on-expired: audit log grew %d -> %d (must be a no-op)", auditBefore, auditAfter)
+ }
+}
+
+func TestWindowExpireTransitionsToExpired(t *testing.T) {
+ srv := newTestServer(t)
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
+ rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
+ win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
+ _ = srv.Store.ActivateWindow(win.WindowID)
+
+ _ = srv.Store.ExpireWindow(win.WindowID)
+ updated, _ := srv.Store.GetWindow(win.WindowID)
+ if updated.Status != windowtypes.StatusExpired {
+ t.Errorf("after expire: status %q, want Expired (Window.Expire was NOT invoked)", updated.Status)
+ }
+}
+
+func TestWindowDetailRendersLifecycleAndAuditLog(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
+ rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
+ win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
+ _ = srv.Store.ActivateWindow(win.WindowID)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/window/"+win.WindowID, nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /window/%s: status %d, want 200", win.WindowID, rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "Active") {
+ t.Errorf("detail: body missing Active badge")
+ }
+ if !strings.Contains(body, "activate") {
+ t.Errorf("detail: body missing activate audit-log entry")
+ }
+ if !strings.Contains(body, "open") {
+ t.Errorf("detail: body missing open audit-log entry")
+ }
+ assertNoBannedTerms(t, body)
+}
+
+func TestWindowDetailMissingReturns404(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/window/window-nobody", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("GET /window/window-nobody: status %d, want 404", rec.Code)
+ }
+ assertNoBannedTerms(t, rec.Body.String())
+}
+
+func TestWindowOpenEmptyGrantorReturns400(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ body := "grantor_holder=&grantee=service-1&scope_kind=ReadStash"
+ req := httptest.NewRequest("POST", "/window", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("POST /window empty grantor: status %d, want 400", rec.Code)
+ }
+ // G-026: rendered-HTML lexicon check on the ERROR response body too.
+ assertNoBannedTerms(t, rec.Body.String())
+}
+
+// Compile-time assertion that the handler uses the real x/window/types struct
+// (D-067: the UI grounds in the real Go type definitions).
+var _ windowtypes.Window
+
+func TestWindowListRendersSeededEmpty(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/window", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /window: status %d, want 200", rec.Code)
+ }
+ body := rec.Body.String()
+ // No windows yet for holder-alia (fresh store) -> empty message.
+ if !strings.Contains(body, "Open a Window") {
+ t.Errorf("GET /window: body missing 'Open a Window' link")
+ }
+ assertNoBannedTerms(t, body)
+}
+
+func TestWindowListRendersCreatedWindows(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
+ rl := windowtypes.RateLimit{MaxActions: 5, PerDurationSeconds: 3600}
+ w, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/window", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /window: status %d, want 200", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, w.WindowID) {
+ t.Errorf("GET /window: body missing created window %s", w.WindowID)
+ }
+ if !strings.Contains(body, "service-1") {
+ t.Errorf("GET /window: body missing grantee service-1")
+ }
+ assertNoBannedTerms(t, body)
+}
+
+func TestWindowNewRendersForm(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/window/new", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /window/new: status %d, want 200", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "Open a Window") {
+ t.Errorf("GET /window/new: body missing 'Open a Window' label")
+ }
+ if !strings.Contains(body, "ReadStash") {
+ t.Errorf("GET /window/new: body missing ScopeKind option ReadStash")
+ }
+ if !strings.Contains(body, "ProcessPassActForStand") {
+ t.Errorf("GET /window/new: body missing ScopeKind option ProcessPassActForStand")
+ }
+ assertNoBannedTerms(t, body)
+}
+
+func TestWindowOpenEmptyGranteeReturns400(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ body := "grantor_holder=holder-alia&grantee=&scope_kind=ReadStash"
+ req := httptest.NewRequest("POST", "/window", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("POST /window empty grantee: status %d, want 400", rec.Code)
+ }
+ assertNoBannedTerms(t, rec.Body.String())
+}
+
+func TestWindowActivateNotFoundReturns400(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("POST", "/window/window-nobody/activate", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("POST activate nobody: status %d, want 400", rec.Code)
+ }
+ assertNoBannedTerms(t, rec.Body.String())
+}
+
+func TestWindowRevokeNotFoundReturns400(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("POST", "/window/window-nobody/revoke", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("POST revoke nobody: status %d, want 400", rec.Code)
+ }
+ assertNoBannedTerms(t, rec.Body.String())
+}
+
+func TestWindowExpireNotFoundReturns400(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("POST", "/window/window-nobody/expire", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("POST expire nobody: status %d, want 400", rec.Code)
+ }
+ assertNoBannedTerms(t, rec.Body.String())
+}
+
+func TestWindowRevokeAndExpireHandlersRedirect(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
+ rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
+ win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
+ _ = srv.Store.ActivateWindow(win.WindowID)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("POST", "/window/"+win.WindowID+"/revoke", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST revoke: status %d, want 303", rec.Code)
+ }
+
+ // Expire on a revoked window: revoked is not terminal for expire, so it
+ // transitions to Expired (Window.Expire sets status unconditionally).
+ rec2 := httptest.NewRecorder()
+ req2 := httptest.NewRequest("POST", "/window/"+win.WindowID+"/expire", nil)
+ mux.ServeHTTP(rec2, req2)
+ if rec2.Code != http.StatusSeeOther {
+ t.Fatalf("POST expire: status %d, want 303", rec2.Code)
+ }
+}
diff --git a/web/store/store.go b/web/store/store.go
index 4d6db21..b43a6f6 100644
--- a/web/store/store.go
+++ b/web/store/store.go
@@ -15,6 +15,7 @@ import (
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
@@ -27,6 +28,8 @@ type Store struct {
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).
@@ -35,6 +38,8 @@ func NewStore() *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
@@ -117,6 +122,148 @@ func (s *Store) GetStashActivity(stashID string) (stashtypes.StashActivity, bool
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).
diff --git a/web/store/store_test.go b/web/store/store_test.go
index 13a863a..6d2405b 100644
--- a/web/store/store_test.go
+++ b/web/store/store_test.go
@@ -6,6 +6,7 @@ import (
identitytypes "github.com/oy/openyield/x/identity/types"
stashtypes "github.com/oy/openyield/x/stash/types"
+ windowtypes "github.com/oy/openyield/x/window/types"
)
func TestNewStoreSeedsFixtures(t *testing.T) {
@@ -214,3 +215,222 @@ func stringOf(r rune, n int) string {
}
return string(b)
}
+
+// --- Window tests (P3) ---
+
+func TestOpenWindowCreatesStatusOpenWithInitialAudit(t *testing.T) {
+ s := NewStore()
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-x"}
+ rl := windowtypes.RateLimit{MaxActions: 5, PerDurationSeconds: 3600}
+ w, err := s.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
+ if err != nil {
+ t.Fatalf("OpenWindow: %v", err)
+ }
+ if w.Status != windowtypes.StatusOpen {
+ t.Errorf("OpenWindow status %q, want Open", w.Status)
+ }
+ if w.WindowID == "" {
+ t.Error("OpenWindow: empty WindowID")
+ }
+ audit := s.GetAuditLog(w.WindowID)
+ if len(audit) != 1 {
+ t.Errorf("OpenWindow: audit log len %d, want 1", len(audit))
+ }
+ if audit[0].Action != "open" {
+ t.Errorf("OpenWindow: audit[0].Action %q, want open", audit[0].Action)
+ }
+}
+
+func TestOpenWindowValidation(t *testing.T) {
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
+ rl := windowtypes.RateLimit{MaxActions: 1}
+ cases := []struct {
+ name, grantor, grantee string
+ wantErr bool
+ }{
+ {"empty grantor", "", "g", true},
+ {"empty grantee", "h", "", true},
+ {"valid", "h", "g", false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ s := NewStore()
+ _, err := s.OpenWindow(c.grantor, c.grantee, scope, 1, 2, rl)
+ if c.wantErr && err == nil {
+ t.Errorf("expected error, got nil")
+ }
+ if !c.wantErr && err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ })
+ }
+}
+
+func TestActivateWindowTransitionsToActive(t *testing.T) {
+ s := NewStore()
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
+ rl := windowtypes.RateLimit{MaxActions: 1}
+ w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
+ if err := s.ActivateWindow(w.WindowID); err != nil {
+ t.Fatalf("ActivateWindow: %v", err)
+ }
+ updated, _ := s.GetWindow(w.WindowID)
+ if updated.Status != windowtypes.StatusActive {
+ t.Errorf("after activate: %q, want Active", updated.Status)
+ }
+ audit := s.GetAuditLog(w.WindowID)
+ if len(audit) != 2 {
+ t.Errorf("after activate: audit len %d, want 2", len(audit))
+ }
+}
+
+func TestActivateWindowNotFound(t *testing.T) {
+ s := NewStore()
+ if err := s.ActivateWindow("window-nobody"); err == nil {
+ t.Error("ActivateWindow(nobody): expected error, got nil")
+ }
+}
+
+func TestActivateWindowOnActiveFails(t *testing.T) {
+ s := NewStore()
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
+ rl := windowtypes.RateLimit{MaxActions: 1}
+ w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
+ _ = s.ActivateWindow(w.WindowID)
+ // Activate again should fail (can only activate Open windows).
+ if err := s.ActivateWindow(w.WindowID); err == nil {
+ t.Error("activate on Active: expected error, got nil (Window.Activate rejects non-Open)")
+ }
+}
+
+func TestRevokeWindowTransitionsToRevoked(t *testing.T) {
+ s := NewStore()
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
+ rl := windowtypes.RateLimit{MaxActions: 1}
+ w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
+ if err := s.RevokeWindow(w.WindowID); err != nil {
+ t.Fatalf("RevokeWindow: %v", err)
+ }
+ updated, _ := s.GetWindow(w.WindowID)
+ if updated.Status != windowtypes.StatusRevoked {
+ t.Errorf("after revoke: %q, want Revoked", updated.Status)
+ }
+ if !updated.Revoked {
+ t.Error("after revoke: Revoked flag false, want true")
+ }
+}
+
+func TestRevokeWindowIdempotent(t *testing.T) {
+ s := NewStore()
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
+ rl := windowtypes.RateLimit{MaxActions: 1}
+ w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
+ _ = s.RevokeWindow(w.WindowID)
+ before := len(s.GetAuditLog(w.WindowID))
+ _ = s.RevokeWindow(w.WindowID)
+ after := len(s.GetAuditLog(w.WindowID))
+ if after != before {
+ t.Errorf("idempotent revoke: audit grew %d -> %d", before, after)
+ }
+}
+
+func TestRevokeWindowOnExpiredIsNoOp(t *testing.T) {
+ s := NewStore()
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
+ rl := windowtypes.RateLimit{MaxActions: 1}
+ w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
+ _ = s.ExpireWindow(w.WindowID)
+ before := len(s.GetAuditLog(w.WindowID))
+ _ = s.RevokeWindow(w.WindowID)
+ updated, _ := s.GetWindow(w.WindowID)
+ if updated.Status != windowtypes.StatusExpired {
+ t.Errorf("revoke-on-expired: %q, want Expired (terminal wins)", updated.Status)
+ }
+ after := len(s.GetAuditLog(w.WindowID))
+ if after != before {
+ t.Errorf("revoke-on-expired: audit grew %d -> %d (no-op)", before, after)
+ }
+}
+
+func TestRevokeWindowNotFound(t *testing.T) {
+ s := NewStore()
+ if err := s.RevokeWindow("window-nobody"); err == nil {
+ t.Error("RevokeWindow(nobody): expected error, got nil")
+ }
+}
+
+func TestExpireWindowTransitionsToExpired(t *testing.T) {
+ s := NewStore()
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
+ rl := windowtypes.RateLimit{MaxActions: 1}
+ w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
+ if err := s.ExpireWindow(w.WindowID); err != nil {
+ t.Fatalf("ExpireWindow: %v", err)
+ }
+ updated, _ := s.GetWindow(w.WindowID)
+ if updated.Status != windowtypes.StatusExpired {
+ t.Errorf("after expire: %q, want Expired", updated.Status)
+ }
+}
+
+func TestExpireWindowNotFound(t *testing.T) {
+ s := NewStore()
+ if err := s.ExpireWindow("window-nobody"); err == nil {
+ t.Error("ExpireWindow(nobody): expected error, got nil")
+ }
+}
+
+func TestExpireWindowIdempotent(t *testing.T) {
+ s := NewStore()
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
+ rl := windowtypes.RateLimit{MaxActions: 1}
+ w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
+ _ = s.ExpireWindow(w.WindowID)
+ before := len(s.GetAuditLog(w.WindowID))
+ _ = s.ExpireWindow(w.WindowID)
+ after := len(s.GetAuditLog(w.WindowID))
+ if after != before {
+ t.Errorf("idempotent expire: audit grew %d -> %d", before, after)
+ }
+}
+
+func TestListWindowsFiltersByGrantor(t *testing.T) {
+ s := NewStore()
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
+ rl := windowtypes.RateLimit{MaxActions: 1}
+ _, _ = s.OpenWindow("holder-alia", "svc1", scope, 1, 2, rl)
+ _, _ = s.OpenWindow("holder-alia", "svc2", scope, 1, 2, rl)
+ _, _ = s.OpenWindow("holder-bryn", "svc3", scope, 1, 2, rl)
+ alia := s.ListWindows("holder-alia")
+ if len(alia) != 2 {
+ t.Errorf("ListWindows(holder-alia) = %d, want 2", len(alia))
+ }
+ bryn := s.ListWindows("holder-bryn")
+ if len(bryn) != 1 {
+ t.Errorf("ListWindows(holder-bryn) = %d, want 1", len(bryn))
+ }
+ nobody := s.ListWindows("nobody")
+ if len(nobody) != 0 {
+ t.Errorf("ListWindows(nobody) = %d, want 0", len(nobody))
+ }
+}
+
+func TestGetWindowHitMiss(t *testing.T) {
+ s := NewStore()
+ scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
+ rl := windowtypes.RateLimit{MaxActions: 1}
+ w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
+ if _, ok := s.GetWindow(w.WindowID); !ok {
+ t.Errorf("GetWindow(%q) miss, want hit", w.WindowID)
+ }
+ if _, ok := s.GetWindow("window-nobody"); ok {
+ t.Error("GetWindow(nobody) hit, want miss")
+ }
+}
+
+func TestGetAuditLogEmptyForMissing(t *testing.T) {
+ s := NewStore()
+ if logs := s.GetAuditLog("window-nobody"); logs != nil {
+ t.Errorf("GetAuditLog(nobody) = %v, want nil", logs)
+ }
+}
diff --git a/web/templates/window_detail.html b/web/templates/window_detail.html
new file mode 100644
index 0000000..d370ef0
--- /dev/null
+++ b/web/templates/window_detail.html
@@ -0,0 +1,68 @@
+{{define "title"}}{{.Window.WindowID}} — OpenYield{{end}}
+
+{{define "content"}}
+
+ {{if eq (string .Window.Status) "Open"}}
+ {{.Window.WindowID}}
+
+
+
+ Window ID {{.Window.WindowID}}
+ Grantor {{.Window.GrantorHolder}}
+ Grantee {{.Window.Grantee}}
+ Scope {{.Window.Scope.Kind}} ({{.Window.Scope.ResourceID}})
+ Start {{.Window.Start}}
+ End {{.Window.End}}
+ Rate limit {{.Window.RateLimit.ActionsConsumed}} / {{.Window.RateLimit.MaxActions}} per {{.Window.RateLimit.PerDurationSeconds}}s
+ Revoked {{if .Window.Revoked}}yes{{else}}no{{end}}
+ Status
+ {{if eq (string .Window.Status) "Open"}}Open{{end}}
+ {{if eq (string .Window.Status) "Active"}}Active{{end}}
+ {{if eq (string .Window.Status) "Revoked"}}Revoked{{end}}
+ {{if eq (string .Window.Status) "Expired"}}Expired{{end}}
+ Lifecycle actions
+
| Entry ID | Timestamp | Action | Result | Granter |
|---|---|---|---|---|
| {{.EntryID}} | +{{.Timestamp}} | +{{.Action}} | +{{.Result}} | +{{.GranterRef}} | +
No audit entries yet.
+ {{end}} +A Window is a Holder-authorized, scope-bounded, time-limited, revocable + delegation of access (REQ-015). The Holder opens a Window so a partner or + service can read a Stash or process a Pass-Act — without giving up custody. + The Window is revocable, rate-limited, and audited.
+ +| Window ID | Grantee | Scope | Status |
|---|---|---|---|
| {{.WindowID}} | +{{.Grantee}} | +{{.Scope.Kind}} ({{.Scope.ResourceID}}) | +{{.Status}} | +
No Windows yet for {{.Grantor}}. Open a Window to begin.
+ {{end}} +A Window delegates scoped access to a partner or service without giving + up custody. The Holder sets the scope, the duration, and a rate-limit; the + Window is revocable at any time.
+ + + +