// Package sandbox — modules.go // // v0.29.0 CS3: Module factories for Starlark sandbox. // Each factory returns a starlarkstruct.Module that the runner // injects into the script namespace based on granted permissions. package sandbox import ( "context" "fmt" "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" "switchboard-core/models" "switchboard-core/store" ) // ─── Secrets Module ────────────────────────── // // Requires permission: secrets.read // Starlark API: // val = secrets.get("api_key") → string or None // all = secrets.list() → list of key names // // Secrets are stored in GlobalConfig under key "ext_secrets:{packageID}" // as a JSON map: {"api_key": "sk-...", "webhook_token": "tok-..."} // Admin sets them via PUT /admin/extensions/:id/secrets. // BuildSecretsModule creates the "secrets" module scoped to a package. func BuildSecretsModule(ctx context.Context, stores store.Stores, packageID string) *starlarkstruct.Module { // Pre-load secrets for this package (single query, not per-call) secretMap := loadExtensionSecrets(ctx, stores, packageID) return MakeModule("secrets", starlark.StringDict{ "get": starlark.NewBuiltin("secrets.get", func( thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple, ) (starlark.Value, error) { var key string if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &key); err != nil { return nil, err } if val, ok := secretMap[key]; ok { return starlark.String(val), nil } return starlark.None, nil }), "list": starlark.NewBuiltin("secrets.list", func( thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple, ) (starlark.Value, error) { keys := make([]starlark.Value, 0, len(secretMap)) for k := range secretMap { keys = append(keys, starlark.String(k)) } return starlark.NewList(keys), nil }), }) } func loadExtensionSecrets(ctx context.Context, stores store.Stores, packageID string) map[string]string { result := make(map[string]string) configKey := "ext_secrets:" + packageID configVal, err := stores.GlobalConfig.Get(ctx, configKey) if err != nil || configVal == nil { return result } for k, v := range configVal { if s, ok := v.(string); ok { result[k] = s } } return result } // ─── Notifications Module ──────────────────── // // Requires permission: notifications.send // Starlark API: // notifications.send(user_id, title, body="", type="extension.notify") // // Creates an in-app notification via the notification store. // The extension cannot send email or bypass user preferences — // that's handled by the notification service layer. // NotificationSender is the interface the notifications module needs. // Matches notifications.Service.Notify without importing the package. type NotificationSender interface { Notify(ctx context.Context, n *models.Notification) error } // BuildNotificationsModule creates the "notifications" module scoped to a package. func BuildNotificationsModule(ctx context.Context, sender NotificationSender, packageID string) *starlarkstruct.Module { return MakeModule("notifications", starlark.StringDict{ "send": starlark.NewBuiltin("notifications.send", func( thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple, ) (starlark.Value, error) { var userID, title string var body string var notifType string = "extension.notify" if err := starlark.UnpackArgs(b.Name(), args, kwargs, "user_id", &userID, "title", &title, "body?", &body, "type?", ¬ifType, ); err != nil { return nil, err } if userID == "" { return nil, fmt.Errorf("notifications.send: user_id is required") } if title == "" { return nil, fmt.Errorf("notifications.send: title is required") } n := &models.Notification{ UserID: userID, Type: notifType, Title: title, Body: body, ResourceType: "extension", ResourceID: packageID, } if err := sender.Notify(ctx, n); err != nil { return nil, fmt.Errorf("notifications.send failed: %w", err) } return starlark.True, nil }), }) }