// Package sandbox — realtime_module.go // // // Allows extensions to publish events to WebSocket channels. // Events are scoped to rooms — only clients subscribed to the // channel receive the event. // // Starlark API: // realtime.publish(channel, event, data={}) // // Requires permission: realtime.publish package sandbox import ( "context" "encoding/json" "fmt" "strings" "time" "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" "switchboard-core/events" ) // maxRealtimePayload is the payload size limit (pg_notify safe). const maxRealtimePayload = 7168 // 7KB // reservedPrefixes are event label prefixes that extensions cannot publish to. var reservedPrefixes = []string{ "user.", "system.", "model.", "role.", "notification.", "workflow.", "plugin.", "internal.", "db.", "tool.", "trigger.", "extension.", "ping", "pong", "room.", } // BuildRealtimeModule creates the "realtime" Starlark module for a package. // Requires the realtime.publish permission. func BuildRealtimeModule(ctx context.Context, bus *events.Bus, packageID string) *starlarkstruct.Module { return MakeModule("realtime", starlark.StringDict{ "publish": starlark.NewBuiltin("realtime.publish", realtimePublish(ctx, bus, packageID)), }) } func realtimePublish(ctx context.Context, bus *events.Bus, packageID string) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { return func(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var channel, event string var data *starlark.Dict if err := starlark.UnpackArgs(b.Name(), args, kwargs, "channel", &channel, "event", &event, "data?", &data, ); err != nil { return nil, err } if channel == "" { return nil, fmt.Errorf("realtime.publish: channel is required") } if event == "" { return nil, fmt.Errorf("realtime.publish: event is required") } // Reject reserved event prefixes for _, prefix := range reservedPrefixes { if strings.HasPrefix(event, prefix) { return nil, fmt.Errorf("realtime.publish: event %q uses reserved prefix %q", event, prefix) } } // Build payload map payload := map[string]any{ "_pkg": packageID, } if data != nil { for _, item := range data.Items() { k, ok := starlark.AsString(item[0]) if !ok { return nil, fmt.Errorf("realtime.publish: dict key must be string, got %s", item[0].Type()) } if k == "_pkg" { continue // reserved field } payload[k] = starlarkToGoVal(item[1]) } } // Marshal and check size jsonBytes, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("realtime.publish: failed to marshal data: %w", err) } if len(jsonBytes) > maxRealtimePayload { return nil, fmt.Errorf("realtime.publish: payload too large (%d bytes, max %d)", len(jsonBytes), maxRealtimePayload) } // Publish to event bus bus.Publish(events.Event{ Label: "realtime." + event, Room: channel, Payload: jsonBytes, Ts: time.Now().UnixMilli(), }) return starlark.True, nil } } // starlarkToGoVal converts a Starlark value to a Go value for JSON marshaling. func starlarkToGoVal(v starlark.Value) any { switch val := v.(type) { case starlark.NoneType: return nil case starlark.Bool: return bool(val) case starlark.Int: if i, ok := val.Int64(); ok { return i } return val.String() case starlark.Float: return float64(val) case starlark.String: return string(val) case *starlark.Dict: m := make(map[string]any, val.Len()) for _, item := range val.Items() { k, ok := starlark.AsString(item[0]) if !ok { k = item[0].String() } m[k] = starlarkToGoVal(item[1]) } return m case *starlark.List: n := val.Len() s := make([]any, n) for i := 0; i < n; i++ { s[i] = starlarkToGoVal(val.Index(i)) } return s case starlark.Tuple: s := make([]any, len(val)) for i, v := range val { s[i] = starlarkToGoVal(v) } return s default: return v.String() } }