CAT-P2: the overview page: a handler that answers GET only, on a port for this machine only, through a required route
The module's bundle (the one process with the bus) serves the page on the port its manifest declares under listens, from: machine, and requires a route that reaches it. No form, no script, no verb behind it; any method but GET is 405. A source that does not answer is said on the page, never shown as an empty list. Issue 1968. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
@@ -155,7 +155,7 @@ func (w *busWorld) start(t *testing.T) (*bundle, string) {
|
||||
_ = os.WriteFile(settings, []byte(`{"judge-model": "sonnet", "drafting": "off"}`), 0o600)
|
||||
status := filepath.Join(dir, "health.json")
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^$")
|
||||
cmd.Env = append(os.Environ(), bundleVar+"=1", "MESH_KNOWLEDGE_SETTINGS="+settings, "MESH_KNOWLEDGE_STATUS="+status,
|
||||
cmd.Env = append(os.Environ(), bundleVar+"=1", "MESH_KNOWLEDGE_PAGE=off", "MESH_KNOWLEDGE_SETTINGS="+settings, "MESH_KNOWLEDGE_STATUS="+status,
|
||||
"MESH_SERVED_MODULE="+Module, "MESH_MODULE="+Module, "MESH_NODE=test")
|
||||
in, _ := cmd.StdinPipe()
|
||||
out, _ := cmd.StdoutPipe()
|
||||
|
||||
@@ -102,6 +102,9 @@ type KB struct {
|
||||
Now func() time.Time
|
||||
Logf func(format string, a ...any)
|
||||
|
||||
// Overview is the overview page's model, when the page runs.
|
||||
Overview *Overview
|
||||
|
||||
// calls are the tool calls in flight, by the proven caller of each (noteCall).
|
||||
calls sync.Map
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ func serve(k *KB, status string) {
|
||||
k.kick = make(chan struct{}, 1)
|
||||
go hear(k)
|
||||
go background(k)
|
||||
go ServePage(k)
|
||||
if err := stdio.Serve("", append(append(Tools(k, callerOf), healthTool(status, k.now, k), highestTool(k), marksTool(k)), moduleTools(k, callerOf)...)); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
// The overview page's handler and the process that keeps it (novox/hq to-be 55 §11, ADR 0616 §4): GET only, no form and no
|
||||
// verb behind it, served by the module's bundle (the one process with the bus: the ledger and the sources' verbs are reached
|
||||
// through it) on a port the manifest declares for this machine only, reached through the route proxy by a route the module requires.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// PageAddress is where the page listens unless MESH_KNOWLEDGE_PAGE says otherwise: the port the manifest declares under
|
||||
// `listens`, which the node's packet filter opens for this machine only.
|
||||
const PageAddress = ":8480"
|
||||
|
||||
// PageHandler renders the model as a page; any method but GET is refused, nothing is written.
|
||||
func PageHandler(o *Overview) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
http.Error(w, "this page is read-only: GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
m, err := o.Model()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
|
||||
if err := pageTemplate.Execute(w, m); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mesh-knowledge] the page could not be rendered: %v\n", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var pageFuncs = template.FuncMap{
|
||||
"clock": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "never"
|
||||
}
|
||||
return t.Local().Format("2006-01-02 15:04")
|
||||
},
|
||||
"join": strings.Join,
|
||||
}
|
||||
|
||||
var pageTemplate = template.Must(template.New("page").Funcs(pageFuncs).Parse(`<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>The mesh at a glance</title>
|
||||
<style>
|
||||
:root{color-scheme:light dark;--bg:#fff;--fg:#1c1c1e;--mute:#6b6b70;--line:#d8d8dc;--warn:#9a5b00;--bad:#a3001d}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#161618;--fg:#ececf0;--mute:#9a9aa2;--line:#35353a;--warn:#e0a040;--bad:#ff6b80}}
|
||||
body{font:15px/1.5 system-ui,sans-serif;background:var(--bg);color:var(--fg);max-width:60rem;margin:2rem auto;padding:0 1rem}
|
||||
h1{font-size:1.4rem}h2{font-size:1.1rem;margin-top:2rem;border-bottom:1px solid var(--line);padding-bottom:.25rem}
|
||||
.mute{color:var(--mute)}.warn{color:var(--warn)}.bad{color:var(--bad)}li{margin:.25rem 0}ul{padding-left:1.2rem}
|
||||
code{font-size:.9em}.src{display:inline-block;margin-right:1rem}
|
||||
</style></head><body>
|
||||
<h1>The mesh at a glance</h1>
|
||||
<p class="mute">Read at {{clock .At}}. Sources:
|
||||
{{range .Sources}}<span class="src">{{.Name}}: {{if .Answered}}answered {{clock .Last}}{{else}}<span class="bad">does not answer</span>{{if .Mark}} ({{.Mark.Cause}}: {{.Mark.Why}}; last answer {{clock .Last}}){{end}}{{end}}</span>{{end}}</p>
|
||||
|
||||
<h2>Designs in progress</h2>
|
||||
{{range .Sources}}{{if and (eq .Name "issue-tracker") (not .Answered)}}<p class="bad">The issue tracker does not answer: this block is not empty, it is unknown (last answer {{clock .Last}}).</p>{{end}}{{end}}
|
||||
{{if .Designs}}<ul>{{range .Designs}}<li><strong>Design {{.Design}}, milestone {{.ID}}</strong> ({{.Status}}): {{.Outcome}}
|
||||
{{if .Issues}}<ul>{{range .Issues}}<li>issue {{.Number}}{{if .Status}} <span class="mute">[{{.Status}}]</span>{{end}} {{.Title}}</li>{{end}}</ul>{{end}}</li>{{end}}</ul>
|
||||
{{else}}<p class="mute">No design has a milestone open.</p>{{end}}
|
||||
|
||||
<h2>Records</h2>
|
||||
<h3>Accepted in the last seven days</h3>
|
||||
{{if .Accepted}}<ul>{{range .Accepted}}<li>{{.Record}}: {{.Title}} <span class="mute">({{.Status}}, {{.Date}})</span></li>{{end}}</ul>{{else}}<p class="mute">None.</p>{{end}}
|
||||
<h3>Waiting</h3>
|
||||
{{if .Waiting}}<ul>{{range .Waiting}}<li>{{.Record}}: {{.Title}} <span class="mute">waits for {{.Waits}}</span></li>{{end}}</ul>{{else}}<p class="mute">None.</p>{{end}}
|
||||
|
||||
<h2>Waiting on you</h2>
|
||||
{{range .Sources}}{{if and (eq .Name "operator-channel") (not .Answered)}}<p class="bad">The operator channel does not answer: the asks below are not complete (last answer {{clock .Last}}).</p>{{end}}{{if and (eq .Name "mesh-delivery") (not .Answered)}}<p class="bad">Delivery does not answer: the groups below are not complete (last answer {{clock .Last}}).</p>{{end}}{{end}}
|
||||
<h3>Records waiting for your Approve</h3>
|
||||
{{if .Operator.Records}}<ul>{{range .Operator.Records}}<li>{{.Record}}: {{.Title}}</li>{{end}}</ul>{{else}}<p class="mute">None.</p>{{end}}
|
||||
<h3>Open asks</h3>
|
||||
{{if .Operator.Asks}}<ul>{{range .Operator.Asks}}<li>{{.Headline}} <span class="mute">from {{.Asker}}{{if .Offered}}, offered on {{join .Offered ", "}}{{end}}</span></li>{{end}}</ul>{{else}}<p class="mute">None.</p>{{end}}
|
||||
<h3>Delivery groups waiting</h3>
|
||||
{{if .Operator.Groups}}<ul>{{range .Operator.Groups}}<li><code>{{.ID}}</code> {{.State}}{{if .Why}}: {{.Why}}{{end}}</li>{{end}}</ul>{{else}}<p class="mute">None.</p>{{end}}
|
||||
</body></html>`))
|
||||
|
||||
// ServePage starts the overview: the model's first view, the events kept, the unanswered sources asked again on their backoff,
|
||||
// and the page on its address. It runs for the life of the process.
|
||||
func ServePage(k *KB) {
|
||||
o := NewOverview(k.Seats, func() ([]*Entry, error) { return k.Store.All(context.Background()) }, nil)
|
||||
k.Overview = o
|
||||
<-stdio.Served()
|
||||
for source, events := range OverviewEvents {
|
||||
for _, pattern := range events {
|
||||
source, pattern := source, pattern
|
||||
go func() {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.Subscribe(pattern, func(e stdio.Envelope) error {
|
||||
o.Heard(source, pattern, e.Body)
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
logf("[mesh-knowledge] the page does not hear %s yet (%v); asking again in %s", pattern, err, wait)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
o.Start()
|
||||
go func() {
|
||||
for range time.Tick(5 * time.Second) {
|
||||
o.Silent()
|
||||
o.Due()
|
||||
}
|
||||
}()
|
||||
addr := os.Getenv("MESH_KNOWLEDGE_PAGE")
|
||||
if addr == "" {
|
||||
addr = PageAddress
|
||||
}
|
||||
if addr == "off" {
|
||||
return // a test's bundle serves no page
|
||||
}
|
||||
srv := &http.Server{Addr: addr, Handler: PageHandler(o), ReadHeaderTimeout: 10 * time.Second}
|
||||
logf("[mesh-knowledge] the overview page listens on %s", addr)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
logf("[mesh-knowledge] the overview page stopped: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The page's handler answers GET and nothing else; its manifest declares the port for this machine only and requires a route
|
||||
// (ADR 0616 §4, AC P2).
|
||||
func TestThePageAnswersGetOnlyAndTheManifestKeepsItToThisMachine(t *testing.T) {
|
||||
w := newOverviewWorld(t)
|
||||
w.entries = []*Entry{recordEntry(301, StatusAccepted, "2026-11-02", w.now.Add(-24*time.Hour), nil)}
|
||||
w.o.Start()
|
||||
h := PageHandler(w.o)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
body := rec.Body.String()
|
||||
if rec.Code != 200 || !strings.Contains(body, "Design 47, milestone 1") || !strings.Contains(body, "ADR 0301") || !strings.Contains(body, "Approve ADR 0301") ||
|
||||
!strings.Contains(body, "fix/a") {
|
||||
t.Fatalf("%d\n%.600s", rec.Code, body)
|
||||
}
|
||||
if strings.Contains(body, "<form") || strings.Contains(body, "<script") || !strings.Contains(rec.Header().Get("Content-Security-Policy"), "default-src 'none'") {
|
||||
t.Fatalf("the page carries a form or a script, or no policy: %v", rec.Header())
|
||||
}
|
||||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch, http.MethodHead, http.MethodOptions} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(method, "/", strings.NewReader("x")))
|
||||
if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != http.MethodGet {
|
||||
t.Errorf("%s answered %d", method, rec.Code)
|
||||
}
|
||||
}
|
||||
if rec := httptest.NewRecorder(); func() int { h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/other", nil)); return rec.Code }() != 404 {
|
||||
t.Error("another path answered")
|
||||
}
|
||||
// A source that does not answer is said on the page, never shown as an empty list.
|
||||
w.fail[AsksVerb] = true
|
||||
w.o.Rebuild(SourceAsks)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if !strings.Contains(rec.Body.String(), "The operator channel does not answer") || !strings.Contains(rec.Body.String(), "verb: no answer in time") {
|
||||
t.Fatalf("%.1500s", rec.Body.String())
|
||||
}
|
||||
// The manifest: the port listens from this machine, the route is required and the page is the one the code serves.
|
||||
_, raw := readManifest(t)
|
||||
var m struct {
|
||||
Requires []any `json:"requires"`
|
||||
Listens []struct {
|
||||
Name string `json:"name"`
|
||||
Port int `json:"port"`
|
||||
From string `json:"from"`
|
||||
} `json:"listens"`
|
||||
Contributes map[string]map[string]string `json:"contributes"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Listens) != 1 || m.Listens[0].From != "machine" || ":"+strconv.Itoa(m.Listens[0].Port) != PageAddress || m.Contributes["route"]["endpoint"] != m.Listens[0].Name {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
if !slices.Contains(m.Requires, any("route")) {
|
||||
t.Fatal("the route is not required")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"slug": "knowledge",
|
||||
"requires": [
|
||||
"postgres-database",
|
||||
"route",
|
||||
{
|
||||
"name": "records", "storage": "ledger", "template": "ledger", "options": {"max-mib": 1024},
|
||||
"types": [
|
||||
@@ -82,11 +83,25 @@
|
||||
"contributes": {
|
||||
"postgres-database": {
|
||||
"name": "knowledge"
|
||||
},
|
||||
"route": {
|
||||
"label": "knowledge",
|
||||
"endpoint": "overview"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"postgres-database": "${dir:state}/database.json"
|
||||
"postgres-database": "${dir:state}/database.json",
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "overview",
|
||||
"port": 8480,
|
||||
"protocol": "tcp",
|
||||
"from": "machine",
|
||||
"why": "the overview page, read-only: only a process on this machine reaches it, and the operator through the route proxy (novox/hq ADR 0616 §4)"
|
||||
}
|
||||
],
|
||||
"secrets": {
|
||||
"postgres-database": "${dir:state}/database.secret"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user