From 3bece7417209abbf0b194aa142a2ed84295b64d7 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Sat, 1 Aug 2026 12:44:27 -0700 Subject: [PATCH 1/5] test: verify plugin wire protocol contracts --- .github/workflows/examples-js.yml | 1 + docs/ARCHITECTURE.md | 21 +- docs/PLUGIN_AUTHOR_GUIDE.md | 41 +- docs/WIRE_PROTOCOL.md | 722 +++++++++++++++--- engines/build_py.py | 8 +- examples/js/github-auth/README.md | 2 +- .../js/github-auth/__tests__/auth.test.json | 4 +- examples/js/github-auth/src/plugin.js | 13 +- .../python/all-permissions-test/README.md | 22 +- .../__tests__/binary.test.json | 19 + .../assets/invalid-utf8.bin | Bin 0 -> 3 bytes .../python/all-permissions-test/src/plugin.py | 22 +- examples/python/github-auth/README.md | 2 +- .../github-auth/__tests__/auth.test.json | 4 +- examples/python/github-auth/src/plugin.py | 13 +- host-runtime/go.mod | 2 +- host-runtime/go.sum | 4 +- host-runtime/host_function_contract_test.go | 174 +++++ host-runtime/plugin/testing/mocks.go | 4 + host-runtime/plugin/testing/runner.go | 24 +- host-runtime/plugin/testing/scenario.go | 15 +- host-runtime/plugin/testing/scenario_test.go | 115 +++ sdks/js/index.d.ts | 17 +- sdks/js/index.js | 20 +- sdks/python/README.md | 12 +- sdks/python/owncast_plugin/__init__.py | 68 +- 26 files changed, 1165 insertions(+), 184 deletions(-) create mode 100644 examples/python/all-permissions-test/__tests__/binary.test.json create mode 100644 examples/python/all-permissions-test/assets/invalid-utf8.bin create mode 100644 host-runtime/host_function_contract_test.go diff --git a/.github/workflows/examples-js.yml b/.github/workflows/examples-js.yml index 9327bbf..8aa2583 100644 --- a/.github/workflows/examples-js.yml +++ b/.github/workflows/examples-js.yml @@ -69,6 +69,7 @@ jobs: fi echo "Building against owncast@${owncast_ref}" go get "github.com/owncast/owncast@${owncast_ref}" + go test ./... 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 536da49..1abb6f9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,7 +46,8 @@ file (see [build flow](#toolchain-and-build-flow)). enforces each plugin's **permissions at call time**: a host function resolves the calling plugin's identity (from a per-instance config value) and rejects the call if the plugin's manifest didn't grant the permission. -- Everything crosses the boundary as JSON. +- Pointer payloads carry JSON, UTF-8 text, or raw bytes. Some host imports use + scalar `I64` values. - Inbound Fediverse hooks are internal notify subscriptions. They are not external HTTP webhooks. Owncast verifies the HTTP signature and actor origin, then sends the raw activity to `onFediverse` / `on_fediverse` and also sends @@ -115,9 +116,9 @@ the exact production code. Key files: `hostfns.go` is intentionally host-agnostic. A host function like `owncast_video_config_read` just calls `env.VideoConfig()`, a field on -`HostEnv`. `BuildHostFunctions` assembles the host functions a plugin gets based -on its declared permissions. **Whoever embeds the runtime fills in `HostEnv`** -with real data. Four hosts do this today: +`HostEnv`. `BuildHostFunctions` assembles the full host-function set and each +call checks the plugin's declared permissions. **Whoever embeds the runtime +fills in `HostEnv`** with real data. Four hosts do this today: | Host | `HostEnv` is backed by | Used for | | ----------------------------- | ------------------------------------ | ------------------------ | @@ -234,14 +235,20 @@ amd64/arm64 on every `v*` tag. passes in production. - **Go tests** cover the runtime packages (`manager`, `dispatcher`, `server`, `sse`, `testing`). -- **Contract/drift tests** keep the Go/TS/snapshot representations aligned. +- **Contract/drift tests** keep Owncast's contract snapshot and the shared + JavaScript and Python import declarations aligned. The host-runtime test + derives the stack ABI from `plugins.BuildHostFunctions`. ## Relationship to Owncast The runtime **lives in the Owncast repo** as `services/plugins/`, where Owncast wires `HostEnv` to its real services. This SDK's `host-runtime/` module imports it, so the dev CLIs run the exact production runtime. The API surface in -`hostfns.go` is pinned by `services/plugins/plugin-contract.json` and its -contract test, so it can't drift from the [Wire Protocol](./WIRE_PROTOCOL.md). +`hostfns.go` has a `services/plugins/plugin-contract.json` snapshot for +permission names, host-function names, and wire types. +`host-runtime/host_function_contract_test.go` separately derives the current +stack signatures from `BuildHostFunctions` and compares the JavaScript and +Python shared engine declarations. + The host-side integration details (wiring, the sync workflow) are documented in the Owncast repo at `docs/plugins.md`. diff --git a/docs/PLUGIN_AUTHOR_GUIDE.md b/docs/PLUGIN_AUTHOR_GUIDE.md index 8d2f1e7..1cc749b 100644 --- a/docs/PLUGIN_AUTHOR_GUIDE.md +++ b/docs/PLUGIN_AUTHOR_GUIDE.md @@ -355,7 +355,7 @@ Each method requires the matching permission in your manifest: | `owncast.users.list()` / `.get(id)` | `users.read` | | `owncast.users.setEnabled(id, enabled, reason?)` | `users.moderate` | | `owncast.users.banIP(ip)` | `users.moderate` | -| `owncast.users.register({authId, displayName})`, find-or-create a viewer identity | `users.register` | +| `owncast.users.register({authId, displayName?, scopes?, profileUrl?, handle?, public?})` | `users.register` | | `owncast.auth.grantSession({userId, ttl?})` / `owncast.auth.endSession()` | `auth.gate` | | `owncast.kv.get(key)` / `.set(key, value)` (+ `.getJSON` / `.setJSON`) | `storage.kv` | | `owncast.storage.upload(name, bytes)`, returns `{url}` | `storage.upload` | @@ -402,6 +402,13 @@ owncast.log.error("sync failed") `info`, `warning`, and `error` map directly to the same Owncast logrus levels. Every entry includes the calling plugin's slug, for example `plugin schedule-sync: sync started`. Owncast replaces control characters with spaces so each entry stays on one line, then truncates messages longer than 4 KiB. No permission is required. Prefer this API over `console.log` or `print` when the level and plugin identity need to reach the Owncast log reliably. +`storage.upload`, `fs.write`, `fs.read`, and `assets.read` preserve arbitrary +bytes. Their string conveniences encode or decode UTF-8 explicitly: +`fs.readText` and `assets.readText` return text, while passing a string to +`storage.upload` or `fs.write` UTF-8 encodes it. Python uses the matching +`read_text` names and returns `bytes` from `read`. + + ### SQL database `storage.sql` gives your plugin one SQLite database of its own, private to your plugin and separate from Owncast's database. `owncast.sql.exec(sql, params?)` runs statements and reports `rowsAffected` and `lastInsertId`, `owncast.sql.query(sql, params?)` returns rows as objects keyed by column name, and `owncast.sql.queryRow(sql, params?)` returns the first row or `null`. Python is the same API with `query_row` and dicts. A failed statement throws (Python raises). The SDK treats a result object with no `error` field as success and rejects a missing or non-object host response. @@ -548,7 +555,7 @@ in the built-in help listing. | `chat.filter` | Subscribe to `filterChatMessage` (read, modify, or drop every chat message). Required for any plugin that declares the handler. | | `users.read` | `owncast.users.list`, `.get` | | `users.moderate` | `owncast.users.setEnabled`, `.banIP` | -| `users.register` | `owncast.users.register`, find-or-create an authenticated Owncast user for an external identity. The host records your slug alongside the raw `authId` and scopes every lookup to that pair, so plugins can't collide on or impersonate each other's users. | +| `users.register` | `owncast.users.register`, find or create an authenticated Owncast user for an external identity. The host scopes the raw `authId` to your plugin slug. Optional `profileUrl`, `handle`, and `public` fields describe a verified identity and whether the viewer chose to display it publicly. | | `auth.gate` | Be the site's viewer-authentication gate: `owncast.auth.grantSession` / `.endSession` plus the `onAuthCheck` hook. Only one gate plugin can be enabled at a time. See [Viewer authentication gates](#viewer-authentication-gates). | | `storage.kv` | Per-plugin namespaced key/value store | | `storage.upload` | `owncast.storage.upload`, upload files, get a public URL | @@ -796,14 +803,26 @@ The flow (see `examples/js/github-auth` for a complete OAuth version): 3. Once satisfied, name the visitor and grant the session: ```js -const { userId } = owncast.users.register({ authId: "provider:1234", displayName: "Jo" }); +const { userId } = owncast.users.register({ + authId: "1234", + displayName: "Jo", + profileUrl: "https://provider.example/users/1234", + handle: "jo", + public: viewerOptedIn, +}); owncast.auth.grantSession({ userId }); // the host attaches the cookie to this response return { status: 302, headers: { Location: returnTo } }; ``` 4. For logout, call `owncast.auth.endSession()` and redirect. -`users.register` finds-or-creates an authenticated Owncast user for an external identity (the host scopes the identity to your slug, so pass the raw external id unprefixed). `grantSession`/`endSession` are only meaningful inside `onHttpRequest`, where the host attaches or clears the cookie on the response after your handler returns. +`users.register` finds or creates an authenticated Owncast user for an external +identity. The host scopes `authId` to your slug, so pass the provider's raw +stable ID. `profileUrl` must be empty or an absolute HTTP(S) URL. Set `handle` +to the verified provider label and set `public` true only after the viewer opts +into public display. `grantSession` and `endSession` are meaningful only inside +`onHttpRequest`, where the host attaches or clears the cookie after the handler +returns. ### Re-validating sessions: `onAuthCheck` @@ -840,7 +859,7 @@ Gate-specific things to know: - `req.user` carries no external identity. Store your own mapping at registration time (`owncast.kv.set("member:" + userId, externalId)`) and look it up in `onAuthCheck`. - Use a stable external id in `authId` (a numeric account id), never a username or email that can change. -- Your own routes stay reachable through the gate — visitors must be able to reach the login screen — which also means inbound webhooks to `/plugins//...` keep working while the gate is up. `/admin` is likewise exempt, so an admin can always fix or disable a misconfigured gate. +- Your own routes stay reachable through the gate because visitors must be able to reach the login screen. Inbound webhooks to `/plugins//...` also keep working while the gate is up. `/admin` is exempt so an admin can always fix or disable a misconfigured gate. - Fail closed while unconfigured: refuse to grant sessions until your config values are set. - Testing: the `authCheck` scenario step drives `onAuthCheck` directly (see [Step types](#step-types)). @@ -930,7 +949,7 @@ Each also has a **dynamic** form computed at request time by a handler instead o } ``` -Bundle the CSS files under `assets/` and reference them by path. The host strips the plugin prefix, reads each file's bytes, and concatenates them in front of a `/* plugin: */` delimiter so a reader can attribute a rule back to whichever plugin shipped it. Disabling the plugin drops its contribution. +Bundle the CSS files under `assets/` and reference them by path. The host strips the plugin prefix, reads each file's bytes, and concatenates them after a delimiter comment that identifies the plugin slug and file. Disabling the plugin drops its contribution. Path rules match action-button URLs: @@ -954,7 +973,7 @@ module.exports = definePlugin({ }); ``` -The returned string is appended to `customStyles` after any static `styles` files (so a later rule wins the cascade), preceded by a `/* plugin: — dynamic */` delimiter. The call is global. It takes no per-viewer argument, which keeps `/api/config` cacheable. Return `""` to contribute nothing on this request. Python exposes the same hook as the bare decorator `@plugin.on_page_styles`. +The returned string is appended to `customStyles` after any static `styles` files, so a later rule wins the cascade. A delimiter comment identifies the plugin slug and marks the contribution as dynamic. The call is global. It takes no per-viewer argument, which keeps `/api/config` cacheable. Return `""` to contribute nothing on this request. Python exposes the same hook as the bare decorator `@plugin.on_page_styles`. ### Scripts @@ -965,7 +984,7 @@ The returned string is appended to `customStyles` after any static `styles` file } ``` -Same path and permission rules as `styles`, applied to `.js` files. The host prefixes each contribution with `// plugin: `. +The same path and permission rules as `styles` apply to `.js` files. The host prefixes each contribution with a comment containing the plugin slug and file name. Two things to keep in mind about execution: @@ -1083,7 +1102,7 @@ install-time path a real Owncast server runs, covering manifest validity, a healthy `register()`, and permission-gated subscriptions (a chat filter without `chat.filter`, or a fediverse handler without `fediverse.inbound`, fails right there with the same error the server gives at install). The check -runs even when a plugin ships no tests, and `package` runs it too — a plugin +runs even when a plugin ships no tests. `package` runs it too, and a plugin that would be rejected at install refuses to package. ```js @@ -1176,6 +1195,10 @@ Final-state `expect` (on the whole scenario): - `emits`, list of `{eventType, payload}` for custom events - `kv`, partial map of your plugin's key/value store after the scenario - `httpRequests`, outbound HTTP made by your plugin +- `userRegistrations`, list of `{authId, displayName?, scopes?, profileUrl?, + handle?, public?}` calls to `owncast.users.register` +- `uploads`, list of `{name, body?}` uploads. Use `bodyBase64` instead of + `body` to compare arbitrary bytes. ### Seeding state with `given` diff --git a/docs/WIRE_PROTOCOL.md b/docs/WIRE_PROTOCOL.md index 8816e20..f9ba5eb 100644 --- a/docs/WIRE_PROTOCOL.md +++ b/docs/WIRE_PROTOCOL.md @@ -1,10 +1,16 @@ # Owncast Plugin Wire Protocol -The contract between the Owncast host runtime and a plugin. This document is the source of truth that every language SDK (and the host implementation in the Owncast server repo) implements. +This is the author-facing contract between the Owncast host runtime and a +plugin. Owncast's stack-based host functions are the implementation source of +truth. ## Overview -At the wasm ABI a plugin is a module exposing a fixed set of well-known exports and importing a fixed set of host functions. For interpreted plugins (JavaScript, Python) that module is the host-embedded **shared engine** (one per language, compiled once and instantiated per plugin) running the plugin's source, which the host injects via Extism config at load. A plugin authored directly as a self-contained wasm module presents the same ABI itself. Either way the protocol below is identical. Communication is single-buffer in / single-buffer out: the host writes a JSON or text body before the call, the plugin reads it via the Extism `Host.input*()` helpers, and any return value is written via `Host.output*()`. +At the Wasm ABI a plugin exposes fixed exports and imports fixed host +functions. Interpreted JavaScript and Python plugins run inside a shared engine +that presents this ABI. A self-contained Wasm plugin presents it directly. +Exports use Extism's single input and output buffers. Host imports use the +stack signatures and memory pointers documented below. ## Exports (plugin → host) @@ -57,43 +63,80 @@ not prevent plugins from also responding. ## Imports (host → plugin) -Because all plugins of a language share one engine, the engine imports the **full** set of host functions and the host enforces **permissions at call time**: every host function resolves the calling plugin's identity (from a per-instance config value the host sets at load) and rejects the call (returning a zero/empty result and logging) when the plugin's manifest didn't grant the matching permission. The SDK wrappers still map one-to-one to these imports, so an author only ever calls the ones their permissions allow. (A self-contained wasm plugin that imports an ungranted host function instead fails to link at instantiation, the older structural enforcement.) +All custom imports use the `extism:host/user` namespace. The shared JavaScript +and Python engines import the full set. The host resolves the calling plugin +and checks its manifest permission on every call. A denied call logs the +denial and returns 0 for a `PTR` or `I64` result. A denied `void` call has no +observable result. + +### ABI types and pointer payloads + +The signatures below are the exact stack-based host ABI: + +- `I64` is a WebAssembly `i64` scalar. Boolean inputs and outputs use 0 for + false and 1 for true unless a function says otherwise. +- `PTR` is Extism's pointer value, carried in an `i64` stack slot. It identifies + one Extism-managed guest-memory allocation whose byte length Extism tracks. + It is not a null-terminated C pointer. +- A **UTF-8 string** pointer contains only the encoded string bytes. +- A **JSON** pointer contains one UTF-8 encoded JSON value of the named shape. +- A **raw bytes** pointer may contain arbitrary bytes and must not be decoded or + JSON-parsed by the host. +- `void` means no output stack value. `()` means no input stack values. + +A returned `PTR` identifies a host-written allocation in the calling plugin's +memory. A 0 return means no value or failure only where noted below. There are +no custom `I32` host imports. ### `chat.send` -- `owncast_send_chat(textPtr: PTR): void`, plugin's bot identity, regular message -- `owncast_send_chat_action(textPtr: PTR): void`, same identity, "/me" action style -- `owncast_send_chat_system(bodyPtr: PTR): void`, no user identity, body rendered as HTML -- `owncast_send_chat_to(clientId: I64, textPtr: PTR): void`, private DM to one client +- `owncast_send_chat(textPtr: PTR): void`. Input: `textPtr` is a UTF-8 string. + Output: none. Sends a regular message using the plugin's bot identity. +- `owncast_send_chat_action(textPtr: PTR): void`. Input: `textPtr` is a UTF-8 + string. Output: none. Sends a `/me`-style action using the bot identity. +- `owncast_send_chat_system(bodyPtr: PTR): void`. Input: `bodyPtr` is a UTF-8 + HTML string. Output: none. Sends a system message without a user identity. +- `owncast_send_chat_to(clientId: I64, textPtr: PTR): void`. Inputs: `clientId` + is the scalar chat client ID and `textPtr` is a UTF-8 string. Output: none. ### `chat.history` -- `owncast_chat_history(limit: I32): PTR`, returns JSON `ChatMessage[]` -- `owncast_chat_clients(): PTR`, returns JSON `ChatClient[]` +- `owncast_chat_history(limit: I64): PTR`. Input: non-positive `limit` values + select the host default of 50 rows; positive values request that row limit. + Output: JSON `ChatMessage[]`. +- `owncast_chat_clients(): PTR`. Input: none. Output: JSON `ChatClient[]`. ### `chat.moderate` -- `owncast_delete_message(idPtr: PTR): void` -- `owncast_kick_client(clientId: I64): void` +- `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. ### `storage.kv` -- `owncast_kv_get(keyPtr: PTR): PTR`, returns text or 0-offset on miss -- `owncast_kv_set(keyPtr: PTR, valPtr: PTR): void` +- `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. ### `storage.upload` -- `owncast_storage_upload(namePtr: PTR, dataPtr: PTR): PTR`, returns JSON `{url}` or 0-offset on failure +- `owncast_storage_upload(namePtr: PTR, dataPtr: PTR): PTR`. Inputs: `namePtr` + is a UTF-8 filename and `dataPtr` is raw bytes. Output: JSON + `{"url": string}`, or 0 on failure. ### `storage.fs` -Sandboxed per-plugin filesystem under `data/plugin-storage//files/`. The host confines every path to the plugin's own directory. +Sandboxed per-plugin filesystem under +`data/plugin-storage//files/`. The host confines every path to the +plugin's own directory. - `owncast_fs_read(pathPtr: PTR): PTR`, returns the file's raw bytes, or 0-offset when missing/unreadable - `owncast_fs_write(pathPtr: PTR, dataPtr: PTR): PTR`, returns JSON `FSResult` (`{error?}`). An empty object means success. -- `owncast_fs_list(dirPtr: PTR): PTR`, returns JSON `string[]` of entry names (missing dir → empty) +- `owncast_fs_list(dirPtr: PTR): PTR`, returns JSON `string[]` of direct entry names (missing dir → empty) - `owncast_fs_delete(pathPtr: PTR): PTR`, returns JSON `FSResult` (`{error?}`). An empty object means success. -- `owncast_fs_exists(pathPtr: PTR): I32`, returns 1 if the path exists, 0 otherwise +- `owncast_fs_exists(pathPtr: PTR): I64`, returns 1 if the path exists, 0 otherwise ### `storage.sql` @@ -185,114 +228,174 @@ plugin should store values above `Number.MAX_SAFE_INTEGER` (2^53 - 1) as TEXT. ### `events.emit` -- `owncast_emit_event(eventTypePtr: PTR, payloadPtr: PTR): void`, payload is a JSON-encoded value +- `owncast_emit_event(eventTypePtr: PTR, payloadPtr: PTR): void`. Inputs: + `eventTypePtr` is a UTF-8 event name and `payloadPtr` is one JSON value. + Output: none. ### `server.read` -- `owncast_stream_current(): PTR`, JSON `StreamInfo` -- `owncast_stream_broadcaster(): PTR`, JSON `StreamBroadcaster` (read-only inbound-feed telemetry) -- `owncast_server_info(): PTR`, JSON `ServerInfo` -- `owncast_server_socials(): PTR`, JSON `SocialHandle[]` -- `owncast_server_emotes(): PTR`, JSON `Emote[]` (custom chat emotes, `{name, url}`) -- `owncast_server_federation(): PTR`, JSON `FederationInfo` -- `owncast_server_tags(): PTR`, JSON `string[]` +- `owncast_stream_current(): PTR`. Input: none. Output: JSON `StreamInfo`. +- `owncast_stream_broadcaster(): PTR`. Input: none. Output: JSON + `StreamBroadcaster`. +- `owncast_server_info(): PTR`. Input: none. Output: JSON `ServerInfo`. +- `owncast_server_socials(): PTR`. Input: none. Output: JSON `SocialHandle[]`. +- `owncast_server_emotes(): PTR`. Input: none. Output: JSON `Emote[]`. +- `owncast_server_federation(): PTR`. Input: none. Output: JSON + `FederationInfo`. +- `owncast_server_tags(): PTR`. Input: none. Output: JSON `string[]`. ### `videoconfig.read` -- `owncast_video_config_read(): PTR`, JSON `VideoConfig` (`{latencyLevel, codec, variants}`) +- `owncast_video_config_read(): PTR`. Input: none. Output: JSON `VideoConfig` + with `latencyLevel`, `codec`, and `variants`. ### `videoconfig.write` -- `owncast_video_config_write(configPtr: PTR): PTR`, applies a partial `VideoConfigUpdate`. Returns JSON `VideoConfigWriteResult` (`{error?}`). An empty object means success. +- `owncast_video_config_write(configPtr: PTR): PTR`. Input: `configPtr` is JSON + partial `VideoConfigUpdate`. Output: JSON `VideoConfigWriteResult` + (`{error?}`). An empty object means success. ### `notifications.send` -- `owncast_notify_discord(textPtr: PTR): void` -- `owncast_notify_browser_push(payloadPtr: PTR): void`, JSON `BrowserPushPayload` -- `owncast_notify_fediverse(payloadPtr: PTR): void`, JSON `FediversePayload` +- `owncast_notify_discord(textPtr: PTR): void`. Input: `textPtr` is a UTF-8 + string. Output: none. +- `owncast_notify_browser_push(payloadPtr: PTR): void`. Input: `payloadPtr` is + JSON `BrowserPushPayload`. Output: none. +- `owncast_notify_fediverse(payloadPtr: PTR): void`. Input: `payloadPtr` is JSON + `FediversePayload`. Output: none. ### `users.read` -- `owncast_users_list(): PTR`, JSON `User[]` -- `owncast_user_get(idPtr: PTR): PTR`, JSON `User` or 0-offset on miss +- `owncast_users_list(): PTR`. Input: none. Output: JSON `User[]`. +- `owncast_user_get(idPtr: PTR): PTR`. Input: `idPtr` is a UTF-8 user ID. + Output: JSON `User`, or 0 when the user is missing. ### `users.moderate` -- `owncast_user_set_enabled(idPtr: PTR, enabled: I32, reasonPtr: PTR): void` -- `owncast_ban_ip(ipPtr: PTR): void` +- `owncast_user_set_enabled(idPtr: PTR, enabled: I64, reasonPtr: PTR): void`. + 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. ### `users.register` -Find-or-create an authenticated Owncast user for an external identity. Used by -a viewer-auth gate (see [`auth.gate`](#authgate)) to turn a provider login into -a real Owncast user before granting it a session. +Find or create an authenticated Owncast user for an external identity. A viewer +authentication gate uses this before granting a session. -- `owncast_users_register(reqPtr: PTR): PTR`, JSON `UserRegisterRequest` in, JSON `UserRegisterResult` out. The host namespaces the request's `authId` by the calling plugin's slug, so two plugins can't collide on or impersonate each other's users. The host restricts which `scopes` a plugin may assign and rejects administrative scopes, so an out-of-policy scope fails the call. +- `owncast_users_register(reqPtr: PTR): PTR`. Input: `reqPtr` is JSON + `UserRegisterRequest`. Output: JSON `UserRegisterResult`. The host supplies the + calling plugin's slug as the identity provider namespace and keeps `authId` + as the unmodified provider-specific ID. The host rejects administrative or + otherwise disallowed scopes. ### `auth.gate` -Grants a plugin the right to be the site's viewer-authentication gate: it -renders the login flow and names the authenticated user, and the host owns the -signed session cookie end to end (the plugin never sees the token). Only one -`auth.gate` plugin can be enabled at a time. Both functions are meaningful only -inside an `on_http_request` handler, where the host attaches or clears the -session cookie on the response after the call returns. +Only one `auth.gate` plugin can be enabled at a time. These calls are meaningful +inside `on_http_request`, where the host can attach or clear the signed session +cookie. The plugin never receives the token. The operator's host-owned access +mode is not part of the plugin wire protocol and a plugin cannot read or change +it. -The access boundary is not part of the plugin wire protocol. The operator -selects one cumulative, host-owned mode: website only, website and stream, or -website, stream, and status. A plugin cannot read or change that selection. +The operator selects one cumulative host mode: website only, website and +stream, or website, stream, and status. -- `owncast_auth_grant_session(reqPtr: PTR): PTR`, JSON `GrantSessionRequest` in, JSON `GrantSessionResult` (`{error?}`) out. An empty object means success. Mints a session for the named `userId` (which the same plugin must have registered via `users.register`) and attaches the signed cookie to the in-flight response. -- `owncast_auth_end_session(): void`, clears the session cookie on the in-flight response (logout). +- `owncast_auth_grant_session(reqPtr: PTR): PTR`. Input: `reqPtr` is JSON + `GrantSessionRequest`. Output: JSON `{"error"?: string}`. +- `owncast_auth_end_session(): void`. Input: none. Output: none. -The optional `on_auth_check` export (see [Exports](#exports-plugin--host)) lets -the gate re-validate a session on each `/` page load and return -`ok` / `refresh` / `deny`. +The optional `on_auth_check` export lets the gate revalidate a session on each +viewer page load and return `ok`, `refresh`, or `deny`. ### `fediverse.post` -- `owncast_fediverse_post(textPtr: PTR): PTR`, JSON `{url}` or 0-offset on failure +- `owncast_fediverse_post(textPtr: PTR): PTR`. Input: `textPtr` is a UTF-8 + string. Output: JSON `{"url": string}`, or 0 on failure. ### `network.fetch` -- Not a custom host function, grants the plugin access to Extism's built-in `Http.request`. The host configures Extism's `AllowedHosts` from the manifest's `network.allowedHosts` (see [Manifest extensions](#manifest-extensions) below). Manifests granting `network.fetch` without `network.allowedHosts` are rejected at load. +This permission grants access to Extism's built-in `Http.request`. It does not +add a custom host import. The host configures `AllowedHosts` from +`manifest.network.allowedHosts`. A manifest that grants `network.fetch` without +an allowed-host list is rejected at load. + +The wildcard `"*"` is allowed only when the manifest states it explicitly. ### `http.serve` -- Not a host function. Grants the host's HTTP server permission to route `/plugins//*` requests to this plugin's `on_http_request` export and to serve static files from its `public/` directory. The plugin's separate `assets/` directory is read by the host for manifest fields that inline content (`styles`, `scripts`, `extraPageContent`) and is never reachable through the plugin's URL space. +This permission does not add a custom host import. It lets the host route +`/plugins//*` requests to `on_http_request` and serve the plugin's +`public/` directory. + +The separate `assets/` directory is read by the host for manifest content and +is not served from the plugin's URL space. ### `http.sse` -- `owncast_sse_send(channelPtr: PTR, eventPtr: PTR, dataPtr: PTR): void`, push one Server-Sent-Events message to every browser connected to `(this plugin, channel)`. `channel` and `event` are plain strings. `data` is the message body (the SDK JSON-encodes non-string values). Fire-and-forget: the call returns immediately and never blocks on a slow or absent client. -- Grants the host permission to serve the reserved `/plugins//_sse/` endpoint (see [Host-reserved endpoints](#host-reserved-endpoints)). Independent of `http.serve`: a plugin may stream events without serving any other routes. +- `owncast_sse_send(channelPtr: PTR, eventPtr: PTR, dataPtr: PTR): void`. + Inputs: all three pointers contain UTF-8 strings. `channelPtr` names the + stream, `eventPtr` names the SSE event, and `dataPtr` contains its text data. + Output: none. The call queues one event for every browser connected to the + plugin and channel. + The call returns after queueing the frame and does not wait for browsers to + consume it. + +The permission also lets the host serve the reserved +`/plugins//_sse/` endpoint. It is independent of `http.serve`. ### `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. -- `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. +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`. ### `chat.filter` -- Not a custom host function. Gates the `filter_chat_message` export: a plugin that registers a `filterChatMessage` handler must declare this permission at load time, otherwise the host rejects the manifest. -- This is deliberately separate from `chat.send`, `chat.history`, and `chat.moderate`: filtering happens inline on every chat message before broadcast (modify the body, drop the message, or pass it through), so the manifest reviewer needs to see it called out explicitly. +This permission gates the `filter_chat_message` export. A plugin that registers +a `filterChatMessage` handler without it is rejected at load. It is separate +from chat sending, history, and moderation because filtering runs inline before +broadcast. + +A filter can modify the message body, drop the message, or pass it through. ### `fediverse.inbound` -- Not a custom host function. Gates notify subscriptions to `fediverse.activity`, `fediverse.follow`, `fediverse.like`, `fediverse.repost`, `fediverse.quote`, `fediverse.mention`, and `fediverse.reply`. If `register` reports any of these subscriptions, the plugin manifest must declare this permission or the host rejects the plugin at load time. -- `fediverse.activity` carries the verified inbound activity's raw JSON object after HTTP signature and actor-origin checks. It is dispatched in addition to any matching specialized event and is also dispatched for verified activity types with no specialized event. -- `fediverse.quote` carries `{actor, target}`, where `target` is the locally authored post that the remote actor quoted. `fediverse.mention` and `fediverse.reply` are limited to verified public `Create(Note)` activities that mention the local account or reply to a locally authored post. -- These are internal plugin notify events, not external HTTP webhooks. `fediverse.post` is separate and permits outbound posts under the streamer's Fediverse identity. +This permission gates notify subscriptions to `fediverse.activity`, +`fediverse.follow`, `fediverse.like`, `fediverse.repost`, `fediverse.quote`, +`fediverse.mention`, and `fediverse.reply`. These are internal plugin events, +not external HTTP webhooks. -### ambient (no permission) +`fediverse.activity` carries the verified inbound activity as a raw JSON +object. It fires alongside a matching specialized event and also covers +verified activity types without a specialized event. `fediverse.quote` carries +`{actor, target}`, where `target` is the quoted local post. Mentions and replies +are verified public `Create(Note)` activities tied to the local account or a +locally authored post. -These imports are granted to every plugin without a declared permission. A plugin can't `setTimeout` or read its own config without the host, and the acts themselves are benign (a scheduled callback still needs its own permissions to do anything, and reading your own manifest-declared config exposes nothing new). +### ambient (no permission) -- `owncast_timer_set(id: I64, delayMs: I64, repeat: I32): I32`, schedule a host-driven timer. The host fires the `timer.fire` event (payload `{id}`) when it elapses. Returns 1 on success, 0 if the plugin is at its pending-timer cap. `delayMs` is clamped to `[100, 86_400_000]`. The SDK maps `id`→callback for `owncast.timer.setTimeout/setInterval`. -- `owncast_timer_clear(id: I64): void`, cancel a pending timer by id. -- `owncast_config_get(keyPtr: PTR): PTR`, returns the JSON value of a `manifest.config` key (admin override, else declared default), or 0-offset for an unknown/unset key. -- `owncast_asset_read(pathPtr: PTR): PTR`, returns the raw bytes of a file from the plugin's own `assets/` directory, or 0-offset when the file is missing or the path escapes the directory. The path is relative to `assets/` and must not start with `/` or contain `..` segments. The host rejects any path that would escape the plugin's own asset tree. Plugins use this to load bundled resources (templates, data files) at request time without needing `storage.fs`. +These imports are available to every plugin: + +- `owncast_timer_set(id: I64, delayMs: I64, repeat: I64): I64`. Inputs: `id` + and `delayMs` are scalar integers. The host clamps `delayMs` to + `[100, 86_400_000]`. `repeat` is 1 for an interval and any other value for a + one-shot timer. Output: scalar 1 on success and 0 at the pending-timer cap. +- `owncast_timer_clear(id: I64): void`. Input: `id` is a scalar timer ID. + Output: none. +- `owncast_config_get(keyPtr: PTR): PTR`. Input: `keyPtr` is a UTF-8 manifest + config key. Output: one JSON value, or 0 for an unknown or unset key. +- `owncast_asset_read(pathPtr: PTR): PTR`. Input: `pathPtr` is a UTF-8 path + relative to the plugin's `assets/` directory. Output: raw file bytes, or 0 + when the path is missing, invalid, or unreadable. - `owncast_log_info(messagePtr: PTR): void`, write an info entry to the Owncast server log - `owncast_log_warning(messagePtr: PTR): void`, write a warning entry to the Owncast server log - `owncast_log_error(messagePtr: PTR): void`, write an error entry to the Owncast server log @@ -398,13 +501,13 @@ Per-entry validation: - `http://` and `https://` URLs are rejected at load. - Each entry must end in `.css`. -Each plugin contribution in the concatenated response is preceded by a `/* plugin: */\n` comment so devtools "view source" can attribute a rule back to whichever plugin shipped it. Disabling the plugin drops its contribution on the next `/api/config` request. +Each plugin contribution in the concatenated response is preceded by a comment that identifies the plugin slug and file, so a reader can attribute a rule to its source. Disabling the plugin drops its contribution on the next `/api/config` request. ### `manifest.scripts[]` An array of relative paths to JavaScript files the plugin contributes to the viewer page. The host reads each file's bytes from the plugin's `assets/` directory and appends them to the response served at `/customjavascript`, so a viewer loads one `