diff --git a/web/handlers/reach.go b/web/handlers/reach.go new file mode 100644 index 0000000..e6065fc --- /dev/null +++ b/web/handlers/reach.go @@ -0,0 +1,90 @@ +package handlers + +import ( + "net/http" + + identitytypes "github.com/oy/openyield/x/identity/types" + stashtypes "github.com/oy/openyield/x/stash/types" +) + +// registerReach wires the Reach signup routes (REQ-040) into the mux. +// Go 1.22 method-pattern routing: GET /reach (list), GET /reach/new (form), +// POST /reach (atomic create + redirect per D-071), GET /reach/{id} (detail). +func (s *Server) registerReach(mux *http.ServeMux) { + mux.HandleFunc("GET /reach", s.handleReachList) + mux.HandleFunc("GET /reach/new", s.handleReachNew) + mux.HandleFunc("POST /reach", s.handleReachCreate) + mux.HandleFunc("GET /reach/{id}", s.handleReachDetail) +} + +// handleReachList renders all Reaches (seeded + created). +func (s *Server) handleReachList(w http.ResponseWriter, r *http.Request) { + reaches := s.Store.ListReaches() + s.render(w, "reach_list.html", map[string]any{"Reaches": reaches}) +} + +// handleReachNew renders the "Create a Reach" form. Lexicon-clean: "Create a +// Reach", NOT a legacy custodial-position label (REQ-012 bans that word). +func (s *Server) handleReachNew(w http.ResponseWriter, r *http.Request) { + s.render(w, "reach_new.html", nil) +} + +// handleReachCreate handles the POST from the "Create a Reach" form. Calls +// store.CreateReach (atomic Reach + Stash per D-071). On validation error +// (G-027) returns 400 with a lexicon-clean message; on duplicate returns 409. +// On success redirects (302) to the new Reach detail page. +func (s *Server) handleReachCreate(w http.ResponseWriter, r *http.Request) { + holderID := r.FormValue("holder_id") + publicKey := r.FormValue("public_key") + reach, _, err := s.Store.CreateReach(holderID, publicKey) + if err != nil { + // G-026: rendered-HTML lexicon check scans error response bodies too; + // keep the error message lexicon-clean (no banned terms). + status := http.StatusBadRequest + if isDuplicate(err) { + status = http.StatusConflict + } + http.Error(w, "Could not create a Reach: "+err.Error(), status) + return + } + http.Redirect(w, r, "/reach/"+reach.HolderID, http.StatusFound) +} + +// handleReachDetail renders one Reach + its associated Stash (BalanceGrain). +func (s *Server) handleReachDetail(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + reach, ok := s.Store.GetReach(id) + if !ok { + http.NotFound(w, r) + return + } + stash, _ := s.Store.GetStash(id) + s.render(w, "reach_detail.html", map[string]any{ + "Reach": reach, + "Stash": stash, + }) +} + +// isDuplicate reports whether err is a duplicate-holder error from +// store.CreateReach. Kept as a string match to avoid exporting store errors. +func isDuplicate(err error) bool { + return err != nil && contains(err.Error(), "already has a Reach") +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} + +// Compile-time assertions that the handlers use the real x/*/types structs +// (D-067: the UI grounds in the real Go type definitions). +var _ identitytypes.Reach +var _ stashtypes.Stash diff --git a/web/handlers/reach_test.go b/web/handlers/reach_test.go new file mode 100644 index 0000000..c188a4f --- /dev/null +++ b/web/handlers/reach_test.go @@ -0,0 +1,185 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/oy/openyield/lexicon" + "github.com/oy/openyield/web/store" +) + +// newTestServer builds a Server with a fresh store + templates parsed from +// web/templates (relative to repo root via the handlers test working dir). +func newTestServer(t *testing.T) *Server { + t.Helper() + srv, err := New(store.NewStore(), "../../web/templates") + if err != nil { + t.Fatalf("new handlers server: %v", err) + } + return srv +} + +// assertNoBannedTerms checks the rendered response body for banned terms +// (G-026: applies to BOTH 200 happy-path AND error response bodies). +func assertNoBannedTerms(t *testing.T, body string) { + t.Helper() + if term, ok := lexicon.FindBannedTerm(body); ok { + t.Errorf("rendered HTML contains banned term %q (REQ-012/G-026)", term) + } +} + +func TestReachListReturnsSeededReaches(t *testing.T) { + srv := newTestServer(t) + mux := http.NewServeMux() + srv.Register(mux) + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/reach", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET /reach: status %d, want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "holder-alia") { + t.Errorf("GET /reach: body missing seeded reach holder-alia") + } + if !strings.Contains(body, "holder-bryn") { + t.Errorf("GET /reach: body missing seeded reach holder-bryn") + } + assertNoBannedTerms(t, body) +} + +func TestReachNewReturnsForm(t *testing.T) { + srv := newTestServer(t) + mux := http.NewServeMux() + srv.Register(mux) + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/reach/new", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET /reach/new: status %d, want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "Create a Reach") { + t.Errorf("GET /reach/new: body missing 'Create a Reach' label") + } + // The legacy custodial-position word is BANNED (REQ-012) — must not appear. + // Check the full banned-terms list via the lexicon package (no literals in + // source); FindBannedTerm does word-boundary matching so this is stricter + // than a naive substring check. + if term, ok := lexicon.FindBannedTerm(body); ok { + t.Errorf("GET /reach/new: body contains banned word %q", term) + } + assertNoBannedTerms(t, body) +} + +func TestReachCreateValidRedirectsAndAtomicallyCreates(t *testing.T) { + srv := newTestServer(t) + mux := http.NewServeMux() + srv.Register(mux) + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=holder-new&public_key=pk-new")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusFound { + t.Fatalf("POST /reach valid: status %d, want 302 (Found)", rec.Code) + } + loc := rec.Header().Get("Location") + if !strings.Contains(loc, "/reach/holder-new") { + t.Errorf("POST /reach: Location %q, want redirect to /reach/holder-new", loc) + } + // D-071: atomic creation — both Reach + Stash must be present. + reach, ok := srv.Store.GetReach("holder-new") + if !ok { + t.Fatalf("POST /reach: GetReach miss after create (atomicity broken)") + } + if !reach.IsNomad { + t.Errorf("POST /reach: created Reach IsNomad=false, want true (D-071)") + } + stash, ok := srv.Store.GetStash("holder-new") + if !ok { + t.Fatalf("POST /reach: GetStash miss after create (atomicity broken — D-071)") + } + if stash.HolderID != reach.HolderID { + t.Errorf("POST /reach: stash.HolderID %q != reach.HolderID %q (D-071)", stash.HolderID, reach.HolderID) + } +} + +func TestReachCreateEmptyHolderIDReturns400(t *testing.T) { + srv := newTestServer(t) + mux := http.NewServeMux() + srv.Register(mux) + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=&public_key=pk")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("POST /reach empty holder: status %d, want 400", rec.Code) + } + // G-026: rendered-HTML lexicon check scans the ERROR response body too. + assertNoBannedTerms(t, rec.Body.String()) +} + +func TestReachCreatePathSeparatorReturns400(t *testing.T) { + srv := newTestServer(t) + mux := http.NewServeMux() + srv.Register(mux) + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=h/x&public_key=pk")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("POST /reach path separator: status %d, want 400", rec.Code) + } + assertNoBannedTerms(t, rec.Body.String()) +} + +func TestReachCreateDuplicateReturns409(t *testing.T) { + srv := newTestServer(t) + mux := http.NewServeMux() + srv.Register(mux) + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=holder-alia&public_key=pk")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusConflict { + t.Fatalf("POST /reach duplicate: status %d, want 409", rec.Code) + } + assertNoBannedTerms(t, rec.Body.String()) +} + +func TestReachDetailSeededReturnsReachAndStash(t *testing.T) { + srv := newTestServer(t) + mux := http.NewServeMux() + srv.Register(mux) + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/reach/holder-alia", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET /reach/holder-alia: status %d, want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "reach-holder-alia") { + t.Errorf("GET /reach/holder-alia: body missing reach-holder-alia") + } + if !strings.Contains(body, "stash-holder-alia") { + t.Errorf("GET /reach/holder-alia: body missing associated stash-holder-alia") + } + if !strings.Contains(body, "Grain") { + t.Errorf("GET /reach/holder-alia: body missing Stash balance in Grain") + } + assertNoBannedTerms(t, body) +} + +func TestReachDetailMissingReturns404(t *testing.T) { + srv := newTestServer(t) + mux := http.NewServeMux() + srv.Register(mux) + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/reach/nobody", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("GET /reach/nobody: status %d, want 404", rec.Code) + } +} diff --git a/web/handlers/server.go b/web/handlers/server.go new file mode 100644 index 0000000..b9eafec --- /dev/null +++ b/web/handlers/server.go @@ -0,0 +1,100 @@ +// Package handlers holds the HTTP handlers for the OpenYield web UI screens. +// +// Each screen (Reach signup, Stash dashboard, Window authorization, Standing +// progress, Bloom accrual) gets its own handler file. handlers/server.go wires +// routes into the mux from web/server.go. Handlers render html/template +// templates against the mock store (web/store). Lexicon-clean by construction +// (REQ-012 / REQ-045): the lexicon_meta_web firewall scans these files. +package handlers + +import ( + "fmt" + "html/template" + "net/http" + "os" + "path/filepath" + + "github.com/oy/openyield/web/store" +) + +// Server bundles the mock store + per-page templates + route registration. +// Each screen handler is a method on Server so it shares the store + tmpl. +// +// Template loading: base.html is parsed once, then each page template is +// parsed in a CLONE of the base set so the per-page "content" block does not +// collide across pages (Go html/template shares the block namespace within +// one set; cloning per page isolates each page's content block). This is the +// standard Go template pattern for layouts + pages. +type Server struct { + Store *store.Store + Pages map[string]*template.Template +} + +// New constructs a Server with the given store + per-page templates loaded +// from templatesDir (the absolute or relative path to web/templates/). +func New(s *store.Store, templatesDir string) (*Server, error) { + basePath := filepath.Join(templatesDir, "base.html") + base, err := template.ParseFiles(basePath) + if err != nil { + return nil, fmt.Errorf("parse base: %w", err) + } + pages := map[string]*template.Template{} + pageGlob := filepath.Join(templatesDir, "*.html") + matches, err := filepath.Glob(pageGlob) + if err != nil { + return nil, fmt.Errorf("glob pages: %w", err) + } + for _, p := range matches { + name := filepath.Base(p) + if name == "base.html" { + continue + } + clone, cerr := base.Clone() + if cerr != nil { + return nil, fmt.Errorf("clone for %s: %w", name, cerr) + } + pt, perr := clone.ParseFiles(p) + if perr != nil { + return nil, fmt.Errorf("parse %s: %w", name, perr) + } + pages[name] = pt + } + return &Server{Store: s, Pages: pages}, nil +} + +// Register wires all screen routes into the given mux (Go 1.22 method +// patterns). Called by web/server.go after constructing the Server. +func (s *Server) Register(mux *http.ServeMux) { + s.registerReach(mux) + // P2..P5 register their own routes (stash, window, standing, bloom). +} + +// render executes the named page template with the given data, writing HTML +// to w. The page template invokes base.html and overrides the "content" block. +func (s *Server) render(w http.ResponseWriter, name string, data any) { + tmpl, ok := s.Pages[name] + if !ok { + http.Error(w, "template not found: "+name, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil { + http.Error(w, "render error", http.StatusInternalServerError) + } +} + +// RenderHome renders the home page (public so web/server.go can call it for +// the "/" route which lives outside handlers.Register). +func (s *Server) RenderHome(w http.ResponseWriter, data any) { + s.render(w, "home.html", data) +} + +// templatesDir returns the default web/templates directory relative to the +// working directory. Used by web/server.go when constructing via New(). +func DefaultTemplatesDir() string { + dir, _ := os.Getwd() + if filepath.Base(dir) == "web" || filepath.Base(dir) == "handlers" { + return filepath.Join(dir, "templates") + } + return "web/templates" +} diff --git a/web/server.go b/web/server.go index 04ba8b9..d472060 100644 --- a/web/server.go +++ b/web/server.go @@ -1,10 +1,12 @@ package main import ( - "html/template" "log" "net/http" "os" + + "github.com/oy/openyield/web/handlers" + "github.com/oy/openyield/web/store" ) func runServer() { @@ -13,29 +15,28 @@ func runServer() { port = "8080" } - tmpl, err := template.ParseGlob("web/templates/*.html") - if err != nil { - log.Fatalf("parse templates: %v", err) - } - mux := http.NewServeMux() + srv, err := handlers.New(store.NewStore(), "web/templates") + if err != nil { + log.Fatalf("init handlers: %v", err) + } + srv.Register(mux) + + // Home page (rendered via the handlers' page machinery too). mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.ExecuteTemplate(w, "home.html", nil); err != nil { - log.Printf("render home: %v", err) - } + srv.RenderHome(w, nil) }) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static")))) - srv := &http.Server{Addr: ":" + port, Handler: mux} + server := &http.Server{Addr: ":" + port, Handler: mux} log.Printf("OpenYield web on :%s", port) - if err := srv.ListenAndServe(); err != nil { + if err := server.ListenAndServe(); err != nil { log.Fatalf("server: %v", err) } } diff --git a/web/store/fixtures.go b/web/store/fixtures.go index 856d035..5f67c02 100644 --- a/web/store/fixtures.go +++ b/web/store/fixtures.go @@ -8,8 +8,8 @@ import ( ) // seed populates the store with a few pre-existing Reach/Stash pairs for the -// list view. All strings lexicon-clean ("Holder"/"Reach"/"Stash"; NOT -// "account"/"bank"/"deposit"). Two fixtures: one mature (90+ active days), +// list view. All strings lexicon-clean ("Holder"/"Reach"/"Stash"; NOT the +// banned financial terms). Two fixtures: one mature (90+ active days), // one immature (45 active days) so the Stash dashboard (P2) can show both // states. func (s *Store) seed() { diff --git a/web/templates/home.html b/web/templates/home.html index 16f27a2..2a96f13 100644 --- a/web/templates/home.html +++ b/web/templates/home.html @@ -1,5 +1,3 @@ -{{template "base.html" .}} - {{define "title"}}OpenYield — real production on the mesh{{end}} {{define "content"}} diff --git a/web/templates/reach_detail.html b/web/templates/reach_detail.html new file mode 100644 index 0000000..7297df0 --- /dev/null +++ b/web/templates/reach_detail.html @@ -0,0 +1,31 @@ +{{define "title"}}{{.Reach.ReachID}} — OpenYield{{end}} + +{{define "content"}} +
+

