Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/PLUGIN_AUTHOR_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,10 @@ owncast.actions.clear();

The host validates each entry with the same rules as `manifest.actions` (title required, exactly one of `url` / `html`, relative URLs and icons auto-prefixed, cross-plugin URLs/icons rejected) and persists the result in the plugin's config so the additions survive a reload. The next viewer `/api/config` request returns `manifest.actions` ++ the runtime list. Requires `ui.modify`.

`owncast.actions.add` and `owncast.actions.clear` throw when the host rejects the
operation. Action errors identify the invalid entry and rule. The entire batch is
rejected, so no entries are added when any entry is invalid.

A common pattern is an admin page that lets the streamer add a custom button (label + URL) on top of the plugin's defaults. The `action-buttons` example in the SDK ships a working version.

## Viewer-page injection
Expand Down
40 changes: 22 additions & 18 deletions docs/WIRE_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,17 +108,18 @@ no custom `I32` host imports.

### `chat.moderate`

- `owncast_delete_message(idPtr: PTR): void`. Input: `idPtr` is a UTF-8 message
ID. Output: none.
- `owncast_kick_client(clientId: I64): void`. Input: `clientId` is the scalar
chat client ID. Output: none.
- `owncast_delete_message(idPtr: PTR): PTR`. Input: `idPtr` is a UTF-8 message
ID. Output: JSON `{"error": string}` on failure or `{}` on success.
- `owncast_kick_client(clientId: I64): PTR`. Input: `clientId` is the scalar
chat client ID. Output: JSON `{"error": string}` on failure or `{}` on success.

### `storage.kv`

- `owncast_kv_get(keyPtr: PTR): PTR`. Input: `keyPtr` is a UTF-8 key. Output:
a UTF-8 string, or 0 when the key is missing.
- `owncast_kv_set(keyPtr: PTR, valPtr: PTR): void`. Inputs: both pointers
contain UTF-8 strings. Output: none.
- `owncast_kv_set(keyPtr: PTR, valPtr: PTR): PTR`. Inputs: both pointers
contain UTF-8 strings. Output: JSON `{"error": string}` on failure or `{}`
on success.

### `storage.upload`

Expand Down Expand Up @@ -272,11 +273,12 @@ plugin should store values above `Number.MAX_SAFE_INTEGER` (2^53 - 1) as TEXT.

### `users.moderate`

- `owncast_user_set_enabled(idPtr: PTR, enabled: I64, reasonPtr: PTR): void`.
- `owncast_user_set_enabled(idPtr: PTR, enabled: I64, reasonPtr: PTR): PTR`.
Inputs: `idPtr` is a UTF-8 user ID, `enabled` is scalar 0 or 1, and
`reasonPtr` is a UTF-8 reason. Output: none.
- `owncast_ban_ip(ipPtr: PTR): void`. Input: `ipPtr` is a UTF-8 IP address.
Output: none.
`reasonPtr` is a UTF-8 reason. Output: JSON `{"error": string}` on failure or
`{}` on success.
- `owncast_ban_ip(ipPtr: PTR): PTR`. Input: `ipPtr` is a UTF-8 IP address.
Output: JSON `{"error": string}` on failure or `{}` on success.

### `users.register`

Expand Down Expand Up @@ -349,14 +351,16 @@ This permission gates UI surfaces inside Owncast's chrome. A manifest that
declares actions, styles, scripts, extra page content, or tabs without
`ui.modify` is rejected at load.

- `owncast_add_actions(actionsPtr: PTR): void`. Input: `actionsPtr` is JSON
`ActionButton[]`. Output: none. The host validates and appends the actions to
the plugin's runtime action list. Invalid input is logged.
Each action needs a title and exactly one of `url` or `html`. The host
rewrites relative URLs and icons into the plugin's namespace, rejects
cross-plugin paths, and persists the merged runtime list in plugin config.
- `owncast_clear_actions(): void`. Input: none. Output: none. Clears runtime
actions without changing `manifest.actions`.
- `owncast_add_actions(actionsPtr: PTR): PTR`. Input: `actionsPtr` is JSON
`ActionButton[]`. The host validates and appends the actions to the plugin's
runtime action list, returning JSON `{error?: string}`. A missing `error`
means success. Each action needs a title and exactly one of `url` or `html`.
The host rewrites relative URLs and icons into the plugin's namespace,
rejects cross-plugin paths, and persists the merged runtime list in plugin
config. The SDK throws when the host returns an error.
- `owncast_clear_actions(): PTR`. Input: none. Output: JSON
`{"error": string}` on failure or `{}` on success. Clears runtime actions
without changing `manifest.actions`.

