mirror of
https://github.com/hanzoai/ui-customization.git
synced 2026-10-30 18:18:42 +00:00
The architectural reference for console's tRPC->ZAP migration. Replaces the in-process tRPC UiCustomization router with a standalone Go binary built on Hanzo Base (embedded encrypted SQLite + plugins) exposing a typed ZAP capability-RPC interface. 11+ services follow this pattern. - proto/ui-customization.zap: canonical schema (zap-spec dialect), source of truth, compiled to Go via zapgen (make zap-gen, idempotent). - server/: ZAP RPC handler at msgType 200. Three decoupled wire layers (transport / envelope / typed payload); capability carried as opaque bytes. Single auth chokepoint (Kind + PermSessionRead bitmask); signature verify gated on a wired issuer registry (TODO -> SPEC.md 2.3). Get + GetModules read from the ui_customization Base collection. Promise-pipelining via an await/resolve table. - main.go: base.New() + vault (per-org encrypted SQLite) + ZAP router. HTTP :8090 (Base health/metrics), ZAP :9999. - cmd/probe/: out-of-process smoke probe. Tests: 4/4 pass (Get, GetModules, permission-denied, pipelining proof showing the dependent call ships before the first answer resolves). Pure-Go (CGO_ENABLED=0); zap-gen + build reproducible. No Prisma/Postgres/Mongo/tRPC.
299 lines
11 KiB
Go
299 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
luxlog "github.com/luxfi/log"
|
|
zaplib "github.com/luxfi/zap"
|
|
zcap "github.com/zap-proto/go/cap"
|
|
|
|
gen "github.com/hanzoai/ui-customization/gen"
|
|
)
|
|
|
|
// PermSessionRead is the CapKindIAMSession low bit gating read operations
|
|
// (zap-spec/capabilities_kinds.md, CapKindIAMSession 0x01 → 1<<0).
|
|
const PermSessionRead uint64 = 1 << 0
|
|
|
|
// Server implements the UiCustomization ZAP capability-RPC interface on top of
|
|
// a Base app. It is the Go peer of console's UiCustomizationTarget: one method
|
|
// per tRPC procedure, each gated on the caller's capability via the single
|
|
// chokepoint requirePermission, then reading from the Base collection.
|
|
type Server struct {
|
|
app core.App
|
|
logger luxlog.Logger
|
|
defaultOrg string
|
|
|
|
// verifier validates capability buffers. Wired to ed25519 (the bootstrap
|
|
// scheme); a PQ deployment swaps in an ML-DSA-65 SchemeVerify + the IAM
|
|
// pubkey registry for IssuerKey. See SPEC.md §2.3.
|
|
verifier zcap.Verifier
|
|
|
|
// promises is the server-side pipelining table: a call may carry
|
|
// PromiseID, and a later call may Target it. Promises are FUTURES — a
|
|
// dependent call that arrives before its target resolves WAITS on the
|
|
// target (it is not rejected), then dispatches against the resolved
|
|
// answer. This is Cap'n Proto promise pipelining: the dependent call is
|
|
// admitted to the server before the target's answer exists, and the
|
|
// round trip is elided. Entries are short-lived (one connection turn).
|
|
mu sync.Mutex
|
|
promises map[uint32]*promiseSlot
|
|
}
|
|
|
|
// promiseSlot is a future for a pipelined call's answer. done is closed when
|
|
// the slot resolves; org is then readable. For UiCustomization the only
|
|
// pipelined value is the authenticated org. resolvedAt drives reaping.
|
|
type promiseSlot struct {
|
|
done chan struct{}
|
|
org string
|
|
resolvedAt time.Time
|
|
}
|
|
|
|
// promiseWaitTimeout bounds how long a dependent call waits for its target to
|
|
// resolve before failing. Generous relative to a same-connection turn.
|
|
const promiseWaitTimeout = 5 * time.Second
|
|
|
|
// getOrCreate returns the slot for id, creating an unresolved one if absent.
|
|
// Both the resolver (the call that owns the PromiseID) and the waiter (a
|
|
// dependent call targeting it) go through here, so whichever races in first
|
|
// creates the shared slot.
|
|
func (s *Server) getOrCreate(id uint32) *promiseSlot {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.reapLocked()
|
|
slot, ok := s.promises[id]
|
|
if !ok {
|
|
slot = &promiseSlot{done: make(chan struct{})}
|
|
s.promises[id] = slot
|
|
}
|
|
return slot
|
|
}
|
|
|
|
// reapLocked drops promise slots that resolved more than promiseWaitTimeout
|
|
// ago — by then any dependent call has either consumed them or timed out, so
|
|
// they are garbage. Bounds the table to in-flight + recently-resolved pipelines.
|
|
// Caller must hold s.mu.
|
|
func (s *Server) reapLocked() {
|
|
cutoff := time.Now().Add(-promiseWaitTimeout)
|
|
for id, slot := range s.promises {
|
|
if !slot.resolvedAt.IsZero() && slot.resolvedAt.Before(cutoff) {
|
|
delete(s.promises, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// resolve fills a promise slot with its answer and wakes any waiters. Safe to
|
|
// call once per slot; a double-resolve (would close a closed channel) is
|
|
// guarded by checking whether org is already set under the lock.
|
|
func (s *Server) resolve(id uint32, org string) {
|
|
slot := s.getOrCreate(id)
|
|
s.mu.Lock()
|
|
select {
|
|
case <-slot.done:
|
|
// already resolved — leave as-is
|
|
default:
|
|
slot.org = org
|
|
slot.resolvedAt = time.Now()
|
|
close(slot.done)
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// await blocks until the target promise resolves or the timeout elapses,
|
|
// returning the resolved org. A nil-id target never happens (callers guard
|
|
// with Target != NoTarget).
|
|
func (s *Server) await(target uint32) (string, bool) {
|
|
slot := s.getOrCreate(target)
|
|
select {
|
|
case <-slot.done:
|
|
s.mu.Lock()
|
|
org := slot.org
|
|
s.mu.Unlock()
|
|
return org, true
|
|
case <-time.After(promiseWaitTimeout):
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// NewServer builds a UiCustomization server. verifier supplies the capability
|
|
// trust anchor; pass a Verifier whose IssuerKey resolves your IAM issuer key.
|
|
func NewServer(app core.App, logger luxlog.Logger, defaultOrg string, verifier zcap.Verifier) *Server {
|
|
return &Server{
|
|
app: app,
|
|
logger: logger,
|
|
defaultOrg: defaultOrg,
|
|
verifier: verifier,
|
|
promises: make(map[uint32]*promiseSlot),
|
|
}
|
|
}
|
|
|
|
// Register wires the server's handler onto a luxfi/zap node at this service's
|
|
// message-type slot. Called from main once the node is constructed.
|
|
func (s *Server) Register(node *zaplib.Node) {
|
|
node.Handle(MsgTypeRouterBase, s.handle)
|
|
}
|
|
|
|
// handle is the ZAP dispatch entrypoint: decode envelope → authorize → route.
|
|
func (s *Server) handle(ctx context.Context, from string, msg *zaplib.Message) (*zaplib.Message, error) {
|
|
req := parseRequest(msg)
|
|
|
|
org, status, errMsg := s.authorize(req)
|
|
if status != StatusOK {
|
|
s.logger.Debug("uic: auth rejected", "from", from, "method", req.Method, "status", status, "err", errMsg)
|
|
return buildResponse(status, req.PromiseID, errorBody(errMsg))
|
|
}
|
|
|
|
// Resolve this call's promise (the authenticated org) so any dependent
|
|
// call WAITING on it can proceed. authorize() already awaited our own
|
|
// target if we had one, so by here `org` is the fully-resolved scope.
|
|
if req.PromiseID != NoTarget {
|
|
s.resolve(req.PromiseID, org)
|
|
}
|
|
|
|
switch req.Method {
|
|
case MethodGet:
|
|
return s.handleGet(req, org)
|
|
case MethodGetModules:
|
|
return s.handleGetModules(req, org)
|
|
default:
|
|
return buildResponse(StatusBadRequest, req.PromiseID, errorBody(fmt.Sprintf("unknown method %d", req.Method)))
|
|
}
|
|
}
|
|
|
|
// authorize resolves the call's effective org and enforces the capability.
|
|
// Returns (org, StatusOK, "") on success; otherwise an error status + message.
|
|
//
|
|
// Pipelining: if the call Targets an earlier promise, its org is inherited
|
|
// from that promise's resolved answer (the dependent call need not re-present
|
|
// scope). The capability is STILL verified on every call — pipelining elides
|
|
// round trips, never authorization.
|
|
func (s *Server) authorize(req Call) (org string, status uint32, errMsg string) {
|
|
c, err := zcap.Wrap(req.Cap)
|
|
if err != nil {
|
|
return "", StatusBadRequest, "malformed capability: " + err.Error()
|
|
}
|
|
|
|
// Kind gate: these methods are defined on a CapKindIAMSession cap.
|
|
if c.Kind() != uint32(zcap.KindIAMSession) {
|
|
return "", StatusForbidden, "capability is not a CapKindIAMSession"
|
|
}
|
|
|
|
// Permission gate — the single chokepoint. Mirrors console requirePermission.
|
|
if c.Permissions()&PermSessionRead == 0 {
|
|
return "", StatusForbidden, "capability lacks PermSessionRead"
|
|
}
|
|
|
|
// Cryptographic verification. The full chain check (signature, expiry,
|
|
// revocation, chain links) lives in verifier.Verify. We run it whenever an
|
|
// issuer registry is wired; with no registry (bootstrap/tests) we skip the
|
|
// signature step but STILL enforce Kind + Permissions above.
|
|
//
|
|
// TODO(SPEC.md §2.3): also bind the cap to the live session via the
|
|
// out-of-band holderSig over a server nonce, and walk the parent chain
|
|
// with verifier.VerifyChain once the IAM pubkey registry is wired here.
|
|
if s.verifier.IssuerKey != nil {
|
|
if err := s.verifier.Verify(c, time.Now().Unix()); err != nil {
|
|
return "", StatusUnauthorized, "capability verify failed: " + err.Error()
|
|
}
|
|
}
|
|
|
|
// Effective org: inherited from a targeted promise, else derived from the
|
|
// cap's holder, else the service default. (Holder→org mapping is an IAM
|
|
// lookup; until that's wired, scope to the default org.)
|
|
//
|
|
// Pipelining: if this call targets an earlier promise, read that promise's
|
|
// resolved answer. The luxfi/zap transport processes a connection's frames
|
|
// strictly FIFO (one handler at a time — see node.go dispatchLoop), and the
|
|
// client ships the target call BEFORE the dependent call (see Client.barrier
|
|
// in client.go). So by the time a dependent call is dispatched, its target
|
|
// has already passed through handle() and resolved. await therefore returns
|
|
// immediately; the timeout exists only as a safety net against a
|
|
// misbehaving client that ships out of order.
|
|
if req.Target != NoTarget {
|
|
org, ok := s.await(req.Target)
|
|
if !ok {
|
|
return "", StatusBadRequest, fmt.Sprintf("pipelined target %d did not resolve in time", req.Target)
|
|
}
|
|
return org, StatusOK, ""
|
|
}
|
|
return s.defaultOrg, StatusOK, ""
|
|
}
|
|
|
|
// handleGet returns the full customization config for the org (present=false
|
|
// when the org is not entitled). Was: uiCustomizationRouter.get.
|
|
func (s *Server) handleGet(req Call, org string) (*zaplib.Message, error) {
|
|
rec, err := s.orgRecord(org)
|
|
if err != nil {
|
|
// No row yet → model as "not present" (matches tRPC null-when-absent).
|
|
body := gen.NewUiCustomizationConfig(gen.UiCustomizationConfigInput{Present: false})
|
|
return buildResponse(StatusOK, req.PromiseID, body)
|
|
}
|
|
|
|
in := gen.UiCustomizationConfigInput{Present: rec.GetBool(fPresent)}
|
|
if in.Present {
|
|
in.Hostname = rec.GetString(fHostname)
|
|
in.DocumentationHref = rec.GetString(fDocumentationHref)
|
|
in.SupportHref = rec.GetString(fSupportHref)
|
|
in.FeedbackHref = rec.GetString(fFeedbackHref)
|
|
in.LogoLightModeHref = rec.GetString(fLogoLightModeHref)
|
|
in.LogoDarkModeHref = rec.GetString(fLogoDarkModeHref)
|
|
in.DefaultModelAdapter = rec.GetString(fDefaultModelAdapter)
|
|
in.DefaultBaseUrlOpenAI = rec.GetString(fDefaultBaseUrlOpenAI)
|
|
in.DefaultBaseUrlAnthropic = rec.GetString(fDefaultBaseUrlAnthropic)
|
|
in.DefaultBaseUrlAzure = rec.GetString(fDefaultBaseUrlAzure)
|
|
in.VisibleModules = textList(modulesOf(rec))
|
|
}
|
|
|
|
body := gen.NewUiCustomizationConfig(in)
|
|
return buildResponse(StatusOK, req.PromiseID, body)
|
|
}
|
|
|
|
// handleGetModules returns just the visible product-module list for the org.
|
|
func (s *Server) handleGetModules(req Call, org string) (*zaplib.Message, error) {
|
|
var mods []string
|
|
if rec, err := s.orgRecord(org); err == nil {
|
|
mods = modulesOf(rec)
|
|
} else {
|
|
mods = visibleModulesFromEnv()
|
|
}
|
|
body := gen.NewVisibleModules(gen.VisibleModulesInput{Modules: textList(mods)})
|
|
return buildResponse(StatusOK, req.PromiseID, body)
|
|
}
|
|
|
|
// orgRecord finds the customization row for an org.
|
|
func (s *Server) orgRecord(org string) (*core.Record, error) {
|
|
col, err := s.app.FindCollectionByNameOrId(CollectionName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.app.FindFirstRecordByFilter(col, "org = {:org}", map[string]any{"org": org})
|
|
}
|
|
|
|
// modulesOf reads the visibleModules JSON array off a record, falling back to
|
|
// the env-derived list when the column is empty/unset.
|
|
func modulesOf(rec *core.Record) []string {
|
|
raw := rec.GetString(fVisibleModules)
|
|
if raw == "" || raw == "null" {
|
|
return visibleModulesFromEnv()
|
|
}
|
|
var mods []string
|
|
if err := json.Unmarshal([]byte(raw), &mods); err != nil || len(mods) == 0 {
|
|
return visibleModulesFromEnv()
|
|
}
|
|
return mods
|
|
}
|
|
|
|
// textList converts a []string into the [][]byte the generated list<text>
|
|
// builders consume (each element is the UTF-8 bytes of the string).
|
|
func textList(ss []string) [][]byte {
|
|
out := make([][]byte, len(ss))
|
|
for i, s := range ss {
|
|
out[i] = []byte(s)
|
|
}
|
|
return out
|
|
}
|