Files
zeekay 238dd248ed Read the platform model, do not ship it
The model was compiled in with go:embed, so every clone and every binary
carried a 1.9MB artifact this repository does not own, under a licence that
needed its own carve-out in LICENSE, its own section in NOTICE, and a
LICENSE-EPL beside them.

It is now read at start from model.json in the data directory. A deployment
that has the artifact supplies it; this repository redistributes nothing.
Absent is not an error -- loadModel returns nil for a file that is not there
and buildHierarchy already answered an empty hierarchy for input it cannot
parse -- so a server without one starts and serves an empty model rather
than refusing to boot over a file it cannot carry.

The model moves onto the server, which is where a value read at Register
belongs; sessions reach it through the server they already hold. Tests carry
their own small model, the ten classes they exercise, hand-written: they
never depended on its contents, only that the model served is the model
held.

LICENSE is MIT OR Apache-2.0 with no exception. NOTICE lists dependencies
only.
2026-09-01 00:48:49 -07:00

446 lines
13 KiB
Go

package transactor
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/hook"
"github.com/hanzoai/team/pkg/model"
"github.com/hanzoai/team/pkg/token"
"golang.org/x/net/websocket"
)
// mount is the WS path the client dials after selectWorkspace:
// wss://host/transactor/<workspace-token>?sessionId=<id>. The account API's
// selectWorkspace returns this base as the WorkspaceLoginInfo.endpoint.
const mount = "/transactor"
// Heartbeat frames (the client's pingConst/pongConst), carried verbatim inside
// a ZAP envelope.
const (
pingFrame = "ping"
pongFrame = "pong!"
)
// server holds the transactor's shared, process-lifetime state: the per-
// workspace SQLite store (the structured data plane — no KV, no Postgres), the
// class hierarchy parsed from the embedded model, and the live-broadcast hub.
type server struct {
app core.App
store *store
hier *hierarchy
hub *hub
secret string
// model is the platform model served to clients, read from the data dir at
// Register. Empty when the deployment supplies none — see model.go.
model []byte
modelHash string
}
// Register binds the ZAP transactor WebSocket on app.
func Register(app core.App) {
model := loadModel(app.DataDir())
srv := &server{
app: app,
store: newStore(filepath.Join(app.DataDir(), "team_workspaces")),
hier: buildHierarchy(model),
model: model,
modelHash: hashModel(model),
hub: newHub(),
secret: env("SERVER_SECRET", token.DefaultSecret),
}
// Publish the singleton so the mirror can project Base-collection writes into
// this workspace store (the ONE plane bridge). One server, one store.
live = srv
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
Func: func(e *core.ServeEvent) error {
e.Router.GET(mount+"/{token...}", func(re *core.RequestEvent) error {
return srv.serve(re)
})
return e.Next()
},
})
}
func (srv *server) serve(re *core.RequestEvent) error {
// The token is the path tail (a JWT, one segment); sessionId rides the query.
raw := strings.TrimPrefix(re.Request.URL.Path, mount+"/")
if i := strings.IndexByte(raw, '/'); i >= 0 {
raw = raw[:i]
}
t, err := token.Decode(raw, srv.secret, true)
if err != nil || t.Account == "" || t.Workspace == "" {
return re.UnauthorizedError("invalid workspace token", err)
}
org, _ := t.Extra["org"].(string)
sess := &session{
server: srv,
store: srv.store,
hier: srv.hier,
account: t.Account,
org: org,
workspace: t.Workspace,
sessionID: re.Request.URL.Query().Get("sessionId"),
}
ws := websocket.Server{
// Same-origin behind the cluster ingress (which enforces TLS); the
// workspace token in the path is the real authorization.
Handshake: func(*websocket.Config, *http.Request) error { return nil },
Handler: func(conn *websocket.Conn) {
defer conn.Close()
sess.conn = conn
sess.seedWorkspace()
sess.backfillFromBase() // project existing Base members/channels/messages into the plane
srv.hub.add(sess)
defer srv.hub.remove(sess)
sess.loop(conn)
},
}
ws.ServeHTTP(re.Response, re.Request)
return nil
}
// session is one live transactor connection, scoped to a (workspace, account).
type session struct {
server *server
store *store
hier *hierarchy
conn *websocket.Conn
wmu sync.Mutex // serializes writes (loop replies + hub broadcasts)
account string
org string // IAM tenant (token extra.org); scopes the data path
workspace string
sessionID string
}
// loop is the frame pump: every WS frame is a ZAP envelope wrapping a JSON-RPC
// message. Decode → dispatch → reply (ZAP-wrapped). msgpack never appears —
// hello negotiates binary:false so the client serializes JSON.
func (s *session) loop(conn *websocket.Conn) {
for {
var frame []byte
if err := websocket.Message.Receive(conn, &frame); err != nil {
return // client closed / read error
}
env, err := Decode(frame)
if err != nil {
continue // not a ZAP frame; ignore
}
reply := s.handle(env.Payload)
if reply == nil {
continue
}
if err := s.send(KindResponse, env.ID, reply); err != nil {
return
}
}
}
// send writes one ZAP-wrapped payload, serialized against concurrent broadcasts.
func (s *session) send(kind Kind, id uint32, payload []byte) error {
s.wmu.Lock()
defer s.wmu.Unlock()
return websocket.Message.Send(s.conn, Encode(Envelope{ID: id, Kind: kind, Payload: payload}))
}
// request is the JSON-RPC envelope carried inside the ZAP payload.
type request struct {
ID int64 `json:"id"`
Method string `json:"method"`
Params []json.RawMessage `json:"params"`
}
// handle dispatches one RPC and returns the JSON reply payload (or nil).
func (s *session) handle(payload []byte) []byte {
if string(payload) == pingFrame {
return []byte(pongFrame)
}
var req request
if err := json.Unmarshal(payload, &req); err != nil {
return nil
}
switch req.Method {
case "hello":
return s.hello(req.ID)
case "loadModel":
return s.loadModel(req.ID)
case "getAccount":
return s.result(req.ID, s.accountObj())
case "findAll":
return s.findAll(req.ID, req.Params)
case "findOne":
return s.findOne(req.ID, req.Params)
case "tx":
return s.tx(req.ID, req.Params)
case "domainRequest":
return s.domainRequest(req.ID, req.Params)
case "searchFulltext":
// Full-text is served by hanzoai/search, not the SQLite data plane;
// until that proxy lands an empty result keeps queries non-fatal.
return s.result(req.ID, map[string]any{"docs": []any{}, "total": 0})
case "loadChunk":
return s.result(req.ID, map[string]any{"idx": 0, "docs": []any{}, "finished": true})
case "getDomainHash":
return s.result(req.ID, "")
case "loadDocs":
return s.result(req.ID, []any{})
default:
return s.result(req.ID, nil)
}
}
// findAll resolves the queried class to its concrete descendants, scans the
// workspace store, filters by query (+ mixin), sorts, limits, and returns a
// TotalArray — the wire shape the client's rpc reviver expects.
func (s *session) findAll(id int64, params []json.RawMessage) []byte {
var class string
if len(params) > 0 {
_ = json.Unmarshal(params[0], &class)
}
var query map[string]any
if len(params) > 1 {
_ = json.Unmarshal(params[1], &query)
}
var opts struct {
Sort json.RawMessage `json:"sort"`
Limit *int `json:"limit"`
Lookup map[string]any `json:"lookup"`
}
if len(params) > 2 {
_ = json.Unmarshal(params[2], &opts)
}
matched := s.queryDocs(class, query)
resultSort(matched, parseSort(opts.Sort))
total := len(matched)
if opts.Limit != nil && *opts.Limit >= 0 && *opts.Limit < total {
matched = matched[:*opts.Limit]
}
matched = s.applyLookups(matched, opts.Lookup)
return s.result(id, totalArray(matched, total))
}
func (s *session) findOne(id int64, params []json.RawMessage) []byte {
var class string
if len(params) > 0 {
_ = json.Unmarshal(params[0], &class)
}
var query map[string]any
if len(params) > 1 {
_ = json.Unmarshal(params[1], &query)
}
matched := s.queryDocs(class, query)
if len(matched) == 0 {
return s.result(id, nil)
}
return s.result(id, matched[0])
}
// queryDocs is the shared find core: candidate scan by class hierarchy, then
// mixin + query filtering.
func (s *session) queryDocs(class string, query map[string]any) []map[string]any {
candidates := s.hier.candidates(class)
docs, err := s.store.byClasses(s.org, s.workspace, candidates)
if err != nil {
return nil
}
isMixin := s.hier.isMixin(class)
var matched []map[string]any
for _, doc := range docs {
if isMixin && !hasMixin(doc, class) {
continue
}
// Mixin queries match the mixin's fields as if top-level (the `$as`
// semantics): overlay the mixin sub-object for matching, but return the
// full doc — the client casts it itself.
matchDoc := doc
if isMixin {
matchDoc = mixinView(doc, class)
}
if query != nil && !matchQuery(matchDoc, query) {
continue
}
matched = append(matched, doc)
}
return matched
}
// tx applies the transaction to the workspace store and broadcasts the applied
// tx(es) so every session's live queries refresh.
func (s *session) tx(id int64, params []json.RawMessage) []byte {
if len(params) == 0 {
return s.result(id, map[string]any{})
}
res, applied := s.applyTx(params[0])
if len(applied) > 0 {
s.server.hub.broadcast(s.workspace, applied)
}
return s.result(id, res)
}
// domainRequest serves operation-domain reads. The communication domain
// (labels, notifications, collaborators…) has no SQLite backing yet; returning
// a well-formed empty DomainResult keeps those live queries from crashing on
// `.value` of null.
func (s *session) domainRequest(id int64, params []json.RawMessage) []byte {
var domain string
if len(params) > 0 {
_ = json.Unmarshal(params[0], &domain)
}
// The client reads DomainResult.value and, for notifications, calls .pop()
// on it — so every communication read returns an empty ARRAY (never an
// object), which keeps labels/notifications/contexts queries non-fatal until
// the communication plane has its own store.
return s.result(id, map[string]any{"domain": domain, "value": []any{}})
}
// hello answers the handshake. binary:false forces JSON so no msgpack is ever
// exchanged; lastHash/account let the client build its model + identity.
// serverVersion is the MODEL version (model.Version, NOT TEAM_VERSION) —
// the number the front's version check compares against.
func (s *session) hello(id int64) []byte {
return mustJSON(map[string]any{
"id": id, // -1
"result": "hello",
"binary": false,
"useCompression": false,
"serverVersion": model.Version(),
"lastHash": s.server.modelHash,
"reconnect": false,
"account": s.accountObj(),
})
}
// loadModel returns the full platform model. We always send full=true (the
// embedded model is the source of truth); the client rebuilds its hierarchy.
func (s *session) loadModel(id int64) []byte {
return mustJSON(map[string]any{
"id": id,
"result": map[string]any{
"full": true,
"hash": s.server.modelHash,
"transactions": json.RawMessage(s.server.model),
},
})
}
// accountObj is the core Account the client caches from hello/getAccount.
func (s *session) accountObj() map[string]any {
return map[string]any{
"uuid": s.account,
"role": "OWNER",
"primarySocialId": "hanzo:" + s.account,
"socialIds": []string{"hanzo:" + s.account},
"fullSocialIds": []any{},
}
}
func (s *session) result(id int64, value any) []byte {
return mustJSON(map[string]any{"id": id, "result": value})
}
// totalArray is the serialized FindResult shape: the rpc reviver turns
// {dataType:'TotalArray',total,value} back into an array carrying .total.
func totalArray(docs []map[string]any, total int) map[string]any {
if docs == nil {
docs = []map[string]any{}
}
return map[string]any{"dataType": "TotalArray", "total": total, "value": docs}
}
func hasMixin(doc map[string]any, mixin string) bool {
_, ok := doc[mixin].(map[string]any)
return ok
}
// mixinView overlays a doc's mixin sub-object onto a shallow copy so a mixin
// query can match the mixin's fields at top level (the `$as`). The original doc
// is never mutated; the caller returns it unchanged.
func mixinView(doc map[string]any, mixin string) map[string]any {
sub, ok := doc[mixin].(map[string]any)
if !ok {
return doc
}
merged := make(map[string]any, len(doc)+len(sub))
for k, v := range doc {
merged[k] = v
}
for k, v := range sub {
merged[k] = v
}
return merged
}
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
return []byte(`{"error":{"code":"marshal"}}`)
}
return b
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// ── live broadcast hub ────────────────────────────────────────────────────────
// hub fans applied txes to every open session of a workspace so live queries
// refresh in real time (and across a user's tabs).
type hub struct {
mu sync.Mutex
ws map[string]map[*session]bool
}
func newHub() *hub { return &hub{ws: map[string]map[*session]bool{}} }
func (h *hub) add(s *session) {
h.mu.Lock()
defer h.mu.Unlock()
set := h.ws[s.workspace]
if set == nil {
set = map[*session]bool{}
h.ws[s.workspace] = set
}
set[s] = true
}
func (h *hub) remove(s *session) {
h.mu.Lock()
defer h.mu.Unlock()
if set := h.ws[s.workspace]; set != nil {
delete(set, s)
if len(set) == 0 {
delete(h.ws, s.workspace)
}
}
}
func (h *hub) broadcast(workspace string, txes []json.RawMessage) {
h.mu.Lock()
var targets []*session
for s := range h.ws[workspace] {
targets = append(targets, s)
}
h.mu.Unlock()
if len(targets) == 0 {
return
}
// A no-id {result:[tx...]} message is the client's tx-broadcast shape.
parts := make([]string, len(txes))
for i, tx := range txes {
parts[i] = string(tx)
}
payload := []byte(`{"result":[` + strings.Join(parts, ",") + `]}`)
for _, s := range targets {
_ = s.send(KindPush, 0, payload)
}
}