### `chat.filter`

Expand Down
14 changes: 7 additions & 7 deletions engines/build_py.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@
("owncast_chat_clients", "", "str"),
],
"chat.moderate": [
("owncast_delete_message", "message_id: str"),
("owncast_kick_client", "client_id: int"),
("owncast_delete_message", "message_id: str", "str"),
("owncast_kick_client", "client_id: int", "str"),
],
"storage.kv": [
("owncast_kv_get", "key: str", "str"),
("owncast_kv_set", "key: str, value: str"),
("owncast_kv_set", "key: str, value: str", "str"),
],
"storage.upload": [
("owncast_storage_upload", "name: str, data: bytes", "str"),
Expand Down Expand Up @@ -90,8 +90,8 @@
("owncast_user_get", "user_id: str", "str"),
],
"users.moderate": [
("owncast_user_set_enabled", "user_id: str, enabled: int, reason: str"),
("owncast_ban_ip", "ip: str"),
("owncast_user_set_enabled", "user_id: str, enabled: int, reason: str", "str"),
("owncast_ban_ip", "ip: str", "str"),
],
"users.register": [
("owncast_users_register", "request: str", "str"),
Expand All @@ -107,8 +107,8 @@
("owncast_sse_send", "channel: str, event: str, data: str"),
],
"ui.modify": [
("owncast_add_actions", "payload: str"),
("owncast_clear_actions", ""),
("owncast_add_actions", "payload: str", "str"),
("owncast_clear_actions", "", "str"),
],
}

