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.
|
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
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
17
ROADMAP.md
17
ROADMAP.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ───────────────
|
||||||
|
|||||||
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"
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
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) {
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user