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
11 changes: 8 additions & 3 deletions .github/workflows/examples-js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,19 @@ jobs:

# Build owncast-plugin-test/serve from source and drop them into the
# shared cache, replacing the released binaries postinstall fetched, so
# examples run against the current runtime. The runtime lives in owncast
# (services/plugins); GOPRIVATE + @develop matches release.yml.
# examples run against the current runtime. A matching branch in the
# Owncast repository takes precedence over develop for cross-repo stacks.
- name: Build host binaries from source
working-directory: host-runtime
env:
GOPRIVATE: github.com/owncast/owncast
run: |
go get github.com/owncast/owncast@develop
owncast_ref=develop
if [[ -n "${GITHUB_HEAD_REF:-}" ]] && git ls-remote --exit-code --heads https://github.com/owncast/owncast.git "refs/heads/${GITHUB_HEAD_REF}" >/dev/null; then
owncast_ref="${GITHUB_HEAD_REF}"
fi
echo "Building against owncast@${owncast_ref}"
go get "github.com/owncast/owncast@${owncast_ref}"
go build -o ../sdks/js/bin/.cache/owncast-plugin-test ./cmd/owncast-plugin-test
go build -o ../sdks/js/bin/.cache/owncast-plugin-serve ./cmd/owncast-plugin-serve

Expand Down
11 changes: 8 additions & 3 deletions .github/workflows/examples-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,19 @@ jobs:
go-version-file: host-runtime/go.mod
cache-dependency-path: host-runtime/go.sum

# Build owncast-plugin-test/serve from the current owncast runtime
# (services/plugins). GOPRIVATE + @develop matches release.yml.
# Build owncast-plugin-test/serve from the current owncast runtime.
# A matching Owncast branch takes precedence over develop for stacked PRs.
- name: Build host binaries from source
working-directory: host-runtime
env:
GOPRIVATE: github.com/owncast/owncast
run: |
go get github.com/owncast/owncast@develop
owncast_ref=develop
if [[ -n "${GITHUB_HEAD_REF:-}" ]] && git ls-remote --exit-code --heads https://github.com/owncast/owncast.git "refs/heads/${GITHUB_HEAD_REF}" >/dev/null; then
owncast_ref="${GITHUB_HEAD_REF}"
fi
echo "Building against owncast@${owncast_ref}"
go get "github.com/owncast/owncast@${owncast_ref}"
mkdir -p "$GITHUB_WORKSPACE/pytoolchain/bin" "$GITHUB_WORKSPACE/pytoolchain/share"
go build -o "$GITHUB_WORKSPACE/pytoolchain/bin/owncast-plugin-test" ./cmd/owncast-plugin-test
go build -o "$GITHUB_WORKSPACE/pytoolchain/bin/owncast-plugin-serve" ./cmd/owncast-plugin-serve
Expand Down
40 changes: 21 additions & 19 deletions docs/PLUGIN_AUTHOR_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,7 @@ Sends are fire-and-forget: the call returns immediately and never blocks, even i
Notes:

- Up to 64 simultaneous connections per plugin. Over that the endpoint returns 503. `EventSource` reconnects automatically.
- If the channel matches one of your `admin.pages[]` globs it's auth-gated like any admin route, handy for an admin-only stats stream.
- If the channel matches one of your `admin.pages` path globs, it is auth-gated like any admin route. This is useful for an admin-only stats stream.
- The endpoint is host-owned and reserved: your `onHttpRequest` never sees `/_sse/...` requests, and you can't serve your own route there.

### Knowing who is connected
Expand Down Expand Up @@ -738,32 +738,33 @@ Prefer it over building a bespoke settings page and KV plumbing.

## Admin pages

Plugins can register pages that appear in the Owncast admin UI for configuration. Declare them in the manifest:
Plugins can register pages that appear in the Owncast admin UI for configuration. Each `pages` object key is a plugin-relative path glob:

```json
{
"permissions": ["http.serve"],
"admin": {
"pages": [
{ "title": "Settings", "path": "/admin", "icon": "gear" },
{ "title": "Settings", "path": "/admin/*" }
]
"pages": {
"/admin": { "title": "Settings", "icon": "gear" },
"/admin/*": { "title": "Settings" }
}
}
}
```

