@owncast/plugin-sdk 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,19 +2,20 @@
2
2
 
3
3
  SDK for authoring [Owncast](https://owncast.online) plugins in JavaScript or TypeScript. Plugins compile to WebAssembly and run sandboxed inside the Owncast server.
4
4
 
5
- Most authors don't install this directly instead, scaffold a new project with `npm create owncast-plugin <name>` and the generated `package.json` already lists it as a dependency.
5
+ Most authors don't install this directly, instead, scaffold a new project with `npx create-owncast-plugin@latest <slug>` and the generated `package.json` already lists it as a dependency.
6
6
 
7
7
  ## Quick start
8
8
 
9
9
  ```sh
10
- npm create owncast-plugin my-plugin
10
+ npx create-owncast-plugin@latest my-plugin
11
11
  cd my-plugin
12
- npm install # postinstall fetches the per-platform wasm toolchain
13
- npm run build # bundles src/plugin.js my-plugin.wasm + my-plugin.ocpkg
14
- npm test # runs scenarios from __tests__/
12
+ npm install # postinstall fetches the per-platform wasm toolchain
13
+ npm run build # compiles src/plugin.js into an intermediate build artifact
14
+ npm run package # zips manifest + wasm + assets + icon.png into my-plugin.ocpkg
15
+ npm test # runs scenarios from __tests__/
15
16
  ```
16
17
 
17
- Then drop `my-plugin.ocpkg` into your Owncast server's `plugins/` directory and enable it from the admin.
18
+ Then install `my-plugin.ocpkg` in Owncast. From the admin's **Plugins** page click **Upload plugin** and pick the file, or copy it directly to the server's `data/plugins/` directory. Toggle **Enabled** on the plugin's row to load it.
18
19
 
19
20
  ## Writing a plugin
20
21
 
@@ -38,10 +39,11 @@ Declare the permissions your plugin uses (`chat.send` for the example above) in
38
39
 
39
40
  ## What's in the package
40
41
 
41
- - `index.js` runtime: `definePlugin`, the `owncast.*` host wrappers, the `filter` constructor.
42
- - `index.d.ts` TypeScript declarations for editor autocomplete on every event payload and host API.
43
- - `bin/owncast-plugin` CLI: `build`, `test`, `serve`, `package` subcommands.
44
- - `scripts/postinstall.js` — downloads the per-platform wasm toolchain (`extism-js`, `wasm-merge`, `wasm-opt`) and the Go test/serve runner on install.
42
+ - `index.js`, runtime: `definePlugin`, the `owncast.*` host wrappers, the `filter` constructor.
43
+ - `index.d.ts`, TypeScript declarations for editor autocomplete on every event payload and host API.
44
+ - `testing.js`, JS test API (`runScenarios`) for writing `__tests__/*.test.js` with the full ergonomics of JavaScript instead of static JSON.
45
+ - `bin/owncast-plugin`, CLI: `build`, `test`, `serve`, `package` subcommands.
46
+ - `scripts/postinstall.js`, downloads the per-platform wasm toolchain (`extism-js`, `wasm-merge`, `wasm-opt`) and the Go test/serve runner on install.
45
47
 
46
48
  ## License
47
49
 
@@ -1,9 +1,15 @@
1
1
  #!/usr/bin/env node
2
- // `owncast-plugin build` bundle src/plugin.{js,ts} into <name>.wasm
3
- // `owncast-plugin test` run scenarios in __tests__/ against the wasm
4
- // `owncast-plugin serve` run a localhost dev HTTP server
5
- // `owncast-plugin package` produce a single-file <name>.ocpkg suitable
2
+ // `owncast-plugin build` , bundle src/plugin.{js,ts} into <slug>.wasm
3
+ // `owncast-plugin test` , run scenarios in __tests__/ against the wasm
4
+ // `owncast-plugin serve` , run a localhost dev HTTP server
5
+ // `owncast-plugin package`, produce a single-file <slug>.ocpkg suitable
6
6
  // for distribution / installation
7
+ //
8
+ // "Slug" is the plugin's identifier: lowercase, hyphenated, used in
9
+ // filenames, URL segments, and as the registry's primary key. Plugin
10
+ // authors set the human-readable display name via `name` in their
11
+ // manifest; if they don't set `slug`, the CLI auto-derives it from
12
+ // `name`.
7
13
 
8
14
  const fs = require("fs");
9
15
  const path = require("path");
@@ -14,24 +20,73 @@ const JSZip = require("jszip");
14
20
  const cmd = process.argv[2] || "build";
15
21
  const restArgs = process.argv.slice(3);
16
22
 
17
- if (cmd === "build") {
18
- buildMain().catch(fail);
19
- } else if (cmd === "test") {
20
- testMain(restArgs);
21
- } else if (cmd === "serve") {
22
- serveMain(restArgs);
23
- } else if (cmd === "package") {
24
- packageMain().catch(fail);
25
- } else {
26
- console.error(`unknown command: ${cmd}\nusage: owncast-plugin <build|test|serve|package>`);
27
- process.exit(1);
28
- }
29
-
30
23
  function fail(e) {
31
24
  console.error(`${cmd} failed: ${e.message}`);
32
25
  process.exit(1);
33
26
  }
34
27
 
28
+ // slugPattern matches a valid plugin slug: a lowercase letter
29
+ // followed by lowercase letters/digits/hyphens, up to 64 chars total.
30
+ // Same shape the host + SDK + registry all validate against.
31
+ const slugPattern = /^[a-z][a-z0-9-]{0,63}$/;
32
+
33
+ // slugify mirrors the host's Go slugify: ASCII letters and digits
34
+ // pass through lowercased; everything else collapses to a single
35
+ // hyphen; leading and trailing hyphens are trimmed.
36
+ // Non-ASCII names (e.g. "Café") degrade noisily (-> "caf"); plugins
37
+ // with accented or non-Latin display names should pin `slug` in the
38
+ // manifest instead of relying on auto-derivation.
39
+ function slugify(input) {
40
+ let out = "";
41
+ let prevHyphen = false;
42
+ for (const ch of input) {
43
+ const code = ch.codePointAt(0);
44
+ let lower = ch;
45
+ if (code >= 65 && code <= 90) lower = String.fromCodePoint(code + 32);
46
+ const lc = lower.codePointAt(0);
47
+ if ((lc >= 97 && lc <= 122) || (lc >= 48 && lc <= 57)) {
48
+ out += lower;
49
+ prevHyphen = false;
50
+ } else if (!prevHyphen && out.length > 0) {
51
+ out += "-";
52
+ prevHyphen = true;
53
+ }
54
+ }
55
+ return out.replace(/-+$/, "");
56
+ }
57
+
58
+ // readAndResolveManifest loads plugin.manifest.json, validates the
59
+ // required fields, and returns a manifest object with `slug` filled
60
+ // in: either the author's explicit `slug`, or one auto-derived from
61
+ // `name`. The returned object is what gets baked into MANIFEST_BASE
62
+ // in the build's synthesized entry, so register() always emits both
63
+ // name (display) and slug (identifier).
64
+ function readAndResolveManifest(manifestPath) {
65
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
66
+ if (!manifest.name || typeof manifest.name !== "string") {
67
+ throw new Error("manifest.name is required");
68
+ }
69
+ if (!manifest.version || typeof manifest.version !== "string") {
70
+ throw new Error("manifest.version is required");
71
+ }
72
+ let slug = manifest.slug;
73
+ if (!slug) {
74
+ slug = slugify(manifest.name);
75
+ if (!slug) {
76
+ throw new Error(
77
+ `could not derive a slug from manifest.name ${JSON.stringify(manifest.name)}; set manifest.slug explicitly`,
78
+ );
79
+ }
80
+ }
81
+ if (!slugPattern.test(slug)) {
82
+ throw new Error(
83
+ `manifest.slug ${JSON.stringify(slug)} must match ${slugPattern} (lowercase letters/digits/hyphens, starting with a letter, max 64 chars)`,
84
+ );
85
+ }
86
+ manifest.slug = slug;
87
+ return manifest;
88
+ }
89
+
35
90
  function testMain(args) {
36
91
  runBinary("owncast-plugin-test", args);
37
92
  }
@@ -46,17 +101,20 @@ function runBinary(name, args) {
46
101
  if (!fs.existsSync(bin)) {
47
102
  console.error(
48
103
  `${name} not found at ${bin}\n` +
49
- `In production this is fetched by the SDK postinstall. For the PoC, ` +
50
- `build it via: cd owncast && go build -o tools/${name} ./cmd/${name}`
104
+ `In production this is fetched by the SDK postinstall. For the PoC, ` +
105
+ `build it via: cd owncast && go build -o tools/${name} ./cmd/${name}`,
51
106
  );
52
107
  process.exit(1);
53
108
  }
54
109
  const env = {
55
110
  ...process.env,
56
- LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`
111
+ LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`,
57
112
  };
58
113
  try {
59
- execFileSync(bin, args.length > 0 ? args : [process.cwd()], { stdio: "inherit", env });
114
+ execFileSync(bin, args.length > 0 ? args : [process.cwd()], {
115
+ stdio: "inherit",
116
+ env,
117
+ });
60
118
  } catch (e) {
61
119
  process.exit(typeof e.status === "number" ? e.status : 1);
62
120
  }
@@ -68,20 +126,27 @@ async function buildMain() {
68
126
  if (!fs.existsSync(manifestPath)) {
69
127
  throw new Error("plugin.manifest.json not found in current directory");
70
128
  }
71
- const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
72
- const name = manifest.name;
73
- if (!name) throw new Error("manifest.name is required");
129
+ const manifest = readAndResolveManifest(manifestPath);
130
+ const slug = manifest.slug;
74
131
 
75
132
  // Detect entry point.
76
133
  let entry = null;
77
- for (const candidate of ["src/plugin.ts", "src/plugin.js", "plugin.ts", "plugin.js"]) {
134
+ for (const candidate of [
135
+ "src/plugin.ts",
136
+ "src/plugin.js",
137
+ "plugin.ts",
138
+ "plugin.js",
139
+ ]) {
78
140
  const p = path.join(cwd, candidate);
79
141
  if (fs.existsSync(p)) {
80
142
  entry = p;
81
143
  break;
82
144
  }
83
145
  }
84
- if (!entry) throw new Error("no plugin source found (expected src/plugin.ts or plugin.js)");
146
+ if (!entry)
147
+ throw new Error(
148
+ "no plugin source found (expected src/plugin.ts or plugin.js)",
149
+ );
85
150
 
86
151
  // Synthesize an entry that injects the manifest, requires user code,
87
152
  // then re-exports the SDK runtime exports as wasm-visible exports.
@@ -132,7 +197,7 @@ module.exports = { register, on_event, on_filter, on_http_request };
132
197
  platform: "neutral",
133
198
  target: "es2020",
134
199
  outfile: bundledJS,
135
- logLevel: "warning"
200
+ logLevel: "warning",
136
201
  });
137
202
 
138
203
  // Generate index.d.ts declaring exports + host imports based on permissions.
@@ -143,16 +208,21 @@ module.exports = { register, on_event, on_filter, on_http_request };
143
208
  const cache = findCacheDir();
144
209
  const extismJs = path.join(cache, "extism-js");
145
210
  if (!fs.existsSync(extismJs)) {
146
- throw new Error(`extism-js not found at ${extismJs} — run \`npm install\` to fetch the toolchain`);
211
+ throw new Error(
212
+ `extism-js not found at ${extismJs}, run \`npm install\` to fetch the toolchain`,
213
+ );
147
214
  }
148
215
  const env = {
149
216
  ...process.env,
150
217
  PATH: `${cache}:${process.env.PATH}`,
151
- LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`
218
+ LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`,
152
219
  };
153
220
 
154
- const wasmOut = path.join(cwd, `${name}.wasm`);
155
- execFileSync(extismJs, [bundledJS, "-i", dts, "-o", wasmOut], { stdio: "inherit", env });
221
+ const wasmOut = path.join(cwd, `${slug}.wasm`);
222
+ execFileSync(extismJs, [bundledJS, "-i", dts, "-o", wasmOut], {
223
+ stdio: "inherit",
224
+ env,
225
+ });
156
226
 
157
227
  // If the project ships static assets in ./assets/, mirror them to the
158
228
  // canonical deployment layout (<name>-assets/) so plugin.Server finds them
@@ -160,17 +230,30 @@ module.exports = { register, on_event, on_filter, on_http_request };
160
230
  // show up live during dev (no rebuild needed for HTML/CSS changes).
161
231
  const assetsSrc = path.join(cwd, "assets");
162
232
  if (fs.existsSync(assetsSrc) && fs.statSync(assetsSrc).isDirectory()) {
163
- const assetsDest = path.join(cwd, `${name}-assets`);
233
+ const assetsDest = path.join(cwd, `${slug}-assets`);
164
234
  let needsLink = true;
165
- if (fs.existsSync(assetsDest)) {
166
- try {
167
- const st = fs.lstatSync(assetsDest);
168
- if (st.isSymbolicLink() && fs.realpathSync(assetsDest) === fs.realpathSync(assetsSrc)) {
169
- needsLink = false;
170
- } else {
171
- fs.rmSync(assetsDest, { recursive: true, force: true });
172
- }
173
- } catch {
235
+ // Use lstatSync (not existsSync), existsSync follows symlinks and
236
+ // returns false for a dangling link, but the link's inode is still
237
+ // there and would make symlinkSync below fail with EEXIST. lstatSync
238
+ // sees the link itself regardless of whether its target resolves.
239
+ let st;
240
+ try {
241
+ st = fs.lstatSync(assetsDest);
242
+ } catch {
243
+ // path doesn't exist at all, fall through to create it.
244
+ }
245
+ if (st) {
246
+ let target;
247
+ if (st.isSymbolicLink()) {
248
+ // realpathSync throws on dangling links; treat that as "doesn't
249
+ // match, replace it" rather than letting it abort the build.
250
+ try {
251
+ target = fs.realpathSync(assetsDest);
252
+ } catch {}
253
+ }
254
+ if (target && target === fs.realpathSync(assetsSrc)) {
255
+ needsLink = false;
256
+ } else {
174
257
  fs.rmSync(assetsDest, { recursive: true, force: true });
175
258
  }
176
259
  }
@@ -182,7 +265,7 @@ module.exports = { register, on_event, on_filter, on_http_request };
182
265
  console.log(`built ${path.relative(cwd, wasmOut)}`);
183
266
  }
184
267
 
185
- // `owncast-plugin package` bundle the project into a single .ocpkg file
268
+ // `owncast-plugin package`, bundle the project into a single .ocpkg file
186
269
  // (zip archive with plugin.manifest.json, plugin.wasm, and optional assets/).
187
270
  // Builds the wasm first if it doesn't exist.
188
271
  async function packageMain() {
@@ -191,11 +274,10 @@ async function packageMain() {
191
274
  if (!fs.existsSync(manifestPath)) {
192
275
  throw new Error("plugin.manifest.json not found in current directory");
193
276
  }
194
- const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
195
- const name = manifest.name;
196
- if (!name) throw new Error("manifest.name is required");
277
+ const manifest = readAndResolveManifest(manifestPath);
278
+ const slug = manifest.slug;
197
279
 
198
- const wasmPath = path.join(cwd, `${name}.wasm`);
280
+ const wasmPath = path.join(cwd, `${slug}.wasm`);
199
281
  if (!fs.existsSync(wasmPath)) {
200
282
  await buildMain();
201
283
  }
@@ -205,6 +287,15 @@ async function packageMain() {
205
287
  zip.file("plugin.manifest.json", fs.readFileSync(manifestPath));
206
288
  zip.file("plugin.wasm", fs.readFileSync(wasmPath));
207
289
  let fileCount = 2;
290
+ // Bundle a top-level icon.png if the plugin source root has one.
291
+ // The host reads it from /api/plugins/<slug>/icon to render in the
292
+ // admin list and sidebar (no manifest field, no http.serve
293
+ // permission required).
294
+ const iconPath = path.join(cwd, "icon.png");
295
+ if (fs.existsSync(iconPath) && fs.statSync(iconPath).isFile()) {
296
+ zip.file("icon.png", fs.readFileSync(iconPath));
297
+ fileCount++;
298
+ }
208
299
  if (fs.existsSync(assetsDir) && fs.statSync(assetsDir).isDirectory()) {
209
300
  for (const file of walkFiles(assetsDir)) {
210
301
  const rel = path.relative(assetsDir, file).split(path.sep).join("/");
@@ -213,15 +304,17 @@ async function packageMain() {
213
304
  }
214
305
  }
215
306
 
216
- const outPath = path.join(cwd, `${name}.ocpkg`);
307
+ const outPath = path.join(cwd, `${slug}.ocpkg`);
217
308
  const buf = await zip.generateAsync({
218
309
  type: "nodebuffer",
219
310
  compression: "DEFLATE",
220
- compressionOptions: { level: 6 }
311
+ compressionOptions: { level: 6 },
221
312
  });
222
313
  fs.writeFileSync(outPath, buf);
223
314
  const sizeKb = Math.round(fs.statSync(outPath).size / 1024);
224
- console.log(`packaged ${path.relative(cwd, outPath)} (${sizeKb} KB, ${fileCount} files)`);
315
+ console.log(
316
+ `packaged ${path.relative(cwd, outPath)} (${sizeKb} KB, ${fileCount} files)`,
317
+ );
225
318
  }
226
319
 
227
320
  function* walkFiles(dir) {
@@ -248,7 +341,7 @@ function generateInterface(manifest) {
248
341
  "register(): I32",
249
342
  "on_event(): I32",
250
343
  "on_filter(): I32",
251
- "on_http_request(): I32"
344
+ "on_http_request(): I32",
252
345
  ];
253
346
 
254
347
  const perms = new Set(manifest.permissions || []);
@@ -277,7 +370,9 @@ function generateInterface(manifest) {
277
370
  imports.push("owncast_user_get(idPtr: PTR): PTR");
278
371
  }
279
372
  if (perms.has("users.moderate")) {
280
- imports.push("owncast_user_set_enabled(idPtr: PTR, enabled: I32, reasonPtr: PTR): void");
373
+ imports.push(
374
+ "owncast_user_set_enabled(idPtr: PTR, enabled: I32, reasonPtr: PTR): void",
375
+ );
281
376
  imports.push("owncast_ban_ip(ipPtr: PTR): void");
282
377
  }
283
378
  if (perms.has("storage.upload")) {
@@ -290,8 +385,14 @@ function generateInterface(manifest) {
290
385
  imports.push("owncast_kv_get(keyPtr: PTR): PTR");
291
386
  imports.push("owncast_kv_set(keyPtr: PTR, valPtr: PTR): void");
292
387
  }
293
- if (perms.has("events.emit")) imports.push("owncast_emit_event(eventTypePtr: PTR, payloadPtr: PTR): void");
294
- if (perms.has("http.sse")) imports.push("owncast_sse_send(channelPtr: PTR, eventPtr: PTR, dataPtr: PTR): void");
388
+ if (perms.has("events.emit"))
389
+ imports.push(
390
+ "owncast_emit_event(eventTypePtr: PTR, payloadPtr: PTR): void",
391
+ );
392
+ if (perms.has("http.sse"))
393
+ imports.push(
394
+ "owncast_sse_send(channelPtr: PTR, eventPtr: PTR, dataPtr: PTR): void",
395
+ );
295
396
  if (perms.has("server.read")) {
296
397
  imports.push("owncast_stream_current(): PTR");
297
398
  imports.push("owncast_server_info(): PTR");
@@ -306,6 +407,10 @@ function generateInterface(manifest) {
306
407
  if (perms.has("videoconfig.write")) {
307
408
  imports.push("owncast_video_config_write(configPtr: PTR): PTR");
308
409
  }
410
+ if (perms.has("ui.modify")) {
411
+ imports.push("owncast_add_actions(actionsPtr: PTR): void");
412
+ imports.push("owncast_clear_actions(): void");
413
+ }
309
414
 
310
415
  let out = `declare module 'main' {\n`;
311
416
  for (const e of exports) out += ` export function ${e};\n`;
@@ -326,9 +431,9 @@ function findCacheDir() {
326
431
  const candidates = [
327
432
  path.join(__dirname, ".cache"),
328
433
  path.join(__dirname, "..", "bin", ".cache"),
329
- path.join(__dirname, "..", "..", "..", "tools")
434
+ path.join(__dirname, "..", "..", "..", "tools"),
330
435
  ];
331
- // Pick the first candidate that has any of the expected tools different
436
+ // Pick the first candidate that has any of the expected tools, different
332
437
  // commands need different binaries (build needs extism-js, test needs
333
438
  // owncast-plugin-test) but they share a cache.
334
439
  for (const c of candidates) {
@@ -343,3 +448,22 @@ function findCacheDir() {
343
448
  return candidates[0];
344
449
  }
345
450
 
451
+ // Dispatch sits at the bottom so every const + function above is
452
+ // fully initialized before any handler runs. Calling a handler from
453
+ // the top of the file would put the top-level `const slugPattern`
454
+ // (and friends) in the TDZ for the first synchronous slice of
455
+ // buildMain/packageMain.
456
+ if (cmd === "build") {
457
+ buildMain().catch(fail);
458
+ } else if (cmd === "test") {
459
+ testMain(restArgs);
460
+ } else if (cmd === "serve") {
461
+ serveMain(restArgs);
462
+ } else if (cmd === "package") {
463
+ packageMain().catch(fail);
464
+ } else {
465
+ console.error(
466
+ `unknown command: ${cmd}\nusage: owncast-plugin <build|test|serve|package>`,
467
+ );
468
+ process.exit(1);
469
+ }
package/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export interface ChatMessage {
6
6
  timestamp: string;
7
7
  }
8
8
 
9
- /** A chat user payload of join/part/rename events. */
9
+ /** A chat user, payload of join/part/rename events. */
10
10
  export interface ChatUser {
11
11
  id: string;
12
12
  displayName: string;
@@ -15,13 +15,13 @@ export interface ChatUser {
15
15
  scopes?: string[];
16
16
  }
17
17
 
18
- /** Payload of `chat.user.renamed` the same user changing their name. */
18
+ /** Payload of `chat.user.renamed`, the same user changing their name. */
19
19
  export interface ChatUserRename {
20
20
  user: ChatUser;
21
21
  previousName: string;
22
22
  }
23
23
 
24
- /** Payload of `chat.message.moderated` a message hidden/restored by a mod. */
24
+ /** Payload of `chat.message.moderated`, a message hidden/restored by a mod. */
25
25
  export interface ChatMessageModeration {
26
26
  messageId: string;
27
27
  visible: boolean;
@@ -30,8 +30,8 @@ export interface ChatMessageModeration {
30
30
 
31
31
  /** Stream-lifecycle payloads. */
32
32
  export interface StreamLifecycleEvent {
33
- startedAt?: string; // ISO-8601, set for stream.started
34
- stoppedAt?: string; // ISO-8601, set for stream.stopped
33
+ startedAt?: string; // ISO-8601, set for stream.started
34
+ stoppedAt?: string; // ISO-8601, set for stream.stopped
35
35
  title?: string;
36
36
  summary?: string;
37
37
  }
@@ -125,7 +125,7 @@ export const Events: {
125
125
  /** Payload shape for fediverse engagement events. */
126
126
  export interface FediverseActor {
127
127
  name: string;
128
- handle: string; // e.g. "@alice@fediverse.example"
128
+ handle: string; // e.g. "@alice@fediverse.example"
129
129
  url?: string;
130
130
  image?: string;
131
131
  }
@@ -136,16 +136,16 @@ export interface FediverseEngagement {
136
136
  target?: { url: string };
137
137
  }
138
138
 
139
- /** Inbound fediverse post a mention or reply that contains content the
139
+ /** Inbound fediverse post, a mention or reply that contains content the
140
140
  * plugin can act on. Carries both the rendered content (which has the
141
141
  * source instance's HTML) and a plain-text version (HTML stripped). */
142
142
  export interface FediverseInboundPost {
143
143
  actor: FediverseActor;
144
- content: string; // HTML from the source instance
145
- contentText: string; // HTML stripped to plain text
146
- url: string; // permalink to the post on its source
147
- postedAt: string; // ISO-8601
148
- inReplyTo?: string; // parent post URL, when this is a reply
144
+ content: string; // HTML from the source instance
145
+ contentText: string; // HTML stripped to plain text
146
+ url: string; // permalink to the post on its source
147
+ postedAt: string; // ISO-8601
148
+ inReplyTo?: string; // parent post URL, when this is a reply
149
149
  attachments?: {
150
150
  url: string;
151
151
  mediaType: string;
@@ -158,6 +158,7 @@ export const Permissions: {
158
158
  readonly ChatSend: "chat.send";
159
159
  readonly ChatHistory: "chat.history";
160
160
  readonly ChatModerate: "chat.moderate";
161
+ readonly ChatFilter: "chat.filter";
161
162
  readonly StorageKV: "storage.kv";
162
163
  readonly StorageUpload: "storage.upload";
163
164
  readonly EventsEmit: "events.emit";
@@ -171,6 +172,7 @@ export const Permissions: {
171
172
  readonly HttpSSE: "http.sse";
172
173
  readonly VideoConfigRead: "videoconfig.read";
173
174
  readonly VideoConfigWrite: "videoconfig.write";
175
+ readonly UIModify: "ui.modify";
174
176
  };
175
177
 
176
178
  export interface BrowserPushPayload {
@@ -204,7 +206,7 @@ export interface User {
204
206
  displayName: string;
205
207
  previousNames?: string[];
206
208
  createdAt?: string;
207
- disabledAt?: string; // ISO-8601 if banned, omitted otherwise
209
+ disabledAt?: string; // ISO-8601 if banned, omitted otherwise
208
210
  scopes?: string[];
209
211
  isBot?: boolean;
210
212
  isAuthenticated?: boolean;
@@ -290,12 +292,12 @@ export interface PluginDef {
290
292
  onFediverseReply?(post: FediverseInboundPost): void | Promise<void>;
291
293
 
292
294
  /** HTTP request handler. Called for any path under /plugins/<name>/ that
293
- * isn't served as a static asset. Default-public gate admin features
295
+ * isn't served as a static asset. Default-public, gate admin features
294
296
  * on `req.authenticated` yourself. Requires `http.serve` permission. */
295
297
  onHttpRequest?(req: IncomingHttpRequest): OutgoingHttpResponse;
296
298
 
297
299
  /** Handlers for plugin-emitted custom events. The key is the event type
298
- * string (e.g. "announcement.broadcast"). Notifications only to filter
300
+ * string (e.g. "announcement.broadcast"). Notifications only, to filter
299
301
  * custom events, additional API will be needed. */
300
302
  on?: { [eventType: string]: (payload: any) => void | Promise<void> };
301
303
 
@@ -314,7 +316,7 @@ export const owncast: {
314
316
  send(text: string): void;
315
317
  /** Same identity, but in action style (italic, like IRC "/me"). */
316
318
  sendAction(text: string): void;
317
- /** Post a system message no user identity, rendered as a server
319
+ /** Post a system message, no user identity, rendered as a server
318
320
  * announcement. The body is rendered as HTML, so the plugin is
319
321
  * responsible for escaping any untrusted content. Same `chat.send`
320
322
  * permission as the other send variants. */
@@ -348,11 +350,13 @@ export const owncast: {
348
350
  upload(name: string, data: Uint8Array | string): UploadResult | null;
349
351
  };
350
352
  /** Post to the fediverse on the streamer's behalf. Requires `fediverse.post`,
351
- * which is high-trust admins should grant it sparingly. The host
352
- * rate-limits at ~5 posts/hour per plugin. */
353
+ * which is high-trust (posts go out under the streamer's own handle);
354
+ * admins should grant it sparingly. */
353
355
  fediverse: {
354
- /** Publish a public, text-only post. Returns { url } on success or null
355
- * on rate-limit / disabled / other failure. */
356
+ /** Publish a public, text-only post. Returns `{ url }` (currently empty
357
+ * on success: Owncast publishes the note but doesn't yet round-trip its
358
+ * URL), or `null` when the host rejects the call (disabled, missing
359
+ * permission, etc.). */
356
360
  post(text: string): { url: string } | null;
357
361
  };
358
362
  /** Send notifications via Owncast's configured channels.
@@ -372,6 +376,22 @@ export const owncast: {
372
376
  events: {
373
377
  emit(eventType: string, payload: unknown): void;
374
378
  };
379
+ /** Control over the viewer action buttons this plugin contributes.
380
+ * The effective list shown to viewers is `manifest.actions` ++
381
+ * whatever has been added at runtime via `add`. Requires
382
+ * `ui.modify`. */
383
+ actions: {
384
+ /** Append one or more buttons to the plugin's runtime list. Each
385
+ * entry is validated with the same rules as `manifest.actions`
386
+ * (title required; exactly one of `url` or `html`; relative URLs
387
+ * rewritten into this plugin's namespace; cross-plugin URLs
388
+ * rejected). The next viewer `/api/config` request returns
389
+ * `manifest.actions` ++ the runtime list. */
390
+ add(actions: ActionButton | ActionButton[]): void;
391
+ /** Drop the runtime additions; only `manifest.actions` remain on
392
+ * the next viewer `/api/config` request. */
393
+ clear(): void;
394
+ };
375
395
  sse: {
376
396
  /** Push one Server-Sent-Event to every browser connected to this
377
397
  * plugin's `/plugins/<name>/_sse/<channel>` stream. `event` is the SSE
@@ -416,7 +436,7 @@ export interface HttpResponse {
416
436
  body: string;
417
437
  }
418
438
 
419
- /** An entry in `manifest.actions` declares an action button the Owncast
439
+ /** An entry in `manifest.actions`, declares an action button the Owncast
420
440
  * UI surfaces while this plugin is enabled. Mirrors Owncast's existing
421
441
  * ExternalAction shape; the host merges enabled-plugin buttons with the
422
442
  * admin-configured list.
@@ -429,7 +449,7 @@ export interface HttpResponse {
429
449
  * explicit `/plugins/<your-name>/...` paths are accepted unchanged.
430
450
  *
431
451
  * When the resolved URL points back into this plugin, the manifest must
432
- * declare `http.serve` the host rejects the load otherwise. */
452
+ * declare `http.serve`, the host rejects the load otherwise. */
433
453
  export interface ActionButton {
434
454
  /** Button label. Required. */
435
455
  title: string;
@@ -437,7 +457,7 @@ export interface ActionButton {
437
457
  url?: string;
438
458
  /** Render this raw HTML when the button is pressed. Mutually exclusive with `url`. */
439
459
  html?: string;
440
- /** Icon image URL same path conventions as `url`. */
460
+ /** Icon image URL, same path conventions as `url`. */
441
461
  icon?: string;
442
462
  /** Accent color, e.g. "#3b82f6". */
443
463
  color?: string;
@@ -447,7 +467,7 @@ export interface ActionButton {
447
467
  openExternally?: boolean;
448
468
  }
449
469
 
450
- /** `manifest.network` narrows outbound HTTP scope for plugins that
470
+ /** `manifest.network`, narrows outbound HTTP scope for plugins that
451
471
  * declare the `network.fetch` permission. Required when that permission
452
472
  * is granted; the host rejects loads otherwise. */
453
473
  export interface NetworkConfig {