Feat v0.5.0 realtime dialog (#30)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #30.
This commit is contained in:
38
CHANGELOG.md
38
CHANGELOG.md
@@ -2,6 +2,44 @@
|
||||
|
||||
All notable changes to Switchboard Core are documented here.
|
||||
|
||||
## v0.5.0 — Realtime Primitive + Dialog Audit + Permissions UI
|
||||
|
||||
### Added
|
||||
|
||||
- **Realtime Starlark module**: `realtime.publish(channel, event, data)` lets
|
||||
extensions publish events to WebSocket channels. Gated by the new
|
||||
`realtime.publish` permission. Payloads limited to 7KB (pg_notify safe).
|
||||
Reserved event prefixes (`system.`, `workflow.`, etc.) are blocked.
|
||||
- **WS room protocol**: Clients send `room.subscribe` / `room.unsubscribe`
|
||||
messages to join/leave rooms. Intercepted in the WebSocket readPump before
|
||||
the bus publish gate (like ping). Per-connection cap of 100 rooms.
|
||||
- **SDK realtime module**: `sw.realtime.subscribe(channel, [event], callback)`
|
||||
manages room join/leave over WebSocket and filters incoming `realtime.*`
|
||||
events by room. Returns unsubscribe handle. Auto re-joins all rooms on
|
||||
WebSocket reconnect. `sw.realtime.channels()` debug helper.
|
||||
- **Admin permissions UI**: Packages page gains a "Permissions" button for
|
||||
packages with declared permissions. Inline drawer shows each permission with
|
||||
Grant/Revoke toggle, granted-by user ID, and a "Grant All" bulk action.
|
||||
- **Status badges**: `pending_review` (amber) and `suspended` (red) badges on
|
||||
package rows. Enable toggle disabled with hint when `pending_review`.
|
||||
- **SDK API client**: `permissions()`, `grantPerm()`, `revokePerm()`,
|
||||
`grantAllPerms()` methods added to `sw.api.admin.packages`.
|
||||
- **`starlarkToGoVal()` helper**: Converts Starlark values to Go `any` for
|
||||
JSON marshaling (reverse of existing `goValToStarlark`).
|
||||
- **8 new Go tests**: Route table cases for `realtime.*` and `room.*`,
|
||||
realtime module publish (happy path, empty data, missing args, reserved
|
||||
prefix, payload too large), `starlarkToGoVal` type conversion.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Dialog audit**: 5 bare `confirm()` calls migrated to `await sw.confirm()`
|
||||
with `{ destructive: true }` styling in tasks, schedules, notes (×2), and
|
||||
editor packages. Package archives rebuilt.
|
||||
- SDK version bumped from 0.2.3 to 0.5.0.
|
||||
- Event route table: `realtime.` → DirToClient, `room.subscribe` /
|
||||
`room.unsubscribe` → DirFromClient.
|
||||
- Runner gains `bus` field and `SetBus()` method; wired in main.go.
|
||||
|
||||
## v0.4.7 — Note Graph
|
||||
|
||||
### Added
|
||||
|
||||
10
README.md
10
README.md
@@ -59,10 +59,12 @@ deployment and customization.
|
||||
|
||||
## Project Status
|
||||
|
||||
**v0.3.8** — Distribution. Bundled packages auto-install on first boot,
|
||||
builder image for faster custom builds, per-environment package allowlists.
|
||||
See [ROADMAP.md](ROADMAP.md) for the full journey from v0.1.0 kernel
|
||||
extraction through v0.3.x workflows to the upcoming v0.4.0 Notes surface.
|
||||
**v0.5.0** — Realtime pub/sub primitive, dialog audit, and admin permissions
|
||||
UI. Extensions can now publish events to WebSocket channels via Starlark;
|
||||
clients subscribe with `sw.realtime.subscribe()`. Admin Packages page gains
|
||||
per-permission grant/revoke controls and status badges. See
|
||||
[ROADMAP.md](ROADMAP.md) for the full journey from v0.1.0 kernel extraction
|
||||
through v0.3.x workflows, v0.4.x Notes surface, to v0.5.x realtime and chat.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
17
ROADMAP.md
17
ROADMAP.md
@@ -1,6 +1,6 @@
|
||||
# Switchboard Core — Roadmap
|
||||
|
||||
## Current: v0.4.5 — Editor Modes + Document Outline
|
||||
## Current: v0.5.0 — Realtime Primitive + Dialog Audit + Permissions UI
|
||||
|
||||
Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat
|
||||
features removed from the kernel. What remains is the minimum viable
|
||||
@@ -329,16 +329,17 @@ module. Everything else — conversations, messages, participants — is
|
||||
pure extension code. Human-to-human chat ships pre-MVP; LLM
|
||||
participation is post-MVP.
|
||||
|
||||
### v0.5.0 — Realtime Primitive + Dialog System (planned)
|
||||
### v0.5.0 — Realtime Primitive + Dialog Audit + Permissions UI
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| `realtime.publish()` | | Starlark module: `realtime.publish(channel, event, data)`. Publishes JSON event to WebSocket hub scoped to channel (e.g. `conversation:{id}`). Hub handles fan-out and cross-replica broadcast. |
|
||||
| `sw.realtime.subscribe()` | | SDK method: `sw.realtime.subscribe(channel, callback)`. Client-side WebSocket listener filtered by channel. Returns unsubscribe handle. |
|
||||
| `sw.ui.Dialog` | | SDK modal primitive: configurable title, body, action buttons. Replaces native `alert()`. Promise-based API. |
|
||||
| `sw.ui.Confirm` | | SDK confirm primitive: question + confirm/cancel buttons. Replaces native `confirm()`. Returns promise resolving to boolean. |
|
||||
| `sw.ui.Prompt` | | SDK prompt primitive: label + text input + confirm/cancel. Replaces native `prompt()`. Returns promise resolving to string or null. |
|
||||
| Native dialog audit | | Sweep all surfaces (notes, tasks, schedules, admin, settings). Replace every `prompt()`, `confirm()`, `alert()` with SDK equivalents. |
|
||||
| `realtime.publish()` | ✅ | Starlark module: `realtime.publish(channel, event, data)`. Publishes JSON event to WebSocket hub scoped to channel. Gated by `realtime.publish` permission. 7KB payload limit. Reserved prefix validation. |
|
||||
| WS room protocol | ✅ | `room.subscribe` / `room.unsubscribe` client messages intercepted in readPump. Per-connection 100-room cap. |
|
||||
| `sw.realtime.subscribe()` | ✅ | SDK method: `sw.realtime.subscribe(channel, [event], callback)`. Auto room join/leave, reconnect recovery. Returns unsubscribe handle. |
|
||||
| `sw.ui.Dialog` | ✅ | Pre-existing (implemented before v0.5.0). SDK modal primitive with focus trap, Promise-based API. |
|
||||
| `sw.ui.Confirm` / `sw.ui.Prompt` | ✅ | Pre-existing. `sw.confirm()` and `sw.prompt()` already exposed in SDK. |
|
||||
| Native dialog audit | ✅ | 5 bare `confirm()` calls migrated to `sw.confirm()` in tasks, schedules, notes (×2), editor packages. |
|
||||
| Admin permissions UI | ✅ | Permissions drawer in admin Packages page. Grant/revoke per permission, grant-all bulk action. `pending_review`/`suspended` status badges. SDK API client methods added. Closes gap identified in v0.4.7. |
|
||||
|
||||
### v0.5.1 — Chat Core Library (planned)
|
||||
|
||||
|
||||
@@ -372,8 +372,8 @@
|
||||
// ── File Operations ─────────────────────────
|
||||
|
||||
async function _deleteFile(wsId, path, fileTree, codeEditor) {
|
||||
const ok = typeof showConfirm === 'function'
|
||||
? await showConfirm('Delete ' + path + '?')
|
||||
const ok = typeof sw !== 'undefined' && sw.confirm
|
||||
? await sw.confirm('Delete ' + path + '?', { destructive: true })
|
||||
: window.confirm('Delete ' + path + '?');
|
||||
if (!ok) return;
|
||||
try {
|
||||
|
||||
@@ -811,7 +811,7 @@
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
if (!note || !confirm('Archive this note?')) return;
|
||||
if (!note || !await sw.confirm('Archive this note?', { destructive: true })) return;
|
||||
await api.del('/notes/' + note.id);
|
||||
onDelete();
|
||||
}
|
||||
@@ -1717,7 +1717,7 @@
|
||||
} catch (e) { console.error('Rename folder failed:', e); }
|
||||
}
|
||||
async function handleDeleteFolder(folderId) {
|
||||
if (!confirm('Delete this folder? Notes will be moved to Unfiled.')) return;
|
||||
if (!await sw.confirm('Delete this folder? Notes will be moved to Unfiled.', { destructive: true })) return;
|
||||
try {
|
||||
await api.del('/folders/' + folderId);
|
||||
if (activeFolderId === folderId) setActiveFolderId(null);
|
||||
|
||||
@@ -275,7 +275,7 @@
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
if (!confirm('Delete schedule "' + item.name + '"?')) return;
|
||||
if (!await sw.confirm('Delete schedule "' + item.name + '"?', { destructive: true })) return;
|
||||
try {
|
||||
await apiDelete(item.id);
|
||||
sw.toast('Schedule deleted', 'success');
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (!confirm('Delete this task?')) return;
|
||||
if (!await sw.confirm('Delete this task?', { destructive: true })) return;
|
||||
await api.del('/items/' + id);
|
||||
onRefresh();
|
||||
}
|
||||
|
||||
@@ -117,6 +117,12 @@ func TestRouteFor(t *testing.T) {
|
||||
// Extension lifecycle
|
||||
{"extension.loaded", DirLocal},
|
||||
{"extension.error", DirLocal},
|
||||
// Realtime (v0.5.0)
|
||||
{"realtime.chat.message", DirToClient},
|
||||
{"realtime.custom.event", DirToClient},
|
||||
// Room management (v0.5.0)
|
||||
{"room.subscribe", DirFromClient},
|
||||
{"room.unsubscribe", DirFromClient},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -190,6 +196,27 @@ func TestWaitFor_CleansUpSubscription(t *testing.T) {
|
||||
bus.Publish(Event{Label: "tool.result.cleanup", Payload: json.RawMessage(`{}`)})
|
||||
}
|
||||
|
||||
func TestRealtimeRouting(t *testing.T) {
|
||||
// realtime.* events should be sent to clients but not accepted from them
|
||||
if !ShouldSendToClient("realtime.chat.message") {
|
||||
t.Error("realtime.* should be sent to client")
|
||||
}
|
||||
if ShouldAcceptFromClient("realtime.chat.message") {
|
||||
t.Error("realtime.* should NOT be accepted from client")
|
||||
}
|
||||
|
||||
// room.subscribe/unsubscribe should be accepted from clients
|
||||
if !ShouldAcceptFromClient("room.subscribe") {
|
||||
t.Error("room.subscribe should be accepted from client")
|
||||
}
|
||||
if !ShouldAcceptFromClient("room.unsubscribe") {
|
||||
t.Error("room.unsubscribe should be accepted from client")
|
||||
}
|
||||
if ShouldSendToClient("room.subscribe") {
|
||||
t.Error("room.subscribe should NOT be sent to client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallRouteToClient(t *testing.T) {
|
||||
if !ShouldSendToClient("tool.call.abc123") {
|
||||
t.Error("tool.call.* should be sent to client")
|
||||
|
||||
@@ -87,6 +87,13 @@ var routeTable = map[string]Direction{
|
||||
"trigger.fired": DirLocal, // Trigger invocation event (observability)
|
||||
"trigger.error": DirLocal, // Trigger execution error
|
||||
|
||||
// Realtime (v0.5.0): extension-published events, room-scoped
|
||||
"realtime.": DirToClient,
|
||||
|
||||
// Room management (v0.5.0): client room join/leave
|
||||
"room.subscribe": DirFromClient,
|
||||
"room.unsubscribe": DirFromClient,
|
||||
|
||||
// Heartbeat
|
||||
"ping": DirFromClient,
|
||||
"pong": DirToClient,
|
||||
|
||||
@@ -316,6 +316,26 @@ func (c *Conn) readPump() {
|
||||
continue
|
||||
}
|
||||
|
||||
// v0.5.0: Room management — intercept before bus publish (like ping)
|
||||
if event.Label == "room.subscribe" {
|
||||
var req struct {
|
||||
Room string `json:"room"`
|
||||
}
|
||||
if json.Unmarshal(event.Payload, &req) == nil && req.Room != "" && len(c.rooms) < 100 {
|
||||
c.JoinRoom(req.Room)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if event.Label == "room.unsubscribe" {
|
||||
var req struct {
|
||||
Room string `json:"room"`
|
||||
}
|
||||
if json.Unmarshal(event.Payload, &req) == nil && req.Room != "" {
|
||||
c.LeaveRoom(req.Room)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate: only accept events allowed from clients
|
||||
if !ShouldAcceptFromClient(event.Label) {
|
||||
continue
|
||||
|
||||
@@ -168,6 +168,8 @@ func main() {
|
||||
if cfg.StoragePath != "" {
|
||||
starlarkRunner.SetPackagesDir(cfg.StoragePath + "/packages")
|
||||
}
|
||||
// v0.5.0: realtime module — extension publish to WebSocket channels
|
||||
starlarkRunner.SetBus(bus)
|
||||
// v0.38.5: allow extensions to reach private IPs (self-hosted Gitea, etc.)
|
||||
if os.Getenv("EXT_ALLOW_PRIVATE_IPS") == "true" {
|
||||
starlarkRunner.SetAllowPrivateIPs(true)
|
||||
|
||||
@@ -35,6 +35,7 @@ const (
|
||||
ExtPermWorkflowAccess = "workflow.access" // v0.30.2: workflow definition + stage data access
|
||||
ExtPermConnectionsRead = "connections.read" // v0.38.1: extension connection resolution
|
||||
ExtPermTriggersRegister = "triggers.register" // v0.2.2: event/webhook trigger registration
|
||||
ExtPermRealtimePublish = "realtime.publish" // v0.5.0: publish events to realtime channels
|
||||
)
|
||||
|
||||
// ValidExtensionPermissions is the set of recognized permission keys.
|
||||
@@ -48,6 +49,7 @@ var ValidExtensionPermissions = map[string]bool{
|
||||
ExtPermWorkflowAccess: true,
|
||||
ExtPermConnectionsRead: true,
|
||||
ExtPermTriggersRegister: true,
|
||||
ExtPermRealtimePublish: true,
|
||||
}
|
||||
|
||||
// ── Extension Permission Model ───────────────
|
||||
|
||||
154
server/sandbox/realtime_module.go
Normal file
154
server/sandbox/realtime_module.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Package sandbox — realtime_module.go
|
||||
//
|
||||
// v0.5.0: Starlark realtime.publish() module.
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
158
server/sandbox/realtime_module_test.go
Normal file
158
server/sandbox/realtime_module_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
"switchboard-core/events"
|
||||
)
|
||||
|
||||
func TestRealtimePublish_HappyPath(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
|
||||
var mu sync.Mutex
|
||||
var received events.Event
|
||||
|
||||
bus.Subscribe("realtime.*", func(e events.Event) {
|
||||
mu.Lock()
|
||||
received = e
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
mod := BuildRealtimeModule(context.Background(), bus, "test-pkg")
|
||||
sb := New(DefaultConfig())
|
||||
|
||||
_, err := sb.Exec(context.Background(), "test.star", `
|
||||
result = realtime.publish("room:123", "chat.message", {"text": "hello", "from": "alice"})
|
||||
`, map[string]starlark.Value{"realtime": mod})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if received.Label != "realtime.chat.message" {
|
||||
t.Errorf("expected label 'realtime.chat.message', got %q", received.Label)
|
||||
}
|
||||
if received.Room != "room:123" {
|
||||
t.Errorf("expected room 'room:123', got %q", received.Room)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(received.Payload, &payload); err != nil {
|
||||
t.Fatalf("payload unmarshal error: %v", err)
|
||||
}
|
||||
if payload["text"] != "hello" {
|
||||
t.Errorf("expected text 'hello', got %v", payload["text"])
|
||||
}
|
||||
if payload["from"] != "alice" {
|
||||
t.Errorf("expected from 'alice', got %v", payload["from"])
|
||||
}
|
||||
if payload["_pkg"] != "test-pkg" {
|
||||
t.Errorf("expected _pkg 'test-pkg', got %v", payload["_pkg"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimePublish_EmptyData(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
var received events.Event
|
||||
bus.Subscribe("realtime.*", func(e events.Event) { received = e })
|
||||
|
||||
mod := BuildRealtimeModule(context.Background(), bus, "my-pkg")
|
||||
sb := New(DefaultConfig())
|
||||
|
||||
_, err := sb.Exec(context.Background(), "test.star", `
|
||||
realtime.publish("ch:1", "heartbeat")
|
||||
`, map[string]starlark.Value{"realtime": mod})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
json.Unmarshal(received.Payload, &payload)
|
||||
if payload["_pkg"] != "my-pkg" {
|
||||
t.Errorf("expected _pkg 'my-pkg', got %v", payload["_pkg"])
|
||||
}
|
||||
// Should only have _pkg
|
||||
if len(payload) != 1 {
|
||||
t.Errorf("expected 1 key (_pkg only), got %d", len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimePublish_MissingArgs(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
mod := BuildRealtimeModule(context.Background(), bus, "pkg")
|
||||
sb := New(DefaultConfig())
|
||||
|
||||
// Missing event
|
||||
_, err := sb.Exec(context.Background(), "test.star", `
|
||||
realtime.publish("ch:1")
|
||||
`, map[string]starlark.Value{"realtime": mod})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing event arg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimePublish_ReservedPrefix(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
mod := BuildRealtimeModule(context.Background(), bus, "pkg")
|
||||
sb := New(DefaultConfig())
|
||||
|
||||
_, err := sb.Exec(context.Background(), "test.star", `
|
||||
realtime.publish("ch:1", "system.notify", {})
|
||||
`, map[string]starlark.Value{"realtime": mod})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for reserved prefix")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reserved prefix") {
|
||||
t.Errorf("error should mention reserved prefix, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimePublish_PayloadTooLarge(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
mod := BuildRealtimeModule(context.Background(), bus, "pkg")
|
||||
sb := New(DefaultConfig())
|
||||
|
||||
// Build a large string via repeated concatenation in Starlark
|
||||
_, err := sb.Exec(context.Background(), "test.star", `
|
||||
big = "x" * 8000
|
||||
realtime.publish("ch:1", "big.event", {"data": big})
|
||||
`, map[string]starlark.Value{"realtime": mod})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for payload too large")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too large") {
|
||||
t.Errorf("error should mention 'too large', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarlarkToGoVal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
val starlark.Value
|
||||
want any
|
||||
}{
|
||||
{"none", starlark.None, nil},
|
||||
{"true", starlark.True, true},
|
||||
{"false", starlark.False, false},
|
||||
{"int", starlark.MakeInt(42), int64(42)},
|
||||
{"float", starlark.Float(3.14), float64(3.14)},
|
||||
{"string", starlark.String("hello"), "hello"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := starlarkToGoVal(tt.val)
|
||||
if got != tt.want {
|
||||
t.Errorf("starlarkToGoVal(%s) = %v (%T), want %v (%T)", tt.val, got, got, tt.want, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
|
||||
starlarkjson "go.starlark.net/lib/json"
|
||||
|
||||
"switchboard-core/events"
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/store"
|
||||
)
|
||||
@@ -62,6 +63,7 @@ type Runner struct {
|
||||
db *sql.DB // nil = db module unavailable
|
||||
dbPostgres bool // true = use $N placeholders; false = use ?
|
||||
allowPrivateIPs bool // v0.38.5: disable SSRF private IP check
|
||||
bus *events.Bus // v0.5.0: event bus for realtime module
|
||||
}
|
||||
|
||||
// NewRunner creates a runner with the given sandbox and dependencies.
|
||||
@@ -97,6 +99,11 @@ func (r *Runner) SetPackagesDir(dir string) {
|
||||
r.packagesDir = dir
|
||||
}
|
||||
|
||||
// SetBus attaches the event bus for the realtime module (v0.5.0).
|
||||
func (r *Runner) SetBus(bus *events.Bus) {
|
||||
r.bus = bus
|
||||
}
|
||||
|
||||
// SetAllowPrivateIPs disables the SSRF check that blocks connections to
|
||||
// private/loopback IPs. For self-hosted environments where extensions
|
||||
// reach internal services. Controlled by EXT_ALLOW_PRIVATE_IPS env var.
|
||||
@@ -327,6 +334,11 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
|
||||
if r.connResolver != nil && rc != nil && rc.UserID != "" {
|
||||
modules["connections"] = BuildConnectionsModule(ctx, r.connResolver, rc.UserID)
|
||||
}
|
||||
|
||||
case models.ExtPermRealtimePublish:
|
||||
if r.bus != nil {
|
||||
modules["realtime"] = BuildRealtimeModule(ctx, r.bus, packageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1035,6 +1035,19 @@ paths:
|
||||
ticket:
|
||||
type: string
|
||||
|
||||
# WebSocket Realtime Protocol (v0.5.0)
|
||||
# ──────────────────────────────────────────────
|
||||
# Room Management (client → server, intercepted before bus):
|
||||
# { "event": "room.subscribe", "payload": { "room": "conversation:abc" } }
|
||||
# { "event": "room.unsubscribe", "payload": { "room": "conversation:abc" } }
|
||||
#
|
||||
# Realtime Events (server → client, room-scoped):
|
||||
# { "event": "realtime.<name>", "room": "<channel>", "payload": { "_pkg": "...", ... }, "ts": 1234567890 }
|
||||
#
|
||||
# Starlark extensions publish via: realtime.publish(channel, event, data)
|
||||
# SDK clients subscribe via: sw.realtime.subscribe(channel, [event], callback)
|
||||
# Max 100 rooms per connection. Payload limit: 7KB.
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Extension Assets
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@@ -255,6 +255,11 @@ export function createDomains(restClient) {
|
||||
consumers: (id) => rc.get(`/api/v1/admin/packages/${id}/consumers`), // v0.38.2
|
||||
registry: () => rc.get('/api/v1/admin/packages/registry'),
|
||||
registryInstall: (url) => rc.post('/api/v1/admin/packages/registry/install', { url }),
|
||||
// v0.5.0: Extension permissions
|
||||
permissions: (id) => rc.get(`/api/v1/admin/extensions/${id}/permissions`),
|
||||
grantPerm: (id, perm) => rc.post(`/api/v1/admin/extensions/${id}/permissions/${perm}/grant`, {}),
|
||||
revokePerm: (id, perm) => rc.post(`/api/v1/admin/extensions/${id}/permissions/${perm}/revoke`, {}),
|
||||
grantAllPerms: (id) => rc.post(`/api/v1/admin/extensions/${id}/permissions/grant-all`, {}),
|
||||
},
|
||||
|
||||
// v0.38.2: Full dependency graph
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createTheme } from './theme.js';
|
||||
import { createStorage } from './storage.js';
|
||||
import { createSlots } from './slots.js';
|
||||
import { createActions } from './actions.js';
|
||||
import { createRealtime } from './realtime.js';
|
||||
import { confirm } from '../primitives/confirm.js';
|
||||
import { prompt } from '../primitives/prompt.js';
|
||||
|
||||
@@ -70,6 +71,7 @@ export async function boot() {
|
||||
const storage = createStorage();
|
||||
const slots = createSlots(events.emit.bind(events));
|
||||
const actions = createActions(events.emit.bind(events));
|
||||
const realtime = createRealtime(events);
|
||||
|
||||
// 7. Assemble sw object
|
||||
const sw = Object.create(null);
|
||||
@@ -102,6 +104,9 @@ export async function boot() {
|
||||
sw.slots = slots;
|
||||
sw.actions = actions;
|
||||
|
||||
// Realtime — room-scoped pub/sub over WebSocket (v0.5.0)
|
||||
sw.realtime = realtime;
|
||||
|
||||
// Shell helpers — imperative confirm/prompt backed by primitives
|
||||
sw.confirm = confirm;
|
||||
sw.prompt = prompt;
|
||||
@@ -162,7 +167,7 @@ export async function boot() {
|
||||
};
|
||||
|
||||
// Marker for idempotency
|
||||
sw._sdk = '0.2.3';
|
||||
sw._sdk = '0.5.0';
|
||||
|
||||
// 8. Expose globally
|
||||
window.sw = sw;
|
||||
|
||||
132
src/js/sw/sdk/realtime.js
Normal file
132
src/js/sw/sdk/realtime.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// ==========================================
|
||||
// Switchboard Core — SDK: Realtime
|
||||
// ==========================================
|
||||
// Room-scoped publish/subscribe over the WebSocket bridge.
|
||||
//
|
||||
// Extensions publish events from Starlark via realtime.publish().
|
||||
// Clients subscribe to channels here — the SDK manages room
|
||||
// join/leave over WS and dispatches incoming realtime.* events.
|
||||
//
|
||||
// Factory: createRealtime(events)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Create the realtime module.
|
||||
*
|
||||
* @param {object} events — SDK event bus (from createEvents)
|
||||
* @returns {object} realtime
|
||||
*/
|
||||
export function createRealtime(events) {
|
||||
|
||||
// channel → Set<{event, fn}>
|
||||
const _channels = new Map();
|
||||
|
||||
// ── Room management ─────────────────────────
|
||||
|
||||
function _joinRoom(channel) {
|
||||
events.emit('room.subscribe', { room: channel });
|
||||
}
|
||||
|
||||
function _leaveRoom(channel) {
|
||||
events.emit('room.unsubscribe', { room: channel });
|
||||
}
|
||||
|
||||
// ── Incoming realtime events ────────────────
|
||||
|
||||
// Single wildcard listener for all realtime.* events from server
|
||||
events.on('realtime.*', (payload, meta) => {
|
||||
if (!meta?.room) return;
|
||||
|
||||
const subs = _channels.get(meta.room);
|
||||
if (!subs || subs.size === 0) return;
|
||||
|
||||
// Extract event name: "realtime.foo.bar" → "foo.bar"
|
||||
const eventName = meta.event?.replace(/^realtime\./, '') || '';
|
||||
|
||||
for (const entry of subs) {
|
||||
// Match: no event filter (catch-all) or exact event match
|
||||
if (!entry.event || entry.event === eventName) {
|
||||
try {
|
||||
entry.fn(payload, { ...meta, channel: meta.room, event: eventName });
|
||||
} catch (e) {
|
||||
console.error(`[sw.realtime] Handler error on ${meta.room}/${eventName}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Reconnect: re-join all active rooms ─────
|
||||
|
||||
events.on('ws.connected', () => {
|
||||
for (const channel of _channels.keys()) {
|
||||
if (_channels.get(channel).size > 0) {
|
||||
_joinRoom(channel);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Public API ──────────────────────────────
|
||||
|
||||
const realtime = {
|
||||
/**
|
||||
* Subscribe to a realtime channel.
|
||||
*
|
||||
* @param {string} channel — room/channel name
|
||||
* @param {string|Function} eventOrCallback — event name filter or callback
|
||||
* @param {Function} [callback] — callback if event name provided
|
||||
* @returns {Function} unsubscribe
|
||||
*/
|
||||
subscribe(channel, eventOrCallback, callback) {
|
||||
if (!channel) throw new Error('sw.realtime.subscribe: channel is required');
|
||||
|
||||
let event = null;
|
||||
let fn;
|
||||
|
||||
if (typeof eventOrCallback === 'function') {
|
||||
fn = eventOrCallback;
|
||||
} else if (typeof eventOrCallback === 'string' && typeof callback === 'function') {
|
||||
event = eventOrCallback;
|
||||
fn = callback;
|
||||
} else {
|
||||
throw new Error('sw.realtime.subscribe: invalid arguments');
|
||||
}
|
||||
|
||||
const entry = { event, fn };
|
||||
|
||||
// First subscriber for this channel — join room
|
||||
if (!_channels.has(channel)) {
|
||||
_channels.set(channel, new Set());
|
||||
_joinRoom(channel);
|
||||
}
|
||||
|
||||
_channels.get(channel).add(entry);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const subs = _channels.get(channel);
|
||||
if (!subs) return;
|
||||
subs.delete(entry);
|
||||
|
||||
// Last subscriber — leave room
|
||||
if (subs.size === 0) {
|
||||
_channels.delete(channel);
|
||||
_leaveRoom(channel);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Debug helper: active channels and subscriber counts.
|
||||
* @returns {object} { channelName: count, ... }
|
||||
*/
|
||||
channels() {
|
||||
const result = {};
|
||||
for (const [channel, subs] of _channels) {
|
||||
result[channel] = subs.size;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
return realtime;
|
||||
}
|
||||
@@ -29,6 +29,8 @@ function sourceBadge(source) {
|
||||
|
||||
function statusBadge(status) {
|
||||
if (status === 'dormant') return html`<span class="badge" style="opacity:0.6;">dormant</span>`;
|
||||
if (status === 'pending_review') return html`<span class="badge" style="background:var(--warning,#e6a817);color:#000;">review</span>`;
|
||||
if (status === 'suspended') return html`<span class="badge" style="background:var(--danger,#d33);color:#fff;">suspended</span>`;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -43,6 +45,8 @@ export default function PackagesSection() {
|
||||
const [registryPkgs, setRegistryPkgs] = useState([]);
|
||||
const [registryLoading, setRegistryLoading] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [permsId, setPermsId] = useState(null); // v0.5.0: permissions drawer
|
||||
const [perms, setPerms] = useState([]);
|
||||
|
||||
const BASE = window.__BASE__ || '';
|
||||
|
||||
@@ -136,6 +140,46 @@ export default function PackagesSection() {
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Permissions drawer (v0.5.0) ────────────
|
||||
async function togglePerms(pkgId) {
|
||||
if (permsId === pkgId) { setPermsId(null); setPerms([]); return; }
|
||||
try {
|
||||
const data = await sw.api.admin.packages.permissions(pkgId);
|
||||
setPerms((data && data.data) || data || []);
|
||||
setPermsId(pkgId);
|
||||
// Close settings if open
|
||||
if (expandedId === pkgId) { setExpandedId(null); setPkgSettings(null); }
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function grantPerm(pkgId, perm) {
|
||||
try {
|
||||
await sw.api.admin.packages.grantPerm(pkgId, perm);
|
||||
const data = await sw.api.admin.packages.permissions(pkgId);
|
||||
setPerms((data && data.data) || data || []);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function revokePerm(pkgId, perm) {
|
||||
try {
|
||||
await sw.api.admin.packages.revokePerm(pkgId, perm);
|
||||
const data = await sw.api.admin.packages.permissions(pkgId);
|
||||
setPerms((data && data.data) || data || []);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function grantAllPerms(pkgId) {
|
||||
try {
|
||||
await sw.api.admin.packages.grantAllPerms(pkgId);
|
||||
const data = await sw.api.admin.packages.permissions(pkgId);
|
||||
setPerms((data && data.data) || data || []);
|
||||
sw.toast('All permissions granted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Registry ────────────────────────────────
|
||||
async function toggleRegistry() {
|
||||
if (registryOpen) { setRegistryOpen(false); return; }
|
||||
@@ -166,6 +210,8 @@ export default function PackagesSection() {
|
||||
const dormant = packages.filter(p => p.status === 'dormant').length;
|
||||
const hasManifestSettings = (pkg) =>
|
||||
pkg.manifest?.settings || pkg.package_settings;
|
||||
const hasDeclaredPerms = (pkg) =>
|
||||
pkg.manifest?.permissions && pkg.manifest.permissions.length > 0;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
@@ -211,8 +257,8 @@ export default function PackagesSection() {
|
||||
<div class="admin-actions-cell">
|
||||
<button class="btn-small"
|
||||
onClick=${() => toggleEnabled(pkg)}
|
||||
disabled=${CORE_IDS.has(pkg.id) || pkg.status === 'dormant'}
|
||||
title=${pkg.status === 'dormant' ? 'Requires chat' : ''}>
|
||||
disabled=${CORE_IDS.has(pkg.id) || pkg.status === 'dormant' || pkg.status === 'pending_review'}
|
||||
title=${pkg.status === 'dormant' ? 'Requires chat' : pkg.status === 'pending_review' ? 'Grant permissions to activate' : ''}>
|
||||
${pkg.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
${hasManifestSettings(pkg) && html`
|
||||
@@ -220,6 +266,11 @@ export default function PackagesSection() {
|
||||
${expandedId === pkg.id ? 'Close' : 'Settings'}
|
||||
</button>
|
||||
`}
|
||||
${hasDeclaredPerms(pkg) && html`
|
||||
<button class="btn-small" onClick=${() => togglePerms(pkg.id)}>
|
||||
${permsId === pkg.id ? 'Close' : 'Permissions'}
|
||||
</button>
|
||||
`}
|
||||
<button class="btn-small" onClick=${() => exportPkg(pkg.id)}>Export</button>
|
||||
${pkg.source !== 'core' && !pkg.is_system && html`
|
||||
<button class="btn-small btn-danger" onClick=${() => deletePkg(pkg)}>Delete</button>
|
||||
@@ -247,6 +298,35 @@ export default function PackagesSection() {
|
||||
}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${/* ── Inline permissions drawer (v0.5.0) ── */``}
|
||||
${permsId === pkg.id && html`
|
||||
<div style="padding:8px 12px 12px 24px;background:var(--bg-2);border-bottom:1px solid var(--border);">
|
||||
${perms.length === 0
|
||||
? html`<span class="text-muted" style="font-size:12px;">No permissions declared</span>`
|
||||
: html`
|
||||
<div style="margin-bottom:8px;">
|
||||
${perms.map(p => html`
|
||||
<div key=${p.permission} style="display:flex;gap:8px;align-items:center;margin-bottom:4px;">
|
||||
<code style="min-width:140px;font-size:12px;">${p.permission}</code>
|
||||
<button class="btn-small ${p.granted ? 'btn-danger' : 'btn-primary'}"
|
||||
style="min-width:64px;font-size:11px;"
|
||||
onClick=${() => p.granted ? revokePerm(pkg.id, p.permission) : grantPerm(pkg.id, p.permission)}>
|
||||
${p.granted ? 'Revoke' : 'Grant'}
|
||||
</button>
|
||||
${p.granted && p.granted_by && html`
|
||||
<span class="text-muted" style="font-size:11px;">by ${p.granted_by}</span>
|
||||
`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
${perms.some(p => !p.granted) && html`
|
||||
<button class="btn-small btn-primary" onClick=${() => grantAllPerms(pkg.id)}>Grant All</button>
|
||||
`}
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user