- `path` is a glob (e.g. `"/admin"`, `"/admin/*"`). Requests under `/plugins/<your-slug>/<path>` that match any declared glob are **auth-gated by the host**, unauthenticated requests get `401` before your plugin code ever runs.
- Each key is a path glob such as `"/admin"` or `"/admin/*"`. Requests under `/plugins/<your-slug>/<path>` that match any declared glob are **auth-gated by the host**. Unauthenticated requests get `401` before your plugin code runs.
- Owncast's admin renders each declared page as a tab inside `/admin/plugins/configure?id=<your-slug>`, embedded as an iframe pointed at `/plugins/<your-slug>/<path>`. Each plugin gets its own bookmarkable URL plus a sidebar entry under **Plugins** in the admin nav.
- Both static assets and dynamic endpoints under matched paths are auth-gated. You don't have to check `req.authenticated` yourself.
- The host auto-injects an admin-themed stylesheet (`/styles/admin/plugin-iframe.css`) into HTML responses on admin paths so plain `<input>`/`<button>` controls match Owncast's look without you needing to ship CSS. Plugins that prefer their own styling can layer on top.
- The host auto-injects an admin-themed stylesheet (`/styles/admin/plugin-iframe.css`) into HTML responses on admin paths so plain `<input>` and `<button>` controls match Owncast's look without you needing to ship CSS. Plugins that prefer their own styling can layer on top.
- The iframe is sandboxed but permits what an admin page normally needs: your scripts, form submits, same-origin `fetch` to your own endpoints, popups, **file downloads** (a blob/data-URL `<a download>` clicked from script), and **`confirm()`/`alert()`/`prompt()`** dialogs. If a browser feature seems silently blocked, suspect the iframe sandbox first.
- JSON object order is not significant. Owncast displays pages in lexicographic path order.

Author flow:

1. Put admin HTML/CSS/JS in `public/admin/index.html` (and friends)
2. Expose admin APIs via `onHttpRequest` at `/admin/api/...`
3. Declare both globs (or just `"/admin/*"`) in `manifest.admin.pages[].path`
4. Visit `/admin/plugins/configure?id=<your-slug>` in the admin UI (or `/plugins/<your-slug>/admin/` directly). Owncast uses your existing admin login to gate the page. No extra prompt.
1. Put admin HTML, CSS, and JavaScript in `public/admin/index.html` and related files.
2. Expose admin APIs via `onHttpRequest` at `/admin/api/...`.
3. Declare both path keys, or just `"/admin/*"`, in `manifest.admin.pages`.
4. Visit `/admin/plugins/configure?id=<your-slug>` in the admin UI or `/plugins/<your-slug>/admin/` directly. Owncast uses your existing admin login to gate the page. No extra prompt.

## Viewer authentication gates

Expand Down Expand Up @@ -999,21 +1000,22 @@ The admin's extra page content goes through the markdown processor, but plugin H

## Viewer-page tabs

Plugins can add tabs to the viewer page's tab row (alongside the built-in **About** and **Followers** tabs) with `manifest.tabs[]`. Each entry requires a `title` and a `slug`, and `content` is optional:
Plugins can add tabs to the viewer page's tab row alongside the built-in **About** and **Followers** tabs with `manifest.tabs`. Each object key is the tab slug. Every value requires a `title`, and `content` is optional:

```json
{
"permissions": ["ui.modify"],
"tabs": [
{ "title": "Music", "slug": "music", "content": "music.html" },
{ "title": "Stream Info", "slug": "stream-info" }
]
"tabs": {
"music": { "title": "Music", "content": "music.html" },
"stream-info": { "title": "Stream Info" }
}
}
```

- `slug`: required. Stable identifier, unique within the plugin's tabs. Passed to `onTabContent` so the handler knows which tab to render. Lowercase letters, digits, hyphens.
- `title`: required. The label shown on the tab. Keep it short (~16 characters max for mobile).
- Each key is the tab's required stable slug. It must be unique within the plugin and use lowercase letters, digits, and hyphens. Owncast passes it to `onTabContent` so the handler knows which tab to render.
- `title`: required. The label shown on the tab. Keep it short, around 16 characters maximum for mobile.
- `content`: optional. Relative path to a static HTML file in `assets/`. When omitted, the host calls `onTabContent`.
- JSON object order is not significant. Owncast displays tabs in lexicographic slug order.

**Static** (`content` present): the host reads the file from `assets/` and inlines it as the tab body. Path rules match `extraPageContent.content`.

Expand Down
46 changes: 32 additions & 14 deletions docs/WIRE_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ the gate re-validate a session on each `/` page load and return
### `ui.modify`