{{.Reach.ReachID}}

+ + + + + + + +
Reach ID{{.Reach.ReachID}}
Holder ID{{.Reach.HolderID}}
Public Key{{.Reach.PublicKey}}
Created{{.Reach.CreatedAt}}
Nomad{{if .Reach.IsNomad}}yes{{else}}no{{end}}
Freeholder{{if .Reach.IsFreeholder}}yes{{else}}no{{end}}
+
+ +{{if .Stash.StashID}} +
+

Stash

+ + + + + + +
Stash ID{{.Stash.StashID}}
Balance{{.Stash.BalanceGrain}} Grain
Created{{.Stash.CreatedAt}}
Last active{{.Stash.LastActive}}
Still{{if .Stash.IsStill}}paused{{else}}active{{end}}
+

View Stash dashboard

+
+{{end}} + +

Back to Reach list

+{{end}} \ No newline at end of file diff --git a/web/templates/reach_list.html b/web/templates/reach_list.html new file mode 100644 index 0000000..27e866e --- /dev/null +++ b/web/templates/reach_list.html @@ -0,0 +1,35 @@ +{{define "title"}}Reach — OpenYield{{end}} + +{{define "content"}} +
+

Reach

+

A Reach is the mesh-native identity a Holder uses to act on the mesh + without a custodian, a gatekeeper, or a legacy financial position. A Nomad + is a Holder who has a Reach and a Stash and is on the way to earning the + four Freeholder signals.

+

Create a Reach

+
+ +
+

Holders on the mesh

+ {{if .Reaches}} + + + + + + {{range .Reaches}} + + + + + + + {{end}} + +
Reach IDHolder IDNomadFreeholder
{{.ReachID}}{{.HolderID}}{{if .IsNomad}}yes{{else}}no{{end}}{{if .IsFreeholder}}yes{{else}}no{{end}}
+ {{else}} +

No Reaches yet. Create a Reach to begin.

+ {{end}} +
+{{end}} \ No newline at end of file diff --git a/web/templates/reach_new.html b/web/templates/reach_new.html new file mode 100644 index 0000000..7a98642 --- /dev/null +++ b/web/templates/reach_new.html @@ -0,0 +1,22 @@ +{{define "title"}}Create a Reach — OpenYield{{end}} + +{{define "content"}} +
+

Create a Reach

+

A Reach is an identity, not a custodial position. The protocol does not + require KYC at the protocol layer; the Reach is the unit of self-service. + Creating a Reach also opens a Stash for you (the place a Nomad holds + Grain) — that pair is enough to begin on the mesh.

+ +
+ + + + + +
+

Back to Reach list

+
+{{end}} \ No newline at end of file