Expand Down
14 changes: 7 additions & 7 deletions engines/javascript/engine.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ declare module 'extism:host' {
owncast_send_chat_to(clientId: I64, textPtr: PTR): void;
owncast_chat_history(limit: I64): PTR;
owncast_chat_clients(): PTR;
owncast_delete_message(idPtr: PTR): void;
owncast_kick_client(clientId: I64): void;
owncast_delete_message(idPtr: PTR): PTR;
owncast_kick_client(clientId: I64): PTR;
owncast_notify_discord(textPtr: PTR): void;
owncast_notify_browser_push(payloadPtr: PTR): void;
owncast_notify_fediverse(payloadPtr: PTR): void;
Expand All @@ -40,8 +40,8 @@ declare module 'extism:host' {
owncast_users_register(reqPtr: PTR): PTR;
owncast_auth_grant_session(reqPtr: PTR): PTR;
owncast_auth_end_session(): void;
owncast_user_set_enabled(idPtr: PTR, enabled: I64, reasonPtr: PTR): void;
owncast_ban_ip(ipPtr: PTR): void;
owncast_user_set_enabled(idPtr: PTR, enabled: I64, reasonPtr: PTR): PTR;
owncast_ban_ip(ipPtr: PTR): PTR;
owncast_storage_upload(namePtr: PTR, dataPtr: PTR): PTR;
owncast_fs_read(pathPtr: PTR): PTR;
owncast_fs_write(pathPtr: PTR, dataPtr: PTR): PTR;
Expand All @@ -52,7 +52,7 @@ declare module 'extism:host' {
owncast_sql_query(requestPtr: PTR): PTR;
owncast_fediverse_post(textPtr: PTR): PTR;
owncast_kv_get(keyPtr: PTR): PTR;
owncast_kv_set(keyPtr: PTR, valPtr: PTR): void;
owncast_kv_set(keyPtr: PTR, valPtr: PTR): PTR;
owncast_emit_event(eventTypePtr: PTR, payloadPtr: PTR): void;
owncast_sse_send(channelPtr: PTR, eventPtr: PTR, dataPtr: PTR): void;
owncast_stream_current(): PTR;
Expand All @@ -64,7 +64,7 @@ declare module 'extism:host' {
owncast_server_tags(): PTR;
owncast_video_config_read(): PTR;
owncast_video_config_write(configPtr: PTR): PTR;
owncast_add_actions(actionsPtr: PTR): void;
owncast_clear_actions(): void;
owncast_add_actions(actionsPtr: PTR): PTR;
owncast_clear_actions(): PTR;
}
}
2 changes: 1 addition & 1 deletion examples/js/action-buttons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Action buttons place UI inside Owncast's own viewer chrome, so the manifest must

1. The plugin declares any always-on buttons under `manifest.actions[]`.
2. On load (or reload), the host parses the manifest and validates each entry: title is required, exactly one of `url` or `html` must be present, relative URLs are rewritten into this plugin's namespace, cross-plugin URLs are rejected.
3. At runtime, `owncast.actions.add(buttons)` appends to the plugin's effective list. The host runs the same validation on each entry and persists the result in the plugin's config.
3. At runtime, `owncast.actions.add(buttons)` appends to the plugin's effective list. The host runs the same validation on each entry and persists the result in the plugin's config. The call throws a descriptive error when validation or persistence fails, and rejects the entire batch.
4. `owncast.actions.clear()` drops the runtime additions. Only the manifest's defaults remain.
5. On every viewer `/api/config` request, the host returns `manifest.actions` ++ the runtime list, projected into Owncast's existing `ExternalAction` shape.

Expand Down
2 changes: 1 addition & 1 deletion examples/python/action-buttons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Action buttons place UI inside Owncast's own viewer chrome, so the manifest must

1. The plugin declares any always-on buttons under `manifest.actions[]`.
2. On load (or reload), the host parses the manifest and validates each entry: title is required, exactly one of `url` or `html` must be present, relative URLs are rewritten into this plugin's namespace, cross-plugin URLs are rejected.
3. At runtime, `owncast.actions.add(buttons)` appends to the plugin's effective list. The host runs the same validation on each entry and persists the result in the plugin's config.
3. At runtime, `owncast.actions.add(buttons)` appends to the plugin's effective list. The host runs the same validation on each entry and persists the result in the plugin's config. The call raises a descriptive error when validation or persistence fails, and rejects the entire batch.
4. `owncast.actions.clear()` drops the runtime additions. Only the manifest's defaults remain.
5. On every viewer `/api/config` request, the host returns `manifest.actions` ++ the runtime list, projected into Owncast's existing `ExternalAction` shape.

Expand Down
12 changes: 8 additions & 4 deletions host-runtime/cmd/owncast-plugin-serve/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,21 +170,25 @@ func main() {
// Side-effecting hooks: in a real Owncast these moderate users,
// kick clients, and send notifications. The dev server can't do any
// of that, so it logs the intent to stderr for the author to see.
DeleteMessage: func(pluginName, messageID string) {
DeleteMessage: func(pluginName, messageID string) error {
logHostCall("chat.delete", pluginName, "message %s", messageID)
return nil
},
KickClient: func(pluginName string, clientID uint64) {
KickClient: func(pluginName string, clientID uint64) error {
logHostCall("chat.kick", pluginName, "client %d", clientID)
return nil
},
SetUserEnabled: func(pluginName, userID string, enabled bool, reason string) {
SetUserEnabled: func(pluginName, userID string, enabled bool, reason string) error {
state := "enabled"
if !enabled {
state = "disabled"
}
logHostCall("users.setEnabled", pluginName, "%s → %s (%s)", userID, state, reason)
return nil
},
BanIP: func(pluginName, ip string) {
BanIP: func(pluginName, ip string) error {
logHostCall("users.banIP", pluginName, "%s", ip)
return nil
},
SendDiscord: func(pluginName, text string) {
logHostCall("notify.discord", pluginName, "%s", text)
Expand Down
2 changes: 1 addition & 1 deletion host-runtime/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.26.2
require (
github.com/extism/go-sdk v1.7.1
github.com/gobwas/glob v0.2.3
github.com/owncast/owncast v0.2.6-0.20260802053447-42ee66391bb1
github.com/owncast/owncast v0.2.6-0.20260803201111-10d12cba1ffb
modernc.org/sqlite v1.53.0
)

Expand Down
4 changes: 2 additions & 2 deletions host-runtime/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6Kb
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/owncast/owncast v0.2.6-0.20260802053447-42ee66391bb1 h1:lgQ95bi/Jf+EE9Ja2nlzdj6A4RbnbGvsKMZhVeTitpY=
github.com/owncast/owncast v0.2.6-0.20260802053447-42ee66391bb1/go.mod h1:/pBiqGTab5UMn37wapn4zzpQUG2476ayj/zlsyJMcgA=
github.com/owncast/owncast v0.2.6-0.20260803201111-10d12cba1ffb h1:v28vfyeK0/0hsjLA4Xju/pkeu/2wyuGRfwnn+K+DomU=
github.com/owncast/owncast v0.2.6-0.20260803201111-10d12cba1ffb/go.mod h1:/pBiqGTab5UMn37wapn4zzpQUG2476ayj/zlsyJMcgA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
Expand Down
6 changes: 4 additions & 2 deletions host-runtime/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,17 @@ func main() {
}
return plugin.HostUser{}, false
},
SetUserEnabled: func(plugin, userID string, enabled bool, reason string) {
SetUserEnabled: func(plugin, userID string, enabled bool, reason string) error {
state := "enabled"
if !enabled {
state = "disabled"
}
fmt.Printf("[users.setEnabled by %s] %s → %s (%s)\n", plugin, userID, state, reason)
return nil
},
BanIP: func(plugin, ip string) {
BanIP: func(plugin, ip string) error {
fmt.Printf("[users.banIP by %s] %s\n", plugin, ip)
return nil
},
ChatClients: func() []plugin.HostChatClient {
return nil // demo has no real chat-client connections
Expand Down
12 changes: 8 additions & 4 deletions host-runtime/plugin/testing/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,15 +269,17 @@ func (m *MockHost) HostEnv() *plugin.HostEnv {
}
return out
},
DeleteMessage: func(_, id string) {
DeleteMessage: func(_, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.deletedMessages = append(m.deletedMessages, id)
return nil
},
KickClient: func(_ string, id uint64) {
KickClient: func(_ string, id uint64) error {
m.mu.Lock()
defer m.mu.Unlock()
m.kickedClients = append(m.kickedClients, id)
return nil
},
SendDiscord: func(_, text string) {
m.mu.Lock()
Expand Down Expand Up @@ -311,17 +313,19 @@ func (m *MockHost) HostEnv() *plugin.HostEnv {
}
return plugin.HostUser{}, false
},
SetUserEnabled: func(_, id string, enabled bool, reason string) {
SetUserEnabled: func(_, id string, enabled bool, reason string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.userMods = append(m.userMods, RecordedUserModeration{
UserID: id, Enabled: enabled, Reason: reason,
})
return nil
},
BanIP: func(_, ip string) {
BanIP: func(_, ip string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.bannedIPs = append(m.bannedIPs, ip)
return nil
},
RegisterUser: func(_ string, req plugin.UserRegisterRequest) (string, error) {
m.mu.Lock()
Expand Down
19 changes: 12 additions & 7 deletions sdks/js/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,9 +590,11 @@ export const owncast: {
/** Recent chat history (most recent last). Requires `chat.history`.
* Default limit is 50. Pass a smaller number to get fewer. */
history(limit?: number): ChatMessage[];
/** Hide a chat message by ID. Requires `chat.moderate`. */
/** Hide a chat message by ID. Throws when the host rejects the operation.
* Requires `chat.moderate`. */
deleteMessage(messageId: string): void;
/** Disconnect a chat client by its numeric ID. Requires `chat.moderate`. */
/** Disconnect a chat client by its numeric ID. Throws when the host rejects
* the operation. Requires `chat.moderate`. */
kick(clientId: number | bigint): void;
/** List currently-connected chat clients. Requires `chat.history`. */
clients(): ChatClient[];
Expand All @@ -603,9 +605,11 @@ export const owncast: {
list(): User[];
/** Fetch one user by ID. Requires `users.read`. */
get(id: string): User | null;
/** Enable/disable a user, with an optional reason. Requires `users.moderate`. */
/** Enable or disable a user, with an optional reason. Throws when the host
* rejects the operation. Requires `users.moderate`. */
setEnabled(id: string, enabled: boolean, reason?: string): void;
/** Ban an IP address. Requires `users.moderate`. */
/** Ban an IP address. Throws when the host rejects the operation. Requires
* `users.moderate`. */
banIP(ip: string): void;
/** Find or create an authenticated user for an external identity. The host
* scopes `authId` to this plugin's slug. `profileUrl` and `handle`
Expand Down Expand Up @@ -695,6 +699,7 @@ export const owncast: {
};
kv: {
get(key: string): string | null;
/** Store a value. Throws when the host rejects the operation. */
set(key: string, value: string | number): void;
/** Read a JSON value, parsed. Returns `fallback` (default `undefined`)
* when the key is unset or holds invalid JSON. Requires `storage.kv`. */
Expand Down Expand Up @@ -733,11 +738,11 @@ export const owncast: {
* entry is validated with the same rules as `manifest.actions`
* (title required, exactly one of `url` or `html`, relative URLs
* rewritten into this plugin's namespace, cross-plugin URLs
* rejected). The next viewer `/api/config` request returns
* `manifest.actions` ++ the runtime list. */
* rejected). Throws when the host rejects the action list. */
add(actions: ActionButton | ActionButton[]): void;
/** Drop the runtime additions, so only `manifest.actions` remain on
* the next viewer `/api/config` request. */
* the next viewer `/api/config` request. Throws when the host rejects the
* operation. */
clear(): void;
};
sse: {
Expand Down
Loading
Loading