Files
base/plugins/zap/handlers.go
zeekay 344fcd1d51 logger: app.Logger() returns the shim interface, default to luxfi/log
core.App.Logger() now returns logger.Logger (the new shim interface)
instead of *slog.Logger, so every consumer talks to the same
backend-agnostic surface and stops pinning itself to log/slog. The raw
*slog.Logger is still reachable as App.SlogLogger() for the in-app
BatchHandler reload hook and the two tests that type-assert the
underlying handler — that's the only sanctioned escape hatch.

Plugins and network code that previously held *slog.Logger fields move
to luxlog.Logger (kms, vault, zap-transport, base-network/transport_zap,
network/archive_writer, tools/tasks/client). luxfi/zap's Logger field
was a pure transit dependency; we drop it from base call sites since
zaplib has its own default.

Remaining "log/slog" imports — 11 files — are confined to the
adapter/handler bridge (tools/logger and core/log_printer/base/app) and
to two tests that exercise the slog.Handler contract directly. Those
are the irreducible boundary where slog is the language of the
interface.
2026-05-15 04:04:26 -07:00

349 lines
8.9 KiB
Go

package zap
import (
"context"
"encoding/json"
"fmt"
"sync"
"github.com/hanzoai/base/core"
luxlog "github.com/luxfi/log"
zaplib "github.com/luxfi/zap"
)
// ZAP message types for Base operations.
const (
MsgTypeCollections uint16 = 100
MsgTypeRecords uint16 = 101
MsgTypeAuth uint16 = 102
MsgTypeRealtime uint16 = 103
)
// ZAP field offsets (matching orm/db/zap.go protocol).
const (
fieldPath = 4 // Text: request path
fieldBody = 12 // Bytes: JSON body
respStatus = 0 // Uint32: HTTP-style status
respBody = 4 // Bytes: response JSON
)
// handler wraps a Base app to handle ZAP messages.
type handler struct {
app core.App
logger luxlog.Logger
node *zaplib.Node
// Realtime subscriptions: peer -> set of collection IDs/names
subsMu sync.RWMutex
subs map[string]map[string]struct{}
}
func newHandler(app core.App, logger luxlog.Logger) *handler {
return &handler{
app: app,
logger: logger,
subs: make(map[string]map[string]struct{}),
}
}
// setNode sets the ZAP node reference for push notifications.
func (h *handler) setNode(node *zaplib.Node) {
h.node = node
}
// handleCollections handles collection CRUD via ZAP.
func (h *handler) handleCollections(ctx context.Context, from string, msg *zaplib.Message) (*zaplib.Message, error) {
root := msg.Root()
path := root.Text(fieldPath)
body := root.Bytes(fieldBody)
h.logger.Debug("zap: collections", "path", path, "from", from, "bodyLen", len(body))
var req map[string]interface{}
if len(body) > 0 {
json.Unmarshal(body, &req)
}
var result interface{}
var err error
switch path {
case "/list":
collections, findErr := h.app.FindAllCollections()
if findErr != nil {
return h.errorResponse(500, findErr.Error())
}
result = collections
case "/get":
name, _ := req["name"].(string)
if name == "" {
return h.errorResponse(400, "collection name required")
}
col, findErr := h.app.FindCollectionByNameOrId(name)
if findErr != nil {
return h.errorResponse(404, "collection not found")
}
result = col
default:
return h.errorResponse(400, fmt.Sprintf("unknown collections path: %s", path))
}
if err != nil {
return h.errorResponse(500, err.Error())
}
return h.jsonResponse(200, result)
}
// handleRecords handles record CRUD via ZAP.
func (h *handler) handleRecords(ctx context.Context, from string, msg *zaplib.Message) (*zaplib.Message, error) {
root := msg.Root()
path := root.Text(fieldPath)
body := root.Bytes(fieldBody)
h.logger.Debug("zap: records", "path", path, "from", from)
var req map[string]interface{}
if len(body) > 0 {
json.Unmarshal(body, &req)
}
collection, _ := req["collection"].(string)
if collection == "" {
return h.errorResponse(400, "collection required")
}
col, err := h.app.FindCollectionByNameOrId(collection)
if err != nil {
return h.errorResponse(404, "collection not found")
}
switch path {
case "/list":
records, findErr := h.app.FindAllRecords(col)
if findErr != nil {
return h.errorResponse(500, findErr.Error())
}
return h.jsonResponse(200, records)
case "/get":
id, _ := req["id"].(string)
if id == "" {
return h.errorResponse(400, "record id required")
}
record, findErr := h.app.FindRecordById(col, id)
if findErr != nil {
return h.errorResponse(404, "record not found")
}
return h.jsonResponse(200, record)
case "/create":
record := core.NewRecord(col)
data, _ := req["data"].(map[string]interface{})
for k, v := range data {
record.Set(k, v)
}
if saveErr := h.app.Save(record); saveErr != nil {
return h.errorResponse(400, saveErr.Error())
}
return h.jsonResponse(200, record)
case "/update":
id, _ := req["id"].(string)
record, findErr := h.app.FindRecordById(col, id)
if findErr != nil {
return h.errorResponse(404, "record not found")
}
data, _ := req["data"].(map[string]interface{})
for k, v := range data {
record.Set(k, v)
}
if saveErr := h.app.Save(record); saveErr != nil {
return h.errorResponse(400, saveErr.Error())
}
return h.jsonResponse(200, record)
case "/delete":
id, _ := req["id"].(string)
record, findErr := h.app.FindRecordById(col, id)
if findErr != nil {
return h.errorResponse(404, "record not found")
}
if delErr := h.app.Delete(record); delErr != nil {
return h.errorResponse(500, delErr.Error())
}
return h.jsonResponse(200, map[string]string{"deleted": id})
default:
return h.errorResponse(400, fmt.Sprintf("unknown records path: %s", path))
}
}
// handleAuth handles auth operations via ZAP.
//
// The legacy /identity local-password path is retired — Hanzo IAM is
// the only credential issuer. The ZAP transport mirrors the HTTP
// surface and returns 410 Gone for that path so callers stop retrying;
// see apis.externalAuthGuard for the HTTP equivalent.
func (h *handler) handleAuth(ctx context.Context, from string, msg *zaplib.Message) (*zaplib.Message, error) {
root := msg.Root()
path := root.Text(fieldPath)
h.logger.Debug("zap: auth", "path", path, "from", from)
switch path {
case "/identity":
return h.errorResponse(410, "local-password auth is retired — use the IAM PKCE flow")
default:
return h.errorResponse(400, fmt.Sprintf("unknown auth path: %s", path))
}
}
// handleRealtime handles realtime subscription management via ZAP.
func (h *handler) handleRealtime(ctx context.Context, from string, msg *zaplib.Message) (*zaplib.Message, error) {
root := msg.Root()
path := root.Text(fieldPath)
body := root.Bytes(fieldBody)
h.logger.Debug("zap: realtime", "path", path, "from", from)
var req map[string]interface{}
if len(body) > 0 {
json.Unmarshal(body, &req)
}
switch path {
case "/subscribe":
collections, _ := req["collections"].([]interface{})
if len(collections) == 0 {
return h.errorResponse(400, "collections array required")
}
h.subsMu.Lock()
if h.subs[from] == nil {
h.subs[from] = make(map[string]struct{})
}
subscribed := make([]string, 0, len(collections))
for _, c := range collections {
if name, ok := c.(string); ok && name != "" {
h.subs[from][name] = struct{}{}
subscribed = append(subscribed, name)
}
}
h.subsMu.Unlock()
h.logger.Info("zap: subscribed", "peer", from, "collections", subscribed)
return h.jsonResponse(200, map[string]interface{}{
"subscribed": subscribed,
})
case "/unsubscribe":
collections, _ := req["collections"].([]interface{})
h.subsMu.Lock()
if len(collections) == 0 {
// Unsubscribe from all
delete(h.subs, from)
} else {
if peerSubs := h.subs[from]; peerSubs != nil {
for _, c := range collections {
if name, ok := c.(string); ok {
delete(peerSubs, name)
}
}
if len(peerSubs) == 0 {
delete(h.subs, from)
}
}
}
h.subsMu.Unlock()
return h.jsonResponse(200, map[string]string{"status": "unsubscribed"})
case "/status":
h.subsMu.RLock()
peerSubs := h.subs[from]
cols := make([]string, 0, len(peerSubs))
for c := range peerSubs {
cols = append(cols, c)
}
h.subsMu.RUnlock()
return h.jsonResponse(200, map[string]interface{}{
"subscriptions": cols,
})
default:
return h.errorResponse(400, fmt.Sprintf("unknown realtime path: %s", path))
}
}
// broadcastEvent pushes a record change event to all subscribed peers.
func (h *handler) broadcastEvent(collection string, action string, record *core.Record) {
if h.node == nil {
return
}
h.subsMu.RLock()
defer h.subsMu.RUnlock()
payload, _ := json.Marshal(map[string]interface{}{
"action": action,
"collection": collection,
"record": record,
})
for peer, collections := range h.subs {
if _, ok := collections[collection]; !ok {
continue
}
msg, err := h.buildResponse(200, payload)
if err != nil {
h.logger.Warn("zap: failed to build broadcast", "error", err)
continue
}
if err := h.node.Send(context.Background(), peer, msg); err != nil {
h.logger.Debug("zap: send to peer failed, removing subscription",
"peer", peer, "error", err)
// Remove dead peer (defer unlock already held as RLock, so schedule cleanup)
go func(p string) {
h.subsMu.Lock()
delete(h.subs, p)
h.subsMu.Unlock()
}(peer)
}
}
}
// jsonResponse builds a ZAP response message with JSON body.
func (h *handler) jsonResponse(status uint32, data interface{}) (*zaplib.Message, error) {
body, _ := json.Marshal(data)
return h.buildResponse(status, body)
}
// errorResponse builds a ZAP error response.
func (h *handler) errorResponse(status uint32, message string) (*zaplib.Message, error) {
body, _ := json.Marshal(map[string]string{"error": message})
return h.buildResponse(status, body)
}
// buildResponse constructs a ZAP response message.
func (h *handler) buildResponse(status uint32, body []byte) (*zaplib.Message, error) {
b := zaplib.NewBuilder(len(body) + 128)
obj := b.StartObject(20)
obj.SetUint32(respStatus, status)
obj.SetBytes(respBody, body)
obj.FinishAsRoot()
data := b.Finish()
msg, err := zaplib.Parse(data)
if err != nil {
return nil, fmt.Errorf("zap: build response: %w", err)
}
return msg, nil
}