- Not a custom host function. Gates UI surfaces that place plugin-contributed elements inside Owncast's own chrome.
- Required when the manifest declares `actions[]`, `styles[]`, `scripts[]`, `extraPageContent`, or `tabs[]`, and required at runtime by `owncast_add_actions` / `owncast_clear_actions`. Manifests that declare any of those fields without `ui.modify` are rejected at load. Runtime calls return a permission error.
- Required when the manifest declares `actions[]`, `styles[]`, `scripts[]`, `extraPageContent`, or `tabs`, and required at runtime by `owncast_add_actions` / `owncast_clear_actions`. Manifests that declare any of those fields without `ui.modify` are rejected at load. Runtime calls return a permission error.
- `owncast_add_actions(jsonPtr: PTR): u64`, append one or more `ActionButton` entries on top of `manifest.actions`. Argument is a JSON array. The host validates each entry with the same rules as the manifest (title required, exactly one of `url` / `html`, relative URLs and icons auto-prefixed to the plugin's namespace, cross-plugin paths rejected) and persists the merged set to the plugin's config. Returns the host call envelope (success indicator + optional error string).
- `owncast_clear_actions(jsonPtr: PTR): u64`, drop every runtime addition. `manifest.actions` are untouched. Argument is an empty JSON object (`"{}"`) for API symmetry. Returns the host call envelope.

Expand Down Expand Up @@ -317,7 +317,7 @@ The plugin process is **not** involved in serving the connection, no wasm call i
Host behavior:

- Requires the `http.sse` permission, 404 otherwise.
- A channel that matches a `manifest.admin.pages[]` glob is auth-gated like any other admin path (401 if not authenticated).
- A channel that matches a `manifest.admin.pages` path glob is auth-gated like any other admin path (401 if not authenticated).
- Connections are capped per-plugin (default 64). Over the cap returns 503.
- Idle streams get a `: keep-alive` comment line every 15s so proxies don't drop them.
- Delivery is best-effort: each client has a small send buffer, and frames are dropped for a client that can't keep up rather than blocking the publishing plugin.
Expand Down Expand Up @@ -356,9 +356,23 @@ Runtime additions go through `owncast_add_actions` / `owncast_clear_actions` (se

The host exposes the merged list as `GET /api/plugins/actions` (public). The Owncast server is responsible for folding that into its existing `/api/externalactions` response.

### `manifest.admin.pages[]`
### `manifest.admin.pages`

Glob-matched routes inside `/plugins/<name>/...` that the host auth-gates before reaching the plugin's `on_http_request`. See `manifest.go:AdminPage`.
An object of plugin-relative path glob keys to admin-page definitions. The host auth-gates matching routes inside `/plugins/<name>/...` before they reach the plugin's `on_http_request`.

```json
{
"/admin": {
"title": "Settings",
"icon": "gear"
},
"/admin/*": {
"title": "Settings"
}
}
```

Each key must start with `/`. Each value requires a non-empty `title` and may include `icon`. The host processes pages in lexicographic path order because JSON object order is not significant.

### `manifest.network.allowedHosts[]`

Expand Down Expand Up @@ -411,31 +425,35 @@ Validation:

Each contribution is wrapped with an `<!-- plugin: <slug> — <file> -->\n` comment for in-page attribution. The admin's content goes through the markdown processor before plugin HTML is prepended. Plugin HTML is left raw so tags and attributes pass through as written.

### `manifest.tabs[]`
### `manifest.tabs`

An array of viewer-page tabs the plugin contributes alongside the built-in tabs (Followers, About).
An object of tab slug keys to viewer-page tab definitions. The tabs appear alongside the built-in tabs (Followers, About).

```json
{
"title": "string (required, tab label)",
"slug": "string (required, stable identifier, passed to on_tab_content)",
"content": "string (optional, relative path to assets/<file>.html)"
"music": {
"title": "Music",
"content": "music.html"
},
"stream-info": {
"title": "Stream Info"
}
}
```

Validation:

- `ui.modify` permission required.
- `http.serve` is **not** required: each tab's HTML is inlined into the response, not served at a URL.
- `title` must be non-empty. Unique within the plugin's tabs.
- `slug` must be a valid slug. Unique within the plugin's tabs. Passed to `on_tab_content` so the plugin knows which tab to render.
- When `content` is present, the same path rules as `manifest.extraPageContent.content` apply (must end in `.html`).
- `http.serve` is **not** required. Each tab's HTML is inlined into the response, not served at a URL.
- Each key must be a valid slug and is passed to `on_tab_content`.
- `title` must be non-empty and unique within the plugin's tabs.
- When `content` is present, the same path rules as `manifest.extraPageContent.content` apply. It must end in `.html`.

**Static** (`content` present): the host reads the file from `assets/` and inlines its bytes.

**Dynamic** (`content` absent): the host calls `on_tab_content` with `{ slug, user? }` and inlines the returned HTML string.

The host emits the tab list on `GET /api/config` under `pluginTabs[]` as `[{slug, title, html}]` entries. The viewer page maps each entry to a tab whose body renders the inlined HTML. `slug` doubles as the React key so a tab only unmounts when the source plugin is disabled/removed.
JSON object order is not significant. The host emits tabs in lexicographic slug order on `GET /api/config` under `pluginTabs[]` as `[{slug, title, html}]` entries. The viewer page maps each entry to a tab whose body renders the inlined HTML. The emitted `slug` is `<plugin-slug>/<tab-slug>` and doubles as the React key.

## Payload types

Expand Down
2 changes: 1 addition & 1 deletion examples/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ One self-contained npm project per directory. Each has its own `README.md` with
| [page-content-demo](./page-content-demo/) | `manifest.extraPageContent`, HTML prepended to the viewer's extra-content block. |
| [theme-hub](./theme-hub/) | Dynamic `onPageStyles` + `onPageScripts`: an admin-selectable theme catalog applied to the whole viewer UI via `customStyles`. |
| [viewer-gate](./viewer-gate/) | `manifest.styles` + `manifest.scripts` together: a confirmation modal on page load. |
| [tabs-demo](./tabs-demo/) | `manifest.tabs[]`, two tabs added to the viewer page's tab row alongside Followers and About. |
| [tabs-demo](./tabs-demo/) | `manifest.tabs`, two tabs added to the viewer page's tab row alongside Followers and About. |

## Building and testing

Expand Down
7 changes: 3 additions & 4 deletions examples/js/action-buttons/plugin.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,12 @@
"storage.kv"
],
"admin": {
"pages": [
{
"pages": {
"/admin": {
"title": "Button labels",
"path": "/admin",
"icon": "gear"
}
]
}
},
"actions": [
{
Expand Down
12 changes: 5 additions & 7 deletions examples/js/admin-demo/plugin.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,14 @@
"storage.kv"
],
"admin": {
"pages": [
{
"pages": {
"/admin": {
"title": "Example Page 1",
"path": "/admin",
"icon": "gear"
},
{
"title": "Example Page 2",
"path": "/admin/anotherpage.html"
"/admin/anotherpage.html": {
"title": "Example Page 2"
}
]
}
}
}
7 changes: 3 additions & 4 deletions examples/js/file-manager/plugin.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,11 @@
"storage.fs"
],
"admin": {
"pages": [
{
"pages": {
"/admin": {
"title": "Files",
"path": "/admin",
"icon": "folder"
}
]
}
}
}
2 changes: 1 addition & 1 deletion examples/js/manual-video-settings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Admin-only form at `/plugins/manual-video-settings/admin/` for hand-editing the
```json
{
"permissions": ["http.serve", "videoconfig.read", "videoconfig.write"],
"admin": { "pages": [{ "title": "Manual Video Settings", "path": "/admin" }] }
"admin": { "pages": { "/admin": { "title": "Manual Video Settings" } } }
}
```

Expand Down
7 changes: 3 additions & 4 deletions examples/js/manual-video-settings/plugin.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,11 @@
"videoconfig.write"
],
"admin": {
"pages": [
{
"pages": {
"/admin": {
"title": "Manual Video Settings",
"path": "/admin",
"icon": "gear"
}
]
}
}
}
6 changes: 3 additions & 3 deletions examples/js/page-content-demo/INSTRUCTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ The manifest declares the two slots without `content` paths, which tells the hos
{
"permissions": ["ui.modify", "server.read"],
"extraPageContent": { "slug": "banner" },
"tabs": [
{ "title": "Stream Info", "slug": "stream-info" }
]
"tabs": {
"stream-info": { "title": "Stream Info" }
}
}
```

Expand Down
6 changes: 3 additions & 3 deletions examples/js/page-content-demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ Demonstrates dynamic `extraPageContent` and viewer tabs using `onPageContent` an
{
"permissions": ["ui.modify", "server.read"],
"extraPageContent": { "slug": "banner" },
"tabs": [
{ "title": "Stream Info", "slug": "stream-info" }
]
"tabs": {
"stream-info": { "title": "Stream Info" }
}
}
```

Expand Down
9 changes: 4 additions & 5 deletions examples/js/page-content-demo/plugin.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@
"extraPageContent": {
"slug": "banner"
},
"tabs": [
{
"title": "Stream Info",
"slug": "stream-info"
"tabs": {
"stream-info": {
"title": "Stream Info"
}
]
}
}
16 changes: 7 additions & 9 deletions examples/js/rules-tab/plugin.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,17 @@
"storage.kv",
"chat.send"
],
"tabs": [
{
"title": "Rules",
"slug": "rules"
"tabs": {
"rules": {
"title": "Rules"
}
],
},
"admin": {
"pages": [
{
"pages": {
"/admin/*": {
"title": "Rules",
"path": "/admin/*",
"icon": "book"
}
]
}
}
}
Loading
Loading