@owncast/plugin-sdk 0.5.0 → 0.10.1

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/index.js CHANGED
@@ -3,15 +3,18 @@
3
3
  // Authors define typed handlers (onChatMessage, filterChatMessage, ...) plus
4
4
  // an `on: { [customEvent]: handler }` object for plugin-emitted events. The
5
5
  // SDK derives the manifest's subscriptions from which handlers are present
6
- // and returns them via register(); authors don't maintain a duplicate list.
6
+ // and returns them via register(). Authors don't maintain a duplicate list.
7
7
 
8
8
  let registered = null;
9
9
 
10
+ // Command registrations used for matching, dispatch, and the unified `!help`.
11
+ const commandManifest = [];
12
+
10
13
  // Host-driven timers. The sandbox has no setTimeout, so owncast.timer.* asks
11
14
  // the host to schedule a callback and call back via the internal "timer.fire"
12
15
  // event. The author's callback stays here in the long-lived instance, keyed by
13
16
  // a guest-allocated id the host echoes back. State persists across calls
14
- // because the plugin instance is reused; timers are dropped on reload.
17
+ // because the plugin instance is reused. Timers are dropped on reload.
15
18
  let nextTimerId = 1;
16
19
  const timerCallbacks = new Map(); // id -> { fn, repeat }
17
20
 
@@ -38,13 +41,22 @@ const Events = Object.freeze({
38
41
  // Once-a-second tick for periodic work (opt in by defining onTick)
39
42
  Tick: "tick",
40
43
  // Fediverse, engagement (metadata only) + inbound posts (with content)
44
+ FediverseActivity: "fediverse.activity",
41
45
  FediverseFollow: "fediverse.follow",
42
46
  FediverseLike: "fediverse.like",
43
47
  FediverseRepost: "fediverse.repost",
48
+ FediverseQuote: "fediverse.quote",
44
49
  FediverseMention: "fediverse.mention",
45
50
  FediverseReply: "fediverse.reply",
46
51
  });
47
52
 
53
+ const InternalEvents = Object.freeze({
54
+ ChatCommand: "chat.command",
55
+ TimerFire: "timer.fire",
56
+ });
57
+
58
+ const DefaultCommandPrefix = "!";
59
+
48
60
  const Permissions = Object.freeze({
49
61
  ChatSend: "chat.send",
50
62
  ChatHistory: "chat.history",
@@ -60,7 +72,10 @@ const Permissions = Object.freeze({
60
72
  NotificationsSend: "notifications.send",
61
73
  UsersRead: "users.read",
62
74
  UsersModerate: "users.moderate",
75
+ UsersRegister: "users.register",
76
+ AuthGate: "auth.gate",
63
77
  FediversePost: "fediverse.post",
78
+ FediverseInbound: "fediverse.inbound",
64
79
  HttpSSE: "http.sse",
65
80
  VideoConfigRead: "videoconfig.read",
66
81
  VideoConfigWrite: "videoconfig.write",
@@ -79,6 +94,30 @@ const filter = Object.freeze({
79
94
  },
80
95
  });
81
96
 
97
+ // Verdict helpers for onAuthCheck (the optional re-validation hook the host runs
98
+ // on a viewer's page load). `ok` keeps the session, `refresh` keeps it and
99
+ // extends the cookie (optional `ttl` seconds), and `deny` ends it and bounces
100
+ // the viewer back to the login screen.
101
+ const authCheck = Object.freeze({
102
+ ok() {
103
+ return { action: "ok" };
104
+ },
105
+ refresh(opts) {
106
+ return { action: "refresh", ...(opts || {}) };
107
+ },
108
+ deny(reason) {
109
+ return { action: "deny", reason: reason || "" };
110
+ },
111
+ });
112
+
113
+ // dispatchAuthCheck routes the host's re-validation call to the author's
114
+ // onAuthCheck handler. No handler → always "ok" (the hook is optional, and a
115
+ // plugin that doesn't implement it simply never revokes mid-session).
116
+ function dispatchAuthCheck(req) {
117
+ if (!registered || !isFn(registered.onAuthCheck)) return { action: "ok" };
118
+ return registered.onAuthCheck(req) || { action: "ok" };
119
+ }
120
+
82
121
  // Distinguishes notification handlers from filter handlers in the HANDLERS
83
122
  // map below. Internal, not part of the public API.
84
123
  const HandlerKind = Object.freeze({
@@ -121,6 +160,11 @@ const HANDLERS = Object.freeze({
121
160
  onSseDisconnect: { event: Events.SseDisconnect, kind: HandlerKind.Notify },
122
161
  // Once-a-second tick
123
162
  onTick: { event: Events.Tick, kind: HandlerKind.Notify },
163
+ // Verified inbound ActivityPub activity (raw JSON object)
164
+ onFediverse: {
165
+ event: Events.FediverseActivity,
166
+ kind: HandlerKind.Notify,
167
+ },
124
168
  // Fediverse engagement (actor + target metadata)
125
169
  onFediverseFollow: {
126
170
  event: Events.FediverseFollow,
@@ -131,6 +175,10 @@ const HANDLERS = Object.freeze({
131
175
  event: Events.FediverseRepost,
132
176
  kind: HandlerKind.Notify,
133
177
  },
178
+ onFediverseQuote: {
179
+ event: Events.FediverseQuote,
180
+ kind: HandlerKind.Notify,
181
+ },
134
182
  // Fediverse inbound posts (with content)
135
183
  onFediverseMention: {
136
184
  event: Events.FediverseMention,
@@ -139,123 +187,70 @@ const HANDLERS = Object.freeze({
139
187
  onFediverseReply: { event: Events.FediverseReply, kind: HandlerKind.Notify },
140
188
  });
141
189
 
142
- // typeof comparisons in well-known categories. JS guarantees these strings,
143
- // but we go through named constants so a stray typo can't pass silently.
144
- const JsType = Object.freeze({
145
- Function: "function",
146
- Object: "object",
147
- });
148
- const isFn = (x) => typeof x === JsType.Function;
149
- const isObj = (x) => x !== null && typeof x === JsType.Object;
190
+ const isFn = (x) => typeof x === "function";
191
+ const isObj = (x) => x !== null && typeof x === "object";
150
192
 
151
193
  function definePlugin(def) {
152
194
  registered = def;
153
- return def;
154
- }
195
+ commandManifest.length = 0;
196
+ if (!def || !isObj(def.commands)) return def;
155
197
 
156
- // defineCommands builds a chat-command router so plugins stop reimplementing
157
- // prefix parsing, aliases, per-user cooldowns, and moderator gating. It returns
158
- // a function you feed a ChatMessage (from onChatMessage or filterChatMessage);
159
- // it parses the command and invokes the matching handler's run(ctx). The return
160
- // value is true when the message was a command (even if gated), false when it
161
- // wasn't — so a filter can drop command messages from chat:
162
- //
163
- // const commands = defineCommands({
164
- // prefix: "!",
165
- // commands: {
166
- // uptime: { run: (ctx) => ctx.reply("up!") },
167
- // ban: { modOnly: true, cooldownMs: 5000, run: (ctx) => ctx.reply(`bye ${ctx.args[0]}`) },
168
- // },
169
- // });
170
- // module.exports = definePlugin({
171
- // onChatMessage: commands,
172
- // // or, to hide command messages from chat:
173
- // // filterChatMessage: (msg) => (commands(msg) ? filter.drop("command") : filter.pass()),
174
- // });
175
- //
176
- // run(ctx) receives { msg, user, command, args, argString, reply, replyPrivately }.
177
- // reply posts publicly; replyPrivately whispers to the sender (falling back to a
178
- // public post if their connection is unknown). Optional hooks: per-command or
179
- // top-level onCooldown(ctx) / onDenied(ctx), and a top-level onUnknown(ctx).
180
- function defineCommands(config) {
181
- config = config || {};
182
- const prefix = config.prefix || "!";
183
- const caseSensitive = !!config.caseSensitive;
184
- const norm = (s) => (caseSensitive ? s : s.toLowerCase());
185
-
186
- // Resolve every name and alias to its canonical command definition.
187
- const table = new Map();
188
- const defs = config.commands || {};
189
- for (const name of Object.keys(defs)) {
190
- const def = defs[name];
191
- table.set(norm(name), { name, def });
192
- for (const alias of def.aliases || []) table.set(norm(alias), { name, def });
198
+ const prefix =
199
+ def.commandPrefix == null ? DefaultCommandPrefix : def.commandPrefix;
200
+ if (typeof prefix !== "string" || prefix.length === 0) {
201
+ throw new TypeError("commandPrefix must be a non-empty string");
193
202
  }
194
-
195
- // Per-(command,user) cooldown clock, in memory for the plugin's lifetime.
196
- const lastRun = new Map();
197
-
198
- return function handle(msg) {
199
- const body = (msg && msg.body) || "";
200
- if (!body.startsWith(prefix)) return false;
201
- const rest = body.slice(prefix.length);
202
- const parts = rest.split(/\s+/).filter(Boolean);
203
- if (parts.length === 0) return false;
204
- const called = parts[0];
205
- const args = parts.slice(1);
206
- const argString = rest.slice(called.length).trim();
207
-
208
- const ctx = {
209
- msg,
210
- user: msg && msg.user,
211
- command: norm(called),
212
- args,
213
- argString,
214
- reply: (text) => owncast.chat.send(text),
215
- replyPrivately: (text) => {
216
- if (!owncast.chat.replyTo(msg, text)) owncast.chat.send(text);
217
- },
218
- };
219
-
220
- const entry = table.get(norm(called));
221
- if (!entry) {
222
- if (isFn(config.onUnknown)) config.onUnknown(ctx);
223
- return false;
203
+ const caseSensitive = !!def.commandsCaseSensitive;
204
+ for (const name of Object.keys(def.commands)) {
205
+ const command = def.commands[name];
206
+ if (!isObj(command)) {
207
+ throw new TypeError(`command "${name}" must be an object`);
224
208
  }
225
- const { name, def } = entry;
226
- ctx.command = name;
227
-
228
- // Moderator gating: the sender's scopes must include MODERATOR.
229
- if (def.modOnly) {
230
- const scopes = (msg.user && msg.user.scopes) || [];
231
- if (!scopes.includes("MODERATOR")) {
232
- if (isFn(def.onDenied)) def.onDenied(ctx);
233
- else if (isFn(config.onDenied)) config.onDenied(ctx);
234
- return true; // matched a command, but the caller wasn't allowed
235
- }
209
+ const aliases = command.aliases == null ? [] : command.aliases;
210
+ if (
211
+ !Array.isArray(aliases) ||
212
+ !aliases.every((alias) => typeof alias === "string")
213
+ ) {
214
+ throw new TypeError(`command "${name}" aliases must be an array of strings`);
236
215
  }
237
-
238
- // Per-user cooldown, clocked off msg.timestamp so it's deterministic in
239
- // tests and independent of any sandbox clock quirks.
240
- const cooldownMs = def.cooldownMs || 0;
241
- if (cooldownMs > 0) {
242
- const userId =
243
- (msg.user && msg.user.id) ||
244
- (msg.clientId != null ? `c${msg.clientId}` : "anon");
245
- const key = `${name}${userId}`;
246
- const now = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
247
- const prev = lastRun.get(key);
248
- if (now && prev && now - prev < cooldownMs) {
249
- if (isFn(def.onCooldown)) def.onCooldown(ctx);
250
- else if (isFn(config.onCooldown)) config.onCooldown(ctx);
251
- return true;
252
- }
253
- if (now) lastRun.set(key, now);
216
+ const cooldownMs =
217
+ command.cooldownMs == null ? 0 : command.cooldownMs;
218
+ if (!Number.isSafeInteger(cooldownMs) || cooldownMs < 0) {
219
+ throw new TypeError(`command "${name}" cooldownMs must be a non-negative integer`);
254
220
  }
221
+ commandManifest.push({
222
+ name,
223
+ prefix,
224
+ description: command.description || "",
225
+ usage: command.usage || "",
226
+ aliases,
227
+ modOnly: !!command.modOnly,
228
+ caseSensitive,
229
+ cooldownMs,
230
+ });
231
+ }
232
+ return def;
233
+ }
255
234
 
256
- if (isFn(def.run)) def.run(ctx);
257
- return true;
258
- };
235
+ function dispatchCommand(event) {
236
+ if (!isObj(event)) return;
237
+ if (!registered || !isObj(registered.commands)) return;
238
+ const command = registered.commands[event.command];
239
+ if (!command || !isFn(command.run)) return;
240
+
241
+ const msg = event.message;
242
+ command.run({
243
+ msg,
244
+ user: msg && msg.user,
245
+ command: event.command,
246
+ invokedAs: event.invokedAs || event.command,
247
+ args: event.args || [],
248
+ argString: event.argString || "",
249
+ reply: (text) => owncast.chat.send(text),
250
+ replyPrivately: (text) => {
251
+ if (!owncast.chat.replyTo(msg, text)) owncast.chat.send(text);
252
+ },
253
+ });
259
254
  }
260
255
 
261
256
  // Used by the build-generated entry to compute subscriptions for register().
@@ -286,12 +281,18 @@ function describeSubscriptions() {
286
281
  return { notify, filter: filterSubs };
287
282
  }
288
283
 
284
+ // Used by the build-generated entry to report command registrations to the
285
+ // host for matching, dispatch, and the unified `!help`.
286
+ function describeCommands() {
287
+ return commandManifest;
288
+ }
289
+
289
290
  function dispatchEvent(envelope) {
290
291
  const { eventType, payload } = envelope;
291
292
  // Internal: a host-scheduled timer elapsed. Run the author's callback,
292
293
  // dropping one-shot entries first so a throw still cleans up. Not routed to
293
294
  // user handlers or the `on` map.
294
- if (eventType === "timer.fire") {
295
+ if (eventType === InternalEvents.TimerFire) {
295
296
  const id = payload && payload.id;
296
297
  const entry = timerCallbacks.get(id);
297
298
  if (entry) {
@@ -300,6 +301,10 @@ function dispatchEvent(envelope) {
300
301
  }
301
302
  return;
302
303
  }
304
+ if (eventType === InternalEvents.ChatCommand) {
305
+ dispatchCommand(payload);
306
+ return;
307
+ }
303
308
  if (!registered) return;
304
309
  for (const [method, info] of Object.entries(HANDLERS)) {
305
310
  if (
@@ -350,7 +355,7 @@ function dispatchHttp(request) {
350
355
  // host runtime captures), so a plugin author running `owncast-plugin
351
356
  // serve` or hitting the host's logs sees exactly which permission to
352
357
  // add to their manifest. apiName is the SDK call the author wrote
353
- // (e.g. "owncast.actions.set"); perm is the manifest permission string.
358
+ // (e.g. "owncast.actions.set"). perm is the manifest permission string.
354
359
  function permError(apiName, perm) {
355
360
  const msg = `${apiName} requires the '${perm}' permission. Add it to your plugin.manifest.json's "permissions" array.`;
356
361
  console.error(`[owncast-plugin] ${msg}`);
@@ -378,50 +383,45 @@ function scheduleTimer(fn, ms, repeat) {
378
383
  return id;
379
384
  }
380
385
 
386
+ // hostFns returns the host import table, throwing an actionable error if the
387
+ // named function wasn't granted (the plugin's manifest is missing its
388
+ // permission). This is the per-call guard every owncast.* method used to inline.
389
+ function hostFns(name, perm) {
390
+ const fns = Host.getFunctions();
391
+ if (!fns[name]) throw new Error(`permission '${perm}' not granted`);
392
+ return fns;
393
+ }
394
+
381
395
  const owncast = {
382
396
  chat: {
383
397
  send(text) {
384
- const fns = Host.getFunctions();
385
- if (!fns.owncast_send_chat)
386
- throw new Error(`permission '${Permissions.ChatSend}' not granted`);
398
+ const fns = hostFns("owncast_send_chat", Permissions.ChatSend);
387
399
  fns.owncast_send_chat(Memory.fromString(text).offset);
388
400
  },
389
401
  sendAction(text) {
390
- const fns = Host.getFunctions();
391
- if (!fns.owncast_send_chat_action)
392
- throw new Error(`permission '${Permissions.ChatSend}' not granted`);
402
+ const fns = hostFns("owncast_send_chat_action", Permissions.ChatSend);
393
403
  fns.owncast_send_chat_action(Memory.fromString(text).offset);
394
404
  },
395
405
  system(body) {
396
- const fns = Host.getFunctions();
397
- if (!fns.owncast_send_chat_system)
398
- throw new Error(`permission '${Permissions.ChatSend}' not granted`);
406
+ const fns = hostFns("owncast_send_chat_system", Permissions.ChatSend);
399
407
  fns.owncast_send_chat_system(Memory.fromString(body).offset);
400
408
  },
401
409
  history(limit) {
402
- const fns = Host.getFunctions();
403
- if (!fns.owncast_chat_history)
404
- throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
410
+ const fns = hostFns("owncast_chat_history", Permissions.ChatHistory);
405
411
  const offset = fns.owncast_chat_history(limit || 0);
406
412
  if (offset == 0) return [];
407
413
  return JSON.parse(Memory.find(offset).readString());
408
414
  },
409
415
  deleteMessage(messageId) {
410
- const fns = Host.getFunctions();
411
- if (!fns.owncast_delete_message)
412
- throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
416
+ const fns = hostFns("owncast_delete_message", Permissions.ChatModerate);
413
417
  fns.owncast_delete_message(Memory.fromString(String(messageId)).offset);
414
418
  },
415
419
  kick(clientId) {
416
- const fns = Host.getFunctions();
417
- if (!fns.owncast_kick_client)
418
- throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
420
+ const fns = hostFns("owncast_kick_client", Permissions.ChatModerate);
419
421
  fns.owncast_kick_client(BigInt(clientId));
420
422
  },
421
423
  sendTo(clientId, text) {
422
- const fns = Host.getFunctions();
423
- if (!fns.owncast_send_chat_to)
424
- throw new Error(`permission '${Permissions.ChatSend}' not granted`);
424
+ const fns = hostFns("owncast_send_chat_to", Permissions.ChatSend);
425
425
  fns.owncast_send_chat_to(
426
426
  BigInt(clientId),
427
427
  Memory.fromString(text).offset,
@@ -442,9 +442,7 @@ const owncast = {
442
442
  return true;
443
443
  },
444
444
  clients() {
445
- const fns = Host.getFunctions();
446
- if (!fns.owncast_chat_clients)
447
- throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
445
+ const fns = hostFns("owncast_chat_clients", Permissions.ChatHistory);
448
446
  const offset = fns.owncast_chat_clients();
449
447
  if (offset == 0) return [];
450
448
  return JSON.parse(Memory.find(offset).readString());
@@ -452,27 +450,19 @@ const owncast = {
452
450
  },
453
451
  users: {
454
452
  list() {
455
- const fns = Host.getFunctions();
456
- if (!fns.owncast_users_list)
457
- throw new Error(`permission '${Permissions.UsersRead}' not granted`);
453
+ const fns = hostFns("owncast_users_list", Permissions.UsersRead);
458
454
  const offset = fns.owncast_users_list();
459
455
  if (offset == 0) return [];
460
456
  return JSON.parse(Memory.find(offset).readString());
461
457
  },
462
458
  get(id) {
463
- const fns = Host.getFunctions();
464
- if (!fns.owncast_user_get)
465
- throw new Error(`permission '${Permissions.UsersRead}' not granted`);
459
+ const fns = hostFns("owncast_user_get", Permissions.UsersRead);
466
460
  const offset = fns.owncast_user_get(Memory.fromString(id).offset);
467
461
  if (offset == 0) return null;
468
462
  return JSON.parse(Memory.find(offset).readString());
469
463
  },
470
464
  setEnabled(id, enabled, reason) {
471
- const fns = Host.getFunctions();
472
- if (!fns.owncast_user_set_enabled)
473
- throw new Error(
474
- `permission '${Permissions.UsersModerate}' not granted`,
475
- );
465
+ const fns = hostFns("owncast_user_set_enabled", Permissions.UsersModerate);
476
466
  fns.owncast_user_set_enabled(
477
467
  Memory.fromString(id).offset,
478
468
  enabled ? 1 : 0,
@@ -480,21 +470,54 @@ const owncast = {
480
470
  );
481
471
  },
482
472
  banIP(ip) {
483
- const fns = Host.getFunctions();
484
- if (!fns.owncast_ban_ip)
485
- throw new Error(
486
- `permission '${Permissions.UsersModerate}' not granted`,
487
- );
473
+ const fns = hostFns("owncast_ban_ip", Permissions.UsersModerate);
488
474
  fns.owncast_ban_ip(Memory.fromString(ip).offset);
489
475
  },
476
+ // Find-or-create an authenticated Owncast user for an external identity
477
+ // (e.g. a provider account). `authId` is the stable provider-scoped id. The
478
+ // host namespaces it by this plugin's slug so it can't collide with or spoof
479
+ // another plugin's users. Optionally seeds displayName and scopes. Returns
480
+ // { userId }. Throws on host error. Requires `users.register`.
481
+ register(opts) {
482
+ const fns = hostFns("owncast_users_register", Permissions.UsersRegister);
483
+ const req =
484
+ typeof opts === "string" ? { authId: opts } : opts || {};
485
+ const offset = fns.owncast_users_register(
486
+ Memory.fromString(JSON.stringify(req)).offset,
487
+ );
488
+ if (offset == 0) throw new Error("users.register failed");
489
+ const result = JSON.parse(Memory.find(offset).readString());
490
+ if (result.error) throw new Error(result.error);
491
+ return result; // { userId }
492
+ },
493
+ },
494
+ // Viewer-authentication gate. Only a plugin holding `auth.gate` (and enabled by
495
+ // an admin) can issue sessions, and these are valid only inside onHttpRequest,
496
+ // where the host attaches/clears the signed session cookie on the response.
497
+ auth: {
498
+ // Issue a gate session for an already-registered user (see users.register).
499
+ // `ttl` is optional seconds, and 0/omitted uses the host default. Throws on
500
+ // host error. Requires `auth.gate`.
501
+ grantSession(opts) {
502
+ const fns = hostFns("owncast_auth_grant_session", Permissions.AuthGate);
503
+ const req = typeof opts === "string" ? { userId: opts } : opts || {};
504
+ const offset = fns.owncast_auth_grant_session(
505
+ Memory.fromString(JSON.stringify(req)).offset,
506
+ );
507
+ if (offset == 0) throw new Error("auth.grantSession failed");
508
+ const result = JSON.parse(Memory.find(offset).readString());
509
+ if (result.error) throw new Error(result.error);
510
+ },
511
+ // Clear the current viewer's gate session (logout). The plugin still owns the
512
+ // response/redirect. Requires `auth.gate`.
513
+ endSession() {
514
+ const fns = hostFns("owncast_auth_end_session", Permissions.AuthGate);
515
+ fns.owncast_auth_end_session();
516
+ },
490
517
  },
491
518
  storage: {
492
519
  upload(name, data) {
493
- const fns = Host.getFunctions();
494
- if (!fns.owncast_storage_upload)
495
- throw new Error(
496
- `permission '${Permissions.StorageUpload}' not granted`,
497
- );
520
+ const fns = hostFns("owncast_storage_upload", Permissions.StorageUpload);
498
521
  const dataMem =
499
522
  data instanceof Uint8Array
500
523
  ? Memory.fromBuffer(
@@ -520,9 +543,7 @@ const owncast = {
520
543
  // Read a file's raw bytes. Returns a Uint8Array, or null if the file
521
544
  // doesn't exist (or can't be read).
522
545
  read(path) {
523
- const fns = Host.getFunctions();
524
- if (!fns.owncast_fs_read)
525
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
546
+ const fns = hostFns("owncast_fs_read", Permissions.StorageFS);
526
547
  const offset = fns.owncast_fs_read(Memory.fromString(path).offset);
527
548
  if (offset == 0) return null;
528
549
  return new Uint8Array(Memory.find(offset).readBytes());
@@ -530,9 +551,7 @@ const owncast = {
530
551
  // Read a file as UTF-8 text. Returns a string, or null if the file
531
552
  // doesn't exist. (The Extism boundary decodes the bytes as UTF-8.)
532
553
  readText(path) {
533
- const fns = Host.getFunctions();
534
- if (!fns.owncast_fs_read)
535
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
554
+ const fns = hostFns("owncast_fs_read", Permissions.StorageFS);
536
555
  const offset = fns.owncast_fs_read(Memory.fromString(path).offset);
537
556
  if (offset == 0) return null;
538
557
  return Memory.find(offset).readString();
@@ -540,9 +559,7 @@ const owncast = {
540
559
  // Write bytes (Uint8Array) or a string to a file, creating parent
541
560
  // directories as needed. Returns { ok, error? }.
542
561
  write(path, data) {
543
- const fns = Host.getFunctions();
544
- if (!fns.owncast_fs_write)
545
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
562
+ const fns = hostFns("owncast_fs_write", Permissions.StorageFS);
546
563
  const dataMem =
547
564
  data instanceof Uint8Array
548
565
  ? Memory.fromBuffer(
@@ -562,27 +579,21 @@ const owncast = {
562
579
  // List the entry names (files and subdirectories) directly inside dir.
563
580
  // A missing directory lists as empty. Returns string[].
564
581
  list(dir) {
565
- const fns = Host.getFunctions();
566
- if (!fns.owncast_fs_list)
567
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
582
+ const fns = hostFns("owncast_fs_list", Permissions.StorageFS);
568
583
  const offset = fns.owncast_fs_list(Memory.fromString(dir || "").offset);
569
584
  if (offset == 0) return [];
570
585
  return JSON.parse(Memory.find(offset).readString());
571
586
  },
572
587
  // Remove a single file or empty directory. Returns { ok, error? }.
573
588
  delete(path) {
574
- const fns = Host.getFunctions();
575
- if (!fns.owncast_fs_delete)
576
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
589
+ const fns = hostFns("owncast_fs_delete", Permissions.StorageFS);
577
590
  const offset = fns.owncast_fs_delete(Memory.fromString(path).offset);
578
591
  if (offset == 0) return { ok: false, error: "delete failed" };
579
592
  return JSON.parse(Memory.find(offset).readString());
580
593
  },
581
594
  // Report whether a path exists inside the sandbox. Returns boolean.
582
595
  exists(path) {
583
- const fns = Host.getFunctions();
584
- if (!fns.owncast_fs_exists)
585
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
596
+ const fns = hostFns("owncast_fs_exists", Permissions.StorageFS);
586
597
  return fns.owncast_fs_exists(Memory.fromString(path).offset) === 1;
587
598
  },
588
599
  },
@@ -591,11 +602,7 @@ const owncast = {
591
602
  * behalf. Returns { url } on success, null on failure (rate-limited,
592
603
  * disabled by admin, etc.). Requires `fediverse.post`. */
593
604
  post(text) {
594
- const fns = Host.getFunctions();
595
- if (!fns.owncast_fediverse_post)
596
- throw new Error(
597
- `permission '${Permissions.FediversePost}' not granted`,
598
- );
605
+ const fns = hostFns("owncast_fediverse_post", Permissions.FediversePost);
599
606
  const offset = fns.owncast_fediverse_post(Memory.fromString(text).offset);
600
607
  if (offset == 0) return null;
601
608
  return JSON.parse(Memory.find(offset).readString());
@@ -603,30 +610,18 @@ const owncast = {
603
610
  },
604
611
  notifications: {
605
612
  discord(text) {
606
- const fns = Host.getFunctions();
607
- if (!fns.owncast_notify_discord)
608
- throw new Error(
609
- `permission '${Permissions.NotificationsSend}' not granted`,
610
- );
613
+ const fns = hostFns("owncast_notify_discord", Permissions.NotificationsSend);
611
614
  fns.owncast_notify_discord(Memory.fromString(text).offset);
612
615
  },
613
616
  browserPush(payload) {
614
- const fns = Host.getFunctions();
615
- if (!fns.owncast_notify_browser_push)
616
- throw new Error(
617
- `permission '${Permissions.NotificationsSend}' not granted`,
618
- );
617
+ const fns = hostFns("owncast_notify_browser_push", Permissions.NotificationsSend);
619
618
  const obj = typeof payload === "string" ? { title: payload } : payload;
620
619
  fns.owncast_notify_browser_push(
621
620
  Memory.fromString(JSON.stringify(obj)).offset,
622
621
  );
623
622
  },
624
623
  fediverse(payload) {
625
- const fns = Host.getFunctions();
626
- if (!fns.owncast_notify_fediverse)
627
- throw new Error(
628
- `permission '${Permissions.NotificationsSend}' not granted`,
629
- );
624
+ const fns = hostFns("owncast_notify_fediverse", Permissions.NotificationsSend);
630
625
  fns.owncast_notify_fediverse(
631
626
  Memory.fromString(JSON.stringify(payload)).offset,
632
627
  );
@@ -634,17 +629,13 @@ const owncast = {
634
629
  },
635
630
  stream: {
636
631
  current() {
637
- const fns = Host.getFunctions();
638
- if (!fns.owncast_stream_current)
639
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
632
+ const fns = hostFns("owncast_stream_current", Permissions.ServerRead);
640
633
  const offset = fns.owncast_stream_current();
641
634
  if (offset == 0) return { online: false, viewers: 0 };
642
635
  return JSON.parse(Memory.find(offset).readString());
643
636
  },
644
637
  broadcaster() {
645
- const fns = Host.getFunctions();
646
- if (!fns.owncast_stream_broadcaster)
647
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
638
+ const fns = hostFns("owncast_stream_broadcaster", Permissions.ServerRead);
648
639
  const offset = fns.owncast_stream_broadcaster();
649
640
  if (offset == 0) return {};
650
641
  return JSON.parse(Memory.find(offset).readString());
@@ -652,41 +643,31 @@ const owncast = {
652
643
  },
653
644
  server: {
654
645
  info() {
655
- const fns = Host.getFunctions();
656
- if (!fns.owncast_server_info)
657
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
646
+ const fns = hostFns("owncast_server_info", Permissions.ServerRead);
658
647
  const offset = fns.owncast_server_info();
659
648
  if (offset == 0) return {};
660
649
  return JSON.parse(Memory.find(offset).readString());
661
650
  },
662
651
  socials() {
663
- const fns = Host.getFunctions();
664
- if (!fns.owncast_server_socials)
665
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
652
+ const fns = hostFns("owncast_server_socials", Permissions.ServerRead);
666
653
  const offset = fns.owncast_server_socials();
667
654
  if (offset == 0) return [];
668
655
  return JSON.parse(Memory.find(offset).readString());
669
656
  },
670
657
  emotes() {
671
- const fns = Host.getFunctions();
672
- if (!fns.owncast_server_emotes)
673
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
658
+ const fns = hostFns("owncast_server_emotes", Permissions.ServerRead);
674
659
  const offset = fns.owncast_server_emotes();
675
660
  if (offset == 0) return [];
676
661
  return JSON.parse(Memory.find(offset).readString());
677
662
  },
678
663
  federation() {
679
- const fns = Host.getFunctions();
680
- if (!fns.owncast_server_federation)
681
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
664
+ const fns = hostFns("owncast_server_federation", Permissions.ServerRead);
682
665
  const offset = fns.owncast_server_federation();
683
666
  if (offset == 0) return { enabled: false };
684
667
  return JSON.parse(Memory.find(offset).readString());
685
668
  },
686
669
  tags() {
687
- const fns = Host.getFunctions();
688
- if (!fns.owncast_server_tags)
689
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
670
+ const fns = hostFns("owncast_server_tags", Permissions.ServerRead);
690
671
  const offset = fns.owncast_server_tags();
691
672
  if (offset == 0) return [];
692
673
  return JSON.parse(Memory.find(offset).readString());
@@ -696,24 +677,16 @@ const owncast = {
696
677
  /** Read the current video/transcoding config: { latencyLevel, codec,
697
678
  * variants }. Requires `videoconfig.read`. */
698
679
  read() {
699
- const fns = Host.getFunctions();
700
- if (!fns.owncast_video_config_read)
701
- throw new Error(
702
- `permission '${Permissions.VideoConfigRead}' not granted`,
703
- );
680
+ const fns = hostFns("owncast_video_config_read", Permissions.VideoConfigRead);
704
681
  const offset = fns.owncast_video_config_read();
705
682
  if (offset == 0) return { latencyLevel: 0, codec: "", variants: [] };
706
683
  return JSON.parse(Memory.find(offset).readString());
707
684
  },
708
685
  /** Apply a partial video config change. Pass any of { latencyLevel, codec,
709
- * variants }; omitted fields are left unchanged. Throws if the host
686
+ * variants }, where omitted fields are left unchanged. Throws if the host
710
687
  * rejects the config. Requires `videoconfig.write`. */
711
688
  write(config) {
712
- const fns = Host.getFunctions();
713
- if (!fns.owncast_video_config_write)
714
- throw new Error(
715
- `permission '${Permissions.VideoConfigWrite}' not granted`,
716
- );
689
+ const fns = hostFns("owncast_video_config_write", Permissions.VideoConfigWrite);
717
690
  const offset = fns.owncast_video_config_write(
718
691
  Memory.fromString(JSON.stringify(config || {})).offset,
719
692
  );
@@ -725,17 +698,13 @@ const owncast = {
725
698
  },
726
699
  kv: {
727
700
  get(key) {
728
- const fns = Host.getFunctions();
729
- if (!fns.owncast_kv_get)
730
- throw new Error(`permission '${Permissions.StorageKV}' not granted`);
701
+ const fns = hostFns("owncast_kv_get", Permissions.StorageKV);
731
702
  const offset = fns.owncast_kv_get(Memory.fromString(key).offset);
732
703
  if (offset == 0) return null;
733
704
  return Memory.find(offset).readString();
734
705
  },
735
706
  set(key, value) {
736
- const fns = Host.getFunctions();
737
- if (!fns.owncast_kv_set)
738
- throw new Error(`permission '${Permissions.StorageKV}' not granted`);
707
+ const fns = hostFns("owncast_kv_set", Permissions.StorageKV);
739
708
  fns.owncast_kv_set(
740
709
  Memory.fromString(key).offset,
741
710
  Memory.fromString(String(value)).offset,
@@ -792,9 +761,7 @@ const owncast = {
792
761
  },
793
762
  events: {
794
763
  emit(eventType, payload) {
795
- const fns = Host.getFunctions();
796
- if (!fns.owncast_emit_event)
797
- throw new Error(`permission '${Permissions.EventsEmit}' not granted`);
764
+ const fns = hostFns("owncast_emit_event", Permissions.EventsEmit);
798
765
  fns.owncast_emit_event(
799
766
  Memory.fromString(eventType).offset,
800
767
  Memory.fromString(JSON.stringify(payload)).offset,
@@ -830,15 +797,13 @@ const owncast = {
830
797
  // send(channel, event, data) pushes one Server-Sent-Event to every
831
798
  // browser connected to this plugin's /plugins/<name>/_sse/<channel>
832
799
  // stream. `event` is the SSE event name (browser side:
833
- // source.addEventListener(event, ...)); pass "" for the default
800
+ // source.addEventListener(event, ...)). Pass "" for the default
834
801
  // "message" event. `data` is sent as-is if it's a string, otherwise
835
802
  // JSON-stringified. Fire-and-forget: returns immediately, and frames to
836
803
  // a slow client are dropped rather than blocking the plugin. Requires
837
804
  // the 'http.sse' permission.
838
805
  send(channel, event, data) {
839
- const fns = Host.getFunctions();
840
- if (!fns.owncast_sse_send)
841
- throw new Error(`permission '${Permissions.HttpSSE}' not granted`);
806
+ const fns = hostFns("owncast_sse_send", Permissions.HttpSSE);
842
807
  const payload = typeof data === "string" ? data : JSON.stringify(data);
843
808
  fns.owncast_sse_send(
844
809
  Memory.fromString(channel || "").offset,
@@ -850,7 +815,7 @@ const owncast = {
850
815
  timer: {
851
816
  // setTimeout(fn, ms) runs fn once after ~ms milliseconds. setInterval
852
817
  // repeats until clear(id). The host drives the schedule (the sandbox has
853
- // no setTimeout); your callback runs in this instance when it fires.
818
+ // no setTimeout). Your callback runs in this instance when it fires.
854
819
  // Returns an id for clear(). Very small delays are clamped up by the host,
855
820
  // and there's a per-plugin cap on pending timers (throws past it).
856
821
  // Note: timers are in-memory and do not survive a plugin reload or a host
@@ -900,18 +865,32 @@ function dispatchPageContent(req) {
900
865
  return registered.onPageContent(req) || "";
901
866
  }
902
867
 
868
+ function dispatchPageStyles() {
869
+ if (!registered || !isFn(registered.onPageStyles)) return "";
870
+ return registered.onPageStyles() || "";
871
+ }
872
+
873
+ function dispatchPageScripts() {
874
+ if (!registered || !isFn(registered.onPageScripts)) return "";
875
+ return registered.onPageScripts() || "";
876
+ }
877
+
903
878
  module.exports = {
904
879
  definePlugin,
905
- defineCommands,
906
880
  owncast,
907
881
  filter,
882
+ authCheck,
908
883
  FilterAction,
909
884
  Events,
910
885
  Permissions,
911
886
  describeSubscriptions,
887
+ describeCommands,
912
888
  dispatchEvent,
913
889
  dispatchFilter,
914
890
  dispatchHttp,
891
+ dispatchAuthCheck,
915
892
  dispatchTabContent,
916
893
  dispatchPageContent,
894
+ dispatchPageStyles,
895
+ dispatchPageScripts,
917
896
  };