feat: initial scaffold of uniweb — unibus web frontend (SPA + gateway)
Extracted from unibus v0.13.0: the chat SPA (web/, React+Mantine, per-user BIP39 wallet) and the web gateway (cmd/webgw, REST+SSE) that acts as a bus peer for the browser. Consumes unibus as a Go module via replace => ../unibus, keeping its own replace fn-registry for the cybersecurity primitives. go build/vet/test and pnpm build green in the new location.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// registerReq is the POST /api/register body. It mirrors the bus contract exactly
|
||||
// (token + the two PUBLIC key halves, each 64 hex chars). The private key never
|
||||
// appears here — registration only publishes the public identity. The handle and
|
||||
// role are NOT accepted from the client; they are fixed by the invite the token
|
||||
// belongs to (no privilege escalation).
|
||||
type registerReq struct {
|
||||
Token string `json:"token"`
|
||||
SignPub string `json:"sign_pub"`
|
||||
KexPub string `json:"kex_pub"`
|
||||
}
|
||||
|
||||
// registerResp is what we return to the browser on success. The bus's /register
|
||||
// (issue: user-accounts) decides handle/role from the invite; in mock mode the
|
||||
// gateway echoes the configured pair so the SPA can greet the new user.
|
||||
type registerResp struct {
|
||||
Handle string `json:"handle"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// registrar fulfils POST /api/register. It targets the bus's POST /register
|
||||
// endpoint (added by the user-accounts work, bus >= 0.12.0). Until that endpoint
|
||||
// is rolled out, a built-in mock validates against a configured set of one-shot
|
||||
// tokens so the whole wallet flow is testable locally. Mock tokens are checked
|
||||
// first; anything else is proxied to the real bus when --register-url is set.
|
||||
type registrar struct {
|
||||
mu sync.Mutex
|
||||
|
||||
registerURL string // bus POST /register; empty => mock-only
|
||||
httpc *http.Client // for proxying to the bus
|
||||
mockTokens map[string]*mockToken // configured one-shot invites for local testing
|
||||
}
|
||||
|
||||
// mockToken is a local stand-in for a bus invite: a token that maps to a fixed
|
||||
// handle+role and can be consumed exactly once.
|
||||
type mockToken struct {
|
||||
handle string
|
||||
role string
|
||||
used bool
|
||||
}
|
||||
|
||||
// newRegistrar parses the --mock-tokens spec ("tok=handle:role,tok2=h2:role2")
|
||||
// and configures the optional proxy target.
|
||||
func newRegistrar(registerURL, mockSpec string) *registrar {
|
||||
r := ®istrar{
|
||||
registerURL: strings.TrimSpace(registerURL),
|
||||
httpc: &http.Client{Timeout: 10 * time.Second},
|
||||
mockTokens: map[string]*mockToken{},
|
||||
}
|
||||
for _, part := range strings.Split(mockSpec, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
// tok=handle:role (role optional, defaults to member)
|
||||
eq := strings.IndexByte(part, '=')
|
||||
if eq < 0 {
|
||||
continue
|
||||
}
|
||||
tok := strings.TrimSpace(part[:eq])
|
||||
hr := strings.TrimSpace(part[eq+1:])
|
||||
handle, role := hr, "member"
|
||||
if c := strings.IndexByte(hr, ':'); c >= 0 {
|
||||
handle, role = strings.TrimSpace(hr[:c]), strings.TrimSpace(hr[c+1:])
|
||||
}
|
||||
if tok != "" && handle != "" {
|
||||
r.mockTokens[tok] = &mockToken{handle: handle, role: role}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// mockTokenCount counts configured mock tokens in a --mock-tokens spec (for the
|
||||
// startup log line).
|
||||
func mockTokenCount(spec string) int {
|
||||
n := 0
|
||||
for _, part := range strings.Split(spec, ",") {
|
||||
if p := strings.TrimSpace(part); p != "" && strings.ContainsRune(p, '=') {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// validHexKey reports whether s is exactly 64 lowercase/uppercase hex chars (a
|
||||
// 32-byte key). Both sign_pub and kex_pub are 32-byte keys.
|
||||
func validHexKey(s string) bool {
|
||||
if len(s) != 64 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// handleRegister validates the keys and consumes the token. Order of resolution:
|
||||
// 1. strict validation of the public keys (defends both mock and proxy paths);
|
||||
// 2. mock token (one-shot) if configured;
|
||||
// 3. proxy to the bus /register if --register-url is set;
|
||||
// 4. otherwise reject with a clear error.
|
||||
func (s *server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerReq
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.Token = strings.TrimSpace(req.Token)
|
||||
if req.Token == "" {
|
||||
writeErr(w, http.StatusBadRequest, "token required")
|
||||
return
|
||||
}
|
||||
if !validHexKey(req.SignPub) {
|
||||
writeErr(w, http.StatusBadRequest, "sign_pub must be 64 hex chars (32 bytes)")
|
||||
return
|
||||
}
|
||||
if !validHexKey(req.KexPub) {
|
||||
writeErr(w, http.StatusBadRequest, "kex_pub must be 64 hex chars (32 bytes)")
|
||||
return
|
||||
}
|
||||
|
||||
reg := s.registrar
|
||||
|
||||
// 2) mock one-shot token.
|
||||
reg.mu.Lock()
|
||||
mt, isMock := reg.mockTokens[req.Token]
|
||||
if isMock {
|
||||
if mt.used {
|
||||
reg.mu.Unlock()
|
||||
writeErr(w, http.StatusConflict, "invite already used")
|
||||
return
|
||||
}
|
||||
mt.used = true
|
||||
handle, role := mt.handle, mt.role
|
||||
reg.mu.Unlock()
|
||||
writeJSON(w, http.StatusCreated, registerResp{Handle: handle, Role: role})
|
||||
return
|
||||
}
|
||||
reg.mu.Unlock()
|
||||
|
||||
// 3) proxy to the real bus /register when configured.
|
||||
if reg.registerURL != "" {
|
||||
s.proxyRegister(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
// 4) no mock match, no proxy target.
|
||||
writeErr(w, http.StatusBadRequest, "invalid or unknown token (and no bus /register configured)")
|
||||
}
|
||||
|
||||
// proxyRegister forwards the registration to the bus's POST /register. The bus
|
||||
// validates the invite (existence, not-used, not-expired) and adds the public
|
||||
// identity to the allowlist with the invite's handle+role. This is unsigned by
|
||||
// design: the TOKEN authorizes the call, not an admin signature.
|
||||
func (s *server) proxyRegister(w http.ResponseWriter, req registerReq) {
|
||||
body, _ := json.Marshal(req)
|
||||
resp, err := s.registrar.httpc.Post(
|
||||
s.registrar.registerURL,
|
||||
"application/json",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, "bus register unreachable: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
|
||||
// On success, try to pass through the bus's handle/role if it returned them;
|
||||
// otherwise a bare 201 is still success.
|
||||
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
|
||||
var rr registerResp
|
||||
_ = json.Unmarshal(raw, &rr)
|
||||
writeJSON(w, http.StatusCreated, rr)
|
||||
return
|
||||
}
|
||||
// Forward the bus's error verbatim where possible.
|
||||
msg := strings.TrimSpace(string(raw))
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("bus register failed (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
writeErr(w, resp.StatusCode, msg)
|
||||
}
|
||||
Reference in New Issue
Block a user