Feat v0.5.0 realtime dialog (#30)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m36s
CI/CD / test-sqlite (push) Successful in 2m43s
CI/CD / build-and-deploy (push) Successful in 1m19s

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:
2026-03-30 11:07:27 +00:00
committed by xcaliber
parent eb9a2d7d27
commit 2abf406db8
21 changed files with 680 additions and 22 deletions

View File

@@ -2,6 +2,44 @@
All notable changes to Switchboard Core are documented here. 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 ## v0.4.7 — Note Graph
### Added ### Added

View File

@@ -59,10 +59,12 @@ deployment and customization.
## Project Status ## Project Status
**v0.3.8**Distribution. Bundled packages auto-install on first boot, **v0.5.0**Realtime pub/sub primitive, dialog audit, and admin permissions
builder image for faster custom builds, per-environment package allowlists. UI. Extensions can now publish events to WebSocket channels via Starlark;
See [ROADMAP.md](ROADMAP.md) for the full journey from v0.1.0 kernel clients subscribe with `sw.realtime.subscribe()`. Admin Packages page gains
extraction through v0.3.x workflows to the upcoming v0.4.0 Notes surface. 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 ## License

View File

@@ -1,6 +1,6 @@
# Switchboard Core — Roadmap # 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 Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat
features removed from the kernel. What remains is the minimum viable 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 pure extension code. Human-to-human chat ships pre-MVP; LLM
participation is post-MVP. participation is post-MVP.
### v0.5.0 — Realtime Primitive + Dialog System (planned) ### v0.5.0 — Realtime Primitive + Dialog Audit + Permissions UI
| Step | Status | Description | | 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. | | `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. |
| `sw.realtime.subscribe()` | | SDK method: `sw.realtime.subscribe(channel, callback)`. Client-side WebSocket listener filtered by channel. Returns unsubscribe handle. | | WS room protocol | ✅ | `room.subscribe` / `room.unsubscribe` client messages intercepted in readPump. Per-connection 100-room cap. |
| `sw.ui.Dialog` | | SDK modal primitive: configurable title, body, action buttons. Replaces native `alert()`. Promise-based API. | | `sw.realtime.subscribe()` | | SDK method: `sw.realtime.subscribe(channel, [event], callback)`. Auto room join/leave, reconnect recovery. Returns unsubscribe handle. |
| `sw.ui.Confirm` | | SDK confirm primitive: question + confirm/cancel buttons. Replaces native `confirm()`. Returns promise resolving to boolean. | | `sw.ui.Dialog` | ✅ | Pre-existing (implemented before v0.5.0). SDK modal primitive with focus trap, Promise-based API. |
| `sw.ui.Prompt` | | SDK prompt primitive: label + text input + confirm/cancel. Replaces native `prompt()`. Returns promise resolving to string or null. | | `sw.ui.Confirm` / `sw.ui.Prompt` | ✅ | Pre-existing. `sw.confirm()` and `sw.prompt()` already exposed in SDK. |
| Native dialog audit | | Sweep all surfaces (notes, tasks, schedules, admin, settings). Replace every `prompt()`, `confirm()`, `alert()` with SDK equivalents. | | 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) ### v0.5.1 — Chat Core Library (planned)

View File

@@ -1 +1 @@
0.4.7 0.5.0

View File

@@ -372,8 +372,8 @@
// ── File Operations ───────────────────────── // ── File Operations ─────────────────────────
async function _deleteFile(wsId, path, fileTree, codeEditor) { async function _deleteFile(wsId, path, fileTree, codeEditor) {
const ok = typeof showConfirm === 'function' const ok = typeof sw !== 'undefined' && sw.confirm
? await showConfirm('Delete ' + path + '?') ? await sw.confirm('Delete ' + path + '?', { destructive: true })
: window.confirm('Delete ' + path + '?'); : window.confirm('Delete ' + path + '?');
if (!ok) return; if (!ok) return;
try { try {

View File

@@ -811,7 +811,7 @@
} }
async function handleArchive() { 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); await api.del('/notes/' + note.id);
onDelete(); onDelete();
} }
@@ -1717,7 +1717,7 @@
} catch (e) { console.error('Rename folder failed:', e); } } catch (e) { console.error('Rename folder failed:', e); }
} }
async function handleDeleteFolder(folderId) { 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 { try {
await api.del('/folders/' + folderId); await api.del('/folders/' + folderId);
if (activeFolderId === folderId) setActiveFolderId(null); if (activeFolderId === folderId) setActiveFolderId(null);

View File

@@ -275,7 +275,7 @@
} }
async function handleDelete(item) { async function handleDelete(item) {
if (!confirm('Delete schedule "' + item.name + '"?')) return; if (!await sw.confirm('Delete schedule "' + item.name + '"?', { destructive: true })) return;
try { try {
await apiDelete(item.id); await apiDelete(item.id);
sw.toast('Schedule deleted', 'success'); sw.toast('Schedule deleted', 'success');

View File

@@ -144,7 +144,7 @@
} }
async function handleDelete(id) { 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); await api.del('/items/' + id);
onRefresh(); onRefresh();
} }

View File

@@ -117,6 +117,12 @@ func TestRouteFor(t *testing.T) {
// Extension lifecycle // Extension lifecycle
{"extension.loaded", DirLocal}, {"extension.loaded", DirLocal},
{"extension.error", 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 { for _, tt := range tests {
@@ -190,6 +196,27 @@ func TestWaitFor_CleansUpSubscription(t *testing.T) {
bus.Publish(Event{Label: "tool.result.cleanup", Payload: json.RawMessage(`{}`)}) 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) { func TestToolCallRouteToClient(t *testing.T) {
if !ShouldSendToClient("tool.call.abc123") { if !ShouldSendToClient("tool.call.abc123") {
t.Error("tool.call.* should be sent to client") t.Error("tool.call.* should be sent to client")

View File

@@ -87,6 +87,13 @@ var routeTable = map[string]Direction{
"trigger.fired": DirLocal, // Trigger invocation event (observability) "trigger.fired": DirLocal, // Trigger invocation event (observability)
"trigger.error": DirLocal, // Trigger execution error "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 // Heartbeat
"ping": DirFromClient, "ping": DirFromClient,
"pong": DirToClient, "pong": DirToClient,

View File

@@ -316,6 +316,26 @@ func (c *Conn) readPump() {
continue 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 // Validate: only accept events allowed from clients
if !ShouldAcceptFromClient(event.Label) { if !ShouldAcceptFromClient(event.Label) {
continue continue

View File

@@ -168,6 +168,8 @@ func main() {
if cfg.StoragePath != "" { if cfg.StoragePath != "" {
starlarkRunner.SetPackagesDir(cfg.StoragePath + "/packages") 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.) // v0.38.5: allow extensions to reach private IPs (self-hosted Gitea, etc.)
if os.Getenv("EXT_ALLOW_PRIVATE_IPS") == "true" { if os.Getenv("EXT_ALLOW_PRIVATE_IPS") == "true" {
starlarkRunner.SetAllowPrivateIPs(true) starlarkRunner.SetAllowPrivateIPs(true)

View File

@@ -35,6 +35,7 @@ const (
ExtPermWorkflowAccess = "workflow.access" // v0.30.2: workflow definition + stage data access ExtPermWorkflowAccess = "workflow.access" // v0.30.2: workflow definition + stage data access
ExtPermConnectionsRead = "connections.read" // v0.38.1: extension connection resolution ExtPermConnectionsRead = "connections.read" // v0.38.1: extension connection resolution
ExtPermTriggersRegister = "triggers.register" // v0.2.2: event/webhook trigger registration 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. // ValidExtensionPermissions is the set of recognized permission keys.
@@ -48,6 +49,7 @@ var ValidExtensionPermissions = map[string]bool{
ExtPermWorkflowAccess: true, ExtPermWorkflowAccess: true,
ExtPermConnectionsRead: true, ExtPermConnectionsRead: true,
ExtPermTriggersRegister: true, ExtPermTriggersRegister: true,
ExtPermRealtimePublish: true,
} }
// ── Extension Permission Model ─────────────── // ── Extension Permission Model ───────────────

View 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()
}
}

View 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)
}
})
}
}

View File

@@ -33,6 +33,7 @@ import (
starlarkjson "go.starlark.net/lib/json" starlarkjson "go.starlark.net/lib/json"
"switchboard-core/events"
"switchboard-core/models" "switchboard-core/models"
"switchboard-core/store" "switchboard-core/store"
) )
@@ -62,6 +63,7 @@ type Runner struct {
db *sql.DB // nil = db module unavailable db *sql.DB // nil = db module unavailable
dbPostgres bool // true = use $N placeholders; false = use ? dbPostgres bool // true = use $N placeholders; false = use ?
allowPrivateIPs bool // v0.38.5: disable SSRF private IP check 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. // NewRunner creates a runner with the given sandbox and dependencies.
@@ -97,6 +99,11 @@ func (r *Runner) SetPackagesDir(dir string) {
r.packagesDir = dir 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 // SetAllowPrivateIPs disables the SSRF check that blocks connections to
// private/loopback IPs. For self-hosted environments where extensions // private/loopback IPs. For self-hosted environments where extensions
// reach internal services. Controlled by EXT_ALLOW_PRIVATE_IPS env var. // 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 != "" { if r.connResolver != nil && rc != nil && rc.UserID != "" {
modules["connections"] = BuildConnectionsModule(ctx, r.connResolver, rc.UserID) modules["connections"] = BuildConnectionsModule(ctx, r.connResolver, rc.UserID)
} }
case models.ExtPermRealtimePublish:
if r.bus != nil {
modules["realtime"] = BuildRealtimeModule(ctx, r.bus, packageID)
}
} }
} }

View File

@@ -1035,6 +1035,19 @@ paths:
ticket: ticket:
type: string 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 # Extension Assets
# ────────────────────────────────────────────── # ──────────────────────────────────────────────

View File

@@ -255,6 +255,11 @@ export function createDomains(restClient) {
consumers: (id) => rc.get(`/api/v1/admin/packages/${id}/consumers`), // v0.38.2 consumers: (id) => rc.get(`/api/v1/admin/packages/${id}/consumers`), // v0.38.2
registry: () => rc.get('/api/v1/admin/packages/registry'), registry: () => rc.get('/api/v1/admin/packages/registry'),
registryInstall: (url) => rc.post('/api/v1/admin/packages/registry/install', { url }), 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 // v0.38.2: Full dependency graph

View File

@@ -21,6 +21,7 @@ import { createTheme } from './theme.js';
import { createStorage } from './storage.js'; import { createStorage } from './storage.js';
import { createSlots } from './slots.js'; import { createSlots } from './slots.js';
import { createActions } from './actions.js'; import { createActions } from './actions.js';
import { createRealtime } from './realtime.js';
import { confirm } from '../primitives/confirm.js'; import { confirm } from '../primitives/confirm.js';
import { prompt } from '../primitives/prompt.js'; import { prompt } from '../primitives/prompt.js';
@@ -70,6 +71,7 @@ export async function boot() {
const storage = createStorage(); const storage = createStorage();
const slots = createSlots(events.emit.bind(events)); const slots = createSlots(events.emit.bind(events));
const actions = createActions(events.emit.bind(events)); const actions = createActions(events.emit.bind(events));
const realtime = createRealtime(events);
// 7. Assemble sw object // 7. Assemble sw object
const sw = Object.create(null); const sw = Object.create(null);
@@ -102,6 +104,9 @@ export async function boot() {
sw.slots = slots; sw.slots = slots;
sw.actions = actions; sw.actions = actions;
// Realtime — room-scoped pub/sub over WebSocket (v0.5.0)
sw.realtime = realtime;
// Shell helpers — imperative confirm/prompt backed by primitives // Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm; sw.confirm = confirm;
sw.prompt = prompt; sw.prompt = prompt;
@@ -162,7 +167,7 @@ export async function boot() {
}; };
// Marker for idempotency // Marker for idempotency
sw._sdk = '0.2.3'; sw._sdk = '0.5.0';
// 8. Expose globally // 8. Expose globally
window.sw = sw; window.sw = sw;

132
src/js/sw/sdk/realtime.js Normal file
View 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;
}

View File

@@ -29,6 +29,8 @@ function sourceBadge(source) {
function statusBadge(status) { function statusBadge(status) {
if (status === 'dormant') return html`<span class="badge" style="opacity:0.6;">dormant</span>`; 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; return null;
} }
@@ -43,6 +45,8 @@ export default function PackagesSection() {
const [registryPkgs, setRegistryPkgs] = useState([]); const [registryPkgs, setRegistryPkgs] = useState([]);
const [registryLoading, setRegistryLoading] = useState(false); const [registryLoading, setRegistryLoading] = useState(false);
const [installing, setInstalling] = 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__ || ''; const BASE = window.__BASE__ || '';
@@ -136,6 +140,46 @@ export default function PackagesSection() {
} catch (e) { sw.toast(e.message, 'error'); } } 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 ──────────────────────────────── // ── Registry ────────────────────────────────
async function toggleRegistry() { async function toggleRegistry() {
if (registryOpen) { setRegistryOpen(false); return; } if (registryOpen) { setRegistryOpen(false); return; }
@@ -166,6 +210,8 @@ export default function PackagesSection() {
const dormant = packages.filter(p => p.status === 'dormant').length; const dormant = packages.filter(p => p.status === 'dormant').length;
const hasManifestSettings = (pkg) => const hasManifestSettings = (pkg) =>
pkg.manifest?.settings || pkg.package_settings; pkg.manifest?.settings || pkg.package_settings;
const hasDeclaredPerms = (pkg) =>
pkg.manifest?.permissions && pkg.manifest.permissions.length > 0;
return html` return html`
<div> <div>
@@ -211,8 +257,8 @@ export default function PackagesSection() {
<div class="admin-actions-cell"> <div class="admin-actions-cell">
<button class="btn-small" <button class="btn-small"
onClick=${() => toggleEnabled(pkg)} onClick=${() => toggleEnabled(pkg)}
disabled=${CORE_IDS.has(pkg.id) || pkg.status === 'dormant'} disabled=${CORE_IDS.has(pkg.id) || pkg.status === 'dormant' || pkg.status === 'pending_review'}
title=${pkg.status === 'dormant' ? 'Requires chat' : ''}> title=${pkg.status === 'dormant' ? 'Requires chat' : pkg.status === 'pending_review' ? 'Grant permissions to activate' : ''}>
${pkg.enabled ? 'Disable' : 'Enable'} ${pkg.enabled ? 'Disable' : 'Enable'}
</button> </button>
${hasManifestSettings(pkg) && html` ${hasManifestSettings(pkg) && html`
@@ -220,6 +266,11 @@ export default function PackagesSection() {
${expandedId === pkg.id ? 'Close' : 'Settings'} ${expandedId === pkg.id ? 'Close' : 'Settings'}
</button> </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> <button class="btn-small" onClick=${() => exportPkg(pkg.id)}>Export</button>
${pkg.source !== 'core' && !pkg.is_system && html` ${pkg.source !== 'core' && !pkg.is_system && html`
<button class="btn-small btn-danger" onClick=${() => deletePkg(pkg)}>Delete</button> <button class="btn-small btn-danger" onClick=${() => deletePkg(pkg)}>Delete</button>
@@ -247,6 +298,35 @@ export default function PackagesSection() {
} }
</div> </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>
`)} `)}
</div> </div>