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}} Stash
+
+
+
+
+ Stash ID {{.Stash.StashID}}
+ Balance {{.Stash.BalanceGrain}} Grain
+ Created {{.Stash.CreatedAt}}
+ Last active {{.Stash.LastActive}}
+ Still {{if .Stash.IsStill}}paused{{else}}active{{end}}
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.
+ +| Reach ID | Holder ID | Nomad | Freeholder |
|---|---|---|---|
| {{.ReachID}} | +{{.HolderID}} | +{{if .IsNomad}}yes{{else}}no{{end}} | +{{if .IsFreeholder}}yes{{else}}no{{end}} | +
No Reaches yet. Create a Reach to begin.
+ {{end}} +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.
+ + + +