From 0af1c51ae9c12a8808376afd73ef07fdca06c8fc Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Mon, 30 Mar 2026 10:40:24 +0000 Subject: [PATCH] Feat v0.5.0 realtime primitive + dialog audit + permissions UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add realtime pub/sub: Starlark realtime.publish() module gated by new realtime.publish permission, WS room protocol (room.subscribe/ room.unsubscribe intercepted in readPump with 100-room cap), and SDK sw.realtime.subscribe() with auto room join/leave and reconnect recovery. Migrate 5 bare confirm() calls to sw.confirm() with destructive styling in tasks, schedules, notes (×2), and editor packages. Add admin permissions UI: per-permission grant/revoke drawer, Grant All bulk action, pending_review/suspended status badges, disabled enable toggle when pending. Closes v0.4.7 permissions UI gap. 8 new Go tests, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 38 ++++++ README.md | 10 +- ROADMAP.md | 17 +-- VERSION | 2 +- packages/editor/js/main.js | 4 +- packages/notes/js/main.js | 4 +- packages/schedules/js/main.js | 2 +- packages/tasks/js/main.js | 2 +- server/events/bus_test.go | 27 +++++ server/events/types.go | 7 ++ server/events/ws.go | 20 ++++ server/main.go | 2 + server/models/models_extension_perm.go | 2 + server/sandbox/realtime_module.go | 154 ++++++++++++++++++++++++ server/sandbox/realtime_module_test.go | 158 +++++++++++++++++++++++++ server/sandbox/runner.go | 12 ++ server/static/openapi.yaml | 13 ++ src/js/sw/sdk/api-domains.js | 5 + src/js/sw/sdk/index.js | 7 +- src/js/sw/sdk/realtime.js | 132 +++++++++++++++++++++ src/js/sw/surfaces/admin/packages.js | 84 ++++++++++++- 21 files changed, 680 insertions(+), 22 deletions(-) create mode 100644 server/sandbox/realtime_module.go create mode 100644 server/sandbox/realtime_module_test.go create mode 100644 src/js/sw/sdk/realtime.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 344e118..5d2975f 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index 89feba3..4da38f5 100644 --- a/README.md +++ b/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 diff --git a/ROADMAP.md b/ROADMAP.md index 684d8e2..6edd892 100644 --- a/ROADMAP.md +++ b/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) diff --git a/VERSION b/VERSION index f905682..8f0916f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.7 +0.5.0 diff --git a/packages/editor/js/main.js b/packages/editor/js/main.js index d2b75c9..4e017e8 100644 --- a/packages/editor/js/main.js +++ b/packages/editor/js/main.js @@ -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 { diff --git a/packages/notes/js/main.js b/packages/notes/js/main.js index c797ec6..c35486e 100644 --- a/packages/notes/js/main.js +++ b/packages/notes/js/main.js @@ -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); diff --git a/packages/schedules/js/main.js b/packages/schedules/js/main.js index 9d033f1..02e5374 100644 --- a/packages/schedules/js/main.js +++ b/packages/schedules/js/main.js @@ -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'); diff --git a/packages/tasks/js/main.js b/packages/tasks/js/main.js index 4090ac5..b834ad8 100644 --- a/packages/tasks/js/main.js +++ b/packages/tasks/js/main.js @@ -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(); } diff --git a/server/events/bus_test.go b/server/events/bus_test.go index aa0ee29..1441e59 100644 --- a/server/events/bus_test.go +++ b/server/events/bus_test.go @@ -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") diff --git a/server/events/types.go b/server/events/types.go index ecdaf8e..5ace1d5 100644 --- a/server/events/types.go +++ b/server/events/types.go @@ -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, diff --git a/server/events/ws.go b/server/events/ws.go index 4a49375..64f1f7f 100644 --- a/server/events/ws.go +++ b/server/events/ws.go @@ -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 diff --git a/server/main.go b/server/main.go index b2e6178..3d678f5 100644 --- a/server/main.go +++ b/server/main.go @@ -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) diff --git a/server/models/models_extension_perm.go b/server/models/models_extension_perm.go index 036d88a..8f77789 100644 --- a/server/models/models_extension_perm.go +++ b/server/models/models_extension_perm.go @@ -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 ─────────────── diff --git a/server/sandbox/realtime_module.go b/server/sandbox/realtime_module.go new file mode 100644 index 0000000..065b52f --- /dev/null +++ b/server/sandbox/realtime_module.go @@ -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() + } +} diff --git a/server/sandbox/realtime_module_test.go b/server/sandbox/realtime_module_test.go new file mode 100644 index 0000000..c770f4f --- /dev/null +++ b/server/sandbox/realtime_module_test.go @@ -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) + } + }) + } +} diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go index d7ad0c8..8c26017 100644 --- a/server/sandbox/runner.go +++ b/server/sandbox/runner.go @@ -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) + } } } diff --git a/server/static/openapi.yaml b/server/static/openapi.yaml index 45d14fb..e4181d2 100644 --- a/server/static/openapi.yaml +++ b/server/static/openapi.yaml @@ -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.", "room": "", "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 # ────────────────────────────────────────────── diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js index 1bc6118..f13849c 100644 --- a/src/js/sw/sdk/api-domains.js +++ b/src/js/sw/sdk/api-domains.js @@ -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 diff --git a/src/js/sw/sdk/index.js b/src/js/sw/sdk/index.js index eddfb07..a3d46f4 100644 --- a/src/js/sw/sdk/index.js +++ b/src/js/sw/sdk/index.js @@ -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; diff --git a/src/js/sw/sdk/realtime.js b/src/js/sw/sdk/realtime.js new file mode 100644 index 0000000..1024d75 --- /dev/null +++ b/src/js/sw/sdk/realtime.js @@ -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; +} diff --git a/src/js/sw/surfaces/admin/packages.js b/src/js/sw/surfaces/admin/packages.js index 6e82752..70c595f 100644 --- a/src/js/sw/surfaces/admin/packages.js +++ b/src/js/sw/surfaces/admin/packages.js @@ -29,6 +29,8 @@ function sourceBadge(source) { function statusBadge(status) { if (status === 'dormant') return html`dormant`; + if (status === 'pending_review') return html`review`; + if (status === 'suspended') return html`suspended`; 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`
@@ -211,8 +257,8 @@ export default function PackagesSection() {
${hasManifestSettings(pkg) && html` @@ -220,6 +266,11 @@ export default function PackagesSection() { ${expandedId === pkg.id ? 'Close' : 'Settings'} `} + ${hasDeclaredPerms(pkg) && html` + + `} ${pkg.source !== 'core' && !pkg.is_system && html` @@ -247,6 +298,35 @@ export default function PackagesSection() { }
`} + + ${/* ── Inline permissions drawer (v0.5.0) ── */``} + ${permsId === pkg.id && html` +
+ ${perms.length === 0 + ? html`No permissions declared` + : html` +
+ ${perms.map(p => html` +
+ ${p.permission} + + ${p.granted && p.granted_by && html` + by ${p.granted_by} + `} +
+ `)} +
+ ${perms.some(p => !p.granted) && html` + + `} + ` + } +
+ `}
`)} -- 2.49.1