Rename mesh-control -> mesh-controller, substrate -> foundation
One name per thing, per the HQ glossary: the module/container/image/binary/repo becomes mesh-controller, the seat the-controller, and the store+broker pair the foundation (embedded base bundles, default template and example lock renamed with their go:embed directives). No behaviour change — a pure vocabulary rename. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The command API: what the mesh can be asked to do, over a network.
|
||||
//
|
||||
// **An adapter and nothing else** (novox/hq ADR 0035). Every route here calls the same function
|
||||
// the command line calls, so a refusal is the same refusal in the same words. Nothing is decided
|
||||
// in this file — the moment it validates something the command line does not, the mesh has two
|
||||
// answers to one question.
|
||||
//
|
||||
// **It refuses everything unless it was told how to know who is asking.** The board is published
|
||||
// on a public name, and this is what stands behind it: an unauthenticated command surface reachable
|
||||
// from the internet is authority over the mesh handed to whoever finds it. So there is no
|
||||
// permissive default and no flag that removes the check — a mesh that has not been told how to
|
||||
// authenticate serves nothing, loudly.
|
||||
//
|
||||
// The intended authenticator is an OAuth2 provider (ADR 0035), which is an ordinary module. Until
|
||||
// one is configured this refuses, which is the correct behaviour rather than a placeholder: a
|
||||
// surface that worked without authentication would be one somebody left running.
|
||||
func apiCommand(ctx context.Context, args []string) error {
|
||||
set := flag.NewFlagSet("api", flag.ContinueOnError)
|
||||
listen := set.String("listen", "127.0.0.1:8081", "where to serve it")
|
||||
issuer := set.String("issuer", "",
|
||||
"the OAuth2 issuer whose tokens this accepts; without it, nothing is served")
|
||||
if _, err := parseAround(set, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(*issuer) == "" {
|
||||
// Refused at start rather than per request, so it is discovered by whoever ran it rather
|
||||
// than by whoever finds it.
|
||||
return errors.New(
|
||||
"--issuer is not set, and this serves commands rather than pages: it will not run " +
|
||||
"without being told whose tokens to believe. An OAuth2 provider is an ordinary " +
|
||||
"module (novox/hq ADR 0035)")
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: *listen,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
Handler: commands(mustAuthenticate(*issuer)),
|
||||
}
|
||||
fmt.Printf("the command API is on http://%s\n", *listen)
|
||||
fmt.Printf(" it accepts tokens from %s and refuses everything else\n", *issuer)
|
||||
fmt.Printf(" every route calls what the command line calls\n")
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
closing, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(closing)
|
||||
}()
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticator says whether a request may act, and as whom.
|
||||
//
|
||||
// An interface so the check can be driven by a test without an identity provider, and so the one
|
||||
// real implementation is the only thing that has to be right.
|
||||
type Authenticator interface {
|
||||
// Who returns the subject a request is acting as, or an error naming why it may not.
|
||||
Who(r *http.Request) (string, error)
|
||||
}
|
||||
|
||||
// mustAuthenticate is the real one: a bearer token from the configured issuer.
|
||||
//
|
||||
// **Not yet verifying the signature**, and it says so rather than pretending. Verification needs
|
||||
// the issuer's keys, which needs an identity provider to exist — so this refuses every request
|
||||
// until that is built, which is the same answer as having no API at all and is honest about why.
|
||||
func mustAuthenticate(issuer string) Authenticator { return notYet{issuer: issuer} }
|
||||
|
||||
type notYet struct{ issuer string }
|
||||
|
||||
func (n notYet) Who(*http.Request) (string, error) {
|
||||
return "", fmt.Errorf(
|
||||
"this mesh has no way to verify a token from %s yet: the identity provider is a module "+
|
||||
"and none is running. Use the command line, which authenticates through nothing "+
|
||||
"because it is already behind the machine's own login", n.issuer)
|
||||
}
|
||||
|
||||
// commands is the routing, separate so a test can drive it without a listener.
|
||||
func commands(who Authenticator) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("POST /assign", acting(who, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||
return assign(ctx, open, in.Node, in.Module)
|
||||
}))
|
||||
mux.HandleFunc("POST /unassign", acting(who, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||
return unassign(ctx, open, in.Node, in.Module)
|
||||
}))
|
||||
|
||||
// Anything else is said plainly, because a command surface answering 404 to a verb somebody
|
||||
// expected is indistinguishable from one that is down.
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
refuse(w, http.StatusNotFound, fmt.Errorf(
|
||||
"%s %s is not something this mesh can be asked; it accepts POST /assign and "+
|
||||
"POST /unassign", r.Method, r.URL.Path))
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
type request struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
// acting is the shape every route shares: authenticate, read, act, answer.
|
||||
func acting(
|
||||
who Authenticator,
|
||||
do func(context.Context, *stores, request) (string, error),
|
||||
) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := who.Who(r); err != nil {
|
||||
refuse(w, http.StatusUnauthorized, err)
|
||||
return
|
||||
}
|
||||
var in request
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
refuse(w, http.StatusBadRequest, fmt.Errorf("this is not a request this understands: %w", err))
|
||||
return
|
||||
}
|
||||
if in.Node == "" || in.Module == "" {
|
||||
refuse(w, http.StatusBadRequest, errors.New(`both "node" and "module" are needed`))
|
||||
return
|
||||
}
|
||||
|
||||
open, err := openStores(r.Context())
|
||||
if err != nil {
|
||||
refuse(w, http.StatusServiceUnavailable, err)
|
||||
return
|
||||
}
|
||||
defer open.Close()
|
||||
|
||||
said, err := do(r.Context(), open, in)
|
||||
if err != nil {
|
||||
// **The refusal the command line would have given, unchanged.** Carrying `said` with
|
||||
// it matters: an assignment that was kept and still does not resolve is two facts,
|
||||
// and dropping either makes the answer wrong.
|
||||
answer(w, http.StatusConflict, map[string]any{"said": said, "refused": err.Error()})
|
||||
return
|
||||
}
|
||||
answer(w, http.StatusOK, map[string]any{"said": said})
|
||||
}
|
||||
}
|
||||
|
||||
func answer(w http.ResponseWriter, status int, body map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func refuse(w http.ResponseWriter, status int, err error) {
|
||||
answer(w, status, map[string]any{"refused": err.Error()})
|
||||
}
|
||||
Reference in New Issue
Block a user