@owncast/plugin-sdk 0.6.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,20 +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 metadata recorded by defineCommands, reported to the host via
11
- // register() so it can build a unified `!help` across all plugins. One entry
12
- // per command: { name, prefix, description, usage, aliases, modOnly }.
10
+ // Command registrations used for matching, dispatch, and the unified `!help`.
13
11
  const commandManifest = [];
14
12
 
15
13
  // Host-driven timers. The sandbox has no setTimeout, so owncast.timer.* asks
16
14
  // the host to schedule a callback and call back via the internal "timer.fire"
17
15
  // event. The author's callback stays here in the long-lived instance, keyed by
18
16
  // a guest-allocated id the host echoes back. State persists across calls
19
- // because the plugin instance is reused; timers are dropped on reload.
17
+ // because the plugin instance is reused. Timers are dropped on reload.
20
18
  let nextTimerId = 1;
21
19
  const timerCallbacks = new Map(); // id -> { fn, repeat }
22
20
 
@@ -43,13 +41,22 @@ const Events = Object.freeze({
43
41
  // Once-a-second tick for periodic work (opt in by defining onTick)
44
42
  Tick: "tick",
45
43
  // Fediverse, engagement (metadata only) + inbound posts (with content)
44
+ FediverseActivity: "fediverse.activity",
46
45
  FediverseFollow: "fediverse.follow",
47
46
  FediverseLike: "fediverse.like",
48
47
  FediverseRepost: "fediverse.repost",
48
+ FediverseQuote: "fediverse.quote",
49
49
  FediverseMention: "fediverse.mention",
50
50
  FediverseReply: "fediverse.reply",
51
51
  });
52
52
 
53
+ const InternalEvents = Object.freeze({
54
+ ChatCommand: "chat.command",
55
+ TimerFire: "timer.fire",
56
+ });
57
+
58
+ const DefaultCommandPrefix = "!";
59
+
53
60
  const Permissions = Object.freeze({
54
61
  ChatSend: "chat.send",
55
62
  ChatHistory: "chat.history",
@@ -65,7 +72,10 @@ const Permissions = Object.freeze({
65
72
  NotificationsSend: "notifications.send",
66
73
  UsersRead: "users.read",
67
74
  UsersModerate: "users.moderate",
75
+ UsersRegister: "users.register",
76
+ AuthGate: "auth.gate",
68
77
  FediversePost: "fediverse.post",
78
+ FediverseInbound: "fediverse.inbound",
69
79
  HttpSSE: "http.sse",
70
80
  VideoConfigRead: "videoconfig.read",
71
81
  VideoConfigWrite: "videoconfig.write",
@@ -84,6 +94,30 @@ const filter = Object.freeze({
84
94
  },
85
95
  });
86
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
+
87
121
  // Distinguishes notification handlers from filter handlers in the HANDLERS
88
122
  // map below. Internal, not part of the public API.
89
123
  const HandlerKind = Object.freeze({
@@ -126,6 +160,11 @@ const HANDLERS = Object.freeze({
126
160
  onSseDisconnect: { event: Events.SseDisconnect, kind: HandlerKind.Notify },
127
161
  // Once-a-second tick
128
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
+ },
129
168
  // Fediverse engagement (actor + target metadata)
130
169
  onFediverseFollow: {
131
170
  event: Events.FediverseFollow,
@@ -136,6 +175,10 @@ const HANDLERS = Object.freeze({
136
175
  event: Events.FediverseRepost,
137
176
  kind: HandlerKind.Notify,
138
177
  },
178
+ onFediverseQuote: {
179
+ event: Events.FediverseQuote,
180
+ kind: HandlerKind.Notify,
181
+ },
139
182
  // Fediverse inbound posts (with content)
140
183
  onFediverseMention: {
141
184
  event: Events.FediverseMention,
@@ -144,156 +187,70 @@ const HANDLERS = Object.freeze({
144
187
  onFediverseReply: { event: Events.FediverseReply, kind: HandlerKind.Notify },
145
188
  });
146
189
 
147
- // typeof comparisons in well-known categories. JS guarantees these strings,
148
- // but we go through named constants so a stray typo can't pass silently.
149
- const JsType = Object.freeze({
150
- Function: "function",
151
- Object: "object",
152
- });
153
- const isFn = (x) => typeof x === JsType.Function;
154
- 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";
155
192
 
156
193
  function definePlugin(def) {
157
- // `commands` is declarative sugar: give definePlugin a command table (and an
158
- // optional commandPrefix) and the SDK wires the chat subscription for you —
159
- // no onChatMessage needed. It expands into an onChatMessage handler here, so
160
- // subscription derivation and dispatch treat it like any chat handler. If you
161
- // also pass onChatMessage, the command router runs first, then your handler.
162
- // For advanced composition (e.g. dropping command messages from chat via a
163
- // filter), use the lower-level defineCommands() router directly instead.
164
- if (def && isObj(def.commands)) {
165
- const router = defineCommands({
166
- prefix: def.commandPrefix,
167
- caseSensitive: def.commandsCaseSensitive,
168
- commands: def.commands,
169
- onUnknown: def.onUnknownCommand,
170
- });
171
- const userHandler = def.onChatMessage;
172
- def.onChatMessage = isFn(userHandler)
173
- ? (msg) => {
174
- router(msg);
175
- userHandler(msg);
176
- }
177
- : router;
178
- }
179
194
  registered = def;
180
- return def;
181
- }
195
+ commandManifest.length = 0;
196
+ if (!def || !isObj(def.commands)) return def;
182
197
 
183
- // defineCommands builds a chat-command router so plugins stop reimplementing
184
- // prefix parsing, aliases, per-user cooldowns, and moderator gating. It returns
185
- // a function you feed a ChatMessage (from onChatMessage or filterChatMessage);
186
- // it parses the command and invokes the matching handler's run(ctx). The return
187
- // value is true when the message was a command (even if gated), false when it
188
- // wasn't so a filter can drop command messages from chat:
189
- //
190
- // const commands = defineCommands({
191
- // prefix: "!",
192
- // commands: {
193
- // uptime: { run: (ctx) => ctx.reply("up!") },
194
- // ban: { modOnly: true, cooldownMs: 5000, run: (ctx) => ctx.reply(`bye ${ctx.args[0]}`) },
195
- // },
196
- // });
197
- // module.exports = definePlugin({
198
- // onChatMessage: commands,
199
- // // or, to hide command messages from chat:
200
- // // filterChatMessage: (msg) => (commands(msg) ? filter.drop("command") : filter.pass()),
201
- // });
202
- //
203
- // run(ctx) receives { msg, user, command, args, argString, reply, replyPrivately }.
204
- // reply posts publicly; replyPrivately whispers to the sender (falling back to a
205
- // public post if their connection is unknown). Optional hooks: per-command or
206
- // top-level onCooldown(ctx) / onDenied(ctx), and a top-level onUnknown(ctx).
207
- function defineCommands(config) {
208
- config = config || {};
209
- const prefix = config.prefix || "!";
210
- const caseSensitive = !!config.caseSensitive;
211
- const norm = (s) => (caseSensitive ? s : s.toLowerCase());
212
-
213
- // Resolve every name and alias to its canonical command definition, and
214
- // record metadata so the host can build a unified `!help` (see
215
- // describeCommands). Metadata is reported via register(); it never affects
216
- // routing.
217
- const table = new Map();
218
- const defs = config.commands || {};
219
- for (const name of Object.keys(defs)) {
220
- const def = defs[name];
221
- table.set(norm(name), { name, def });
222
- 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");
202
+ }
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`);
208
+ }
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`);
215
+ }
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`);
220
+ }
223
221
  commandManifest.push({
224
222
  name,
225
223
  prefix,
226
- description: def.description || "",
227
- usage: def.usage || "",
228
- aliases: def.aliases || [],
229
- modOnly: !!def.modOnly,
224
+ description: command.description || "",
225
+ usage: command.usage || "",
226
+ aliases,
227
+ modOnly: !!command.modOnly,
228
+ caseSensitive,
229
+ cooldownMs,
230
230
  });
231
231
  }
232
+ return def;
233
+ }
232
234
 
233
- // Per-(command,user) cooldown clock, in memory for the plugin's lifetime.
234
- const lastRun = new Map();
235
-
236
- return function handle(msg) {
237
- const body = (msg && msg.body) || "";
238
- if (!body.startsWith(prefix)) return false;
239
- const rest = body.slice(prefix.length);
240
- const parts = rest.split(/\s+/).filter(Boolean);
241
- if (parts.length === 0) return false;
242
- const called = parts[0];
243
- const args = parts.slice(1);
244
- const argString = rest.slice(called.length).trim();
245
-
246
- const ctx = {
247
- msg,
248
- user: msg && msg.user,
249
- command: norm(called),
250
- args,
251
- argString,
252
- reply: (text) => owncast.chat.send(text),
253
- replyPrivately: (text) => {
254
- if (!owncast.chat.replyTo(msg, text)) owncast.chat.send(text);
255
- },
256
- };
257
-
258
- const entry = table.get(norm(called));
259
- if (!entry) {
260
- if (isFn(config.onUnknown)) config.onUnknown(ctx);
261
- return false;
262
- }
263
- const { name, def } = entry;
264
- ctx.command = name;
265
-
266
- // Moderator gating: the sender's scopes must include MODERATOR.
267
- if (def.modOnly) {
268
- const scopes = (msg.user && msg.user.scopes) || [];
269
- if (!scopes.includes("MODERATOR")) {
270
- if (isFn(def.onDenied)) def.onDenied(ctx);
271
- else if (isFn(config.onDenied)) config.onDenied(ctx);
272
- return true; // matched a command, but the caller wasn't allowed
273
- }
274
- }
275
-
276
- // Per-user cooldown, clocked off msg.timestamp so it's deterministic in
277
- // tests and independent of any sandbox clock quirks.
278
- const cooldownMs = def.cooldownMs || 0;
279
- if (cooldownMs > 0) {
280
- const userId =
281
- (msg.user && msg.user.id) ||
282
- (msg.clientId != null ? `c${msg.clientId}` : "anon");
283
- const key = `${name}${userId}`;
284
- const now = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
285
- const prev = lastRun.get(key);
286
- if (now && prev && now - prev < cooldownMs) {
287
- if (isFn(def.onCooldown)) def.onCooldown(ctx);
288
- else if (isFn(config.onCooldown)) config.onCooldown(ctx);
289
- return true;
290
- }
291
- if (now) lastRun.set(key, now);
292
- }
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;
293
240
 
294
- if (isFn(def.run)) def.run(ctx);
295
- return true;
296
- };
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
+ });
297
254
  }
298
255
 
299
256
  // Used by the build-generated entry to compute subscriptions for register().
@@ -324,9 +281,8 @@ function describeSubscriptions() {
324
281
  return { notify, filter: filterSubs };
325
282
  }
326
283
 
327
- // Used by the build-generated entry to report the plugin's chat commands in
328
- // register(), so the host can answer a unified `!help`. Empty when the plugin
329
- // declares no commands.
284
+ // Used by the build-generated entry to report command registrations to the
285
+ // host for matching, dispatch, and the unified `!help`.
330
286
  function describeCommands() {
331
287
  return commandManifest;
332
288
  }
@@ -336,7 +292,7 @@ function dispatchEvent(envelope) {
336
292
  // Internal: a host-scheduled timer elapsed. Run the author's callback,
337
293
  // dropping one-shot entries first so a throw still cleans up. Not routed to
338
294
  // user handlers or the `on` map.
339
- if (eventType === "timer.fire") {
295
+ if (eventType === InternalEvents.TimerFire) {
340
296
  const id = payload && payload.id;
341
297
  const entry = timerCallbacks.get(id);
342
298
  if (entry) {
@@ -345,6 +301,10 @@ function dispatchEvent(envelope) {
345
301
  }
346
302
  return;
347
303
  }
304
+ if (eventType === InternalEvents.ChatCommand) {
305
+ dispatchCommand(payload);
306
+ return;
307
+ }
348
308
  if (!registered) return;
349
309
  for (const [method, info] of Object.entries(HANDLERS)) {
350
310
  if (
@@ -395,7 +355,7 @@ function dispatchHttp(request) {
395
355
  // host runtime captures), so a plugin author running `owncast-plugin
396
356
  // serve` or hitting the host's logs sees exactly which permission to
397
357
  // add to their manifest. apiName is the SDK call the author wrote
398
- // (e.g. "owncast.actions.set"); perm is the manifest permission string.
358
+ // (e.g. "owncast.actions.set"). perm is the manifest permission string.
399
359
  function permError(apiName, perm) {
400
360
  const msg = `${apiName} requires the '${perm}' permission. Add it to your plugin.manifest.json's "permissions" array.`;
401
361
  console.error(`[owncast-plugin] ${msg}`);
@@ -423,50 +383,45 @@ function scheduleTimer(fn, ms, repeat) {
423
383
  return id;
424
384
  }
425
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
+
426
395
  const owncast = {
427
396
  chat: {
428
397
  send(text) {
429
- const fns = Host.getFunctions();
430
- if (!fns.owncast_send_chat)
431
- throw new Error(`permission '${Permissions.ChatSend}' not granted`);
398
+ const fns = hostFns("owncast_send_chat", Permissions.ChatSend);
432
399
  fns.owncast_send_chat(Memory.fromString(text).offset);
433
400
  },
434
401
  sendAction(text) {
435
- const fns = Host.getFunctions();
436
- if (!fns.owncast_send_chat_action)
437
- throw new Error(`permission '${Permissions.ChatSend}' not granted`);
402
+ const fns = hostFns("owncast_send_chat_action", Permissions.ChatSend);
438
403
  fns.owncast_send_chat_action(Memory.fromString(text).offset);
439
404
  },
440
405
  system(body) {
441
- const fns = Host.getFunctions();
442
- if (!fns.owncast_send_chat_system)
443
- throw new Error(`permission '${Permissions.ChatSend}' not granted`);
406
+ const fns = hostFns("owncast_send_chat_system", Permissions.ChatSend);
444
407
  fns.owncast_send_chat_system(Memory.fromString(body).offset);
445
408
  },
446
409
  history(limit) {
447
- const fns = Host.getFunctions();
448
- if (!fns.owncast_chat_history)
449
- throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
410
+ const fns = hostFns("owncast_chat_history", Permissions.ChatHistory);
450
411
  const offset = fns.owncast_chat_history(limit || 0);
451
412
  if (offset == 0) return [];
452
413
  return JSON.parse(Memory.find(offset).readString());
453
414
  },
454
415
  deleteMessage(messageId) {
455
- const fns = Host.getFunctions();
456
- if (!fns.owncast_delete_message)
457
- throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
416
+ const fns = hostFns("owncast_delete_message", Permissions.ChatModerate);
458
417
  fns.owncast_delete_message(Memory.fromString(String(messageId)).offset);
459
418
  },
460
419
  kick(clientId) {
461
- const fns = Host.getFunctions();
462
- if (!fns.owncast_kick_client)
463
- throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
420
+ const fns = hostFns("owncast_kick_client", Permissions.ChatModerate);
464
421
  fns.owncast_kick_client(BigInt(clientId));
465
422
  },
466
423
  sendTo(clientId, text) {
467
- const fns = Host.getFunctions();
468
- if (!fns.owncast_send_chat_to)
469
- throw new Error(`permission '${Permissions.ChatSend}' not granted`);
424
+ const fns = hostFns("owncast_send_chat_to", Permissions.ChatSend);
470
425
  fns.owncast_send_chat_to(
471
426
  BigInt(clientId),
472
427
  Memory.fromString(text).offset,
@@ -487,9 +442,7 @@ const owncast = {
487
442
  return true;
488
443
  },
489
444
  clients() {
490
- const fns = Host.getFunctions();
491
- if (!fns.owncast_chat_clients)
492
- throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
445
+ const fns = hostFns("owncast_chat_clients", Permissions.ChatHistory);
493
446
  const offset = fns.owncast_chat_clients();
494
447
  if (offset == 0) return [];
495
448
  return JSON.parse(Memory.find(offset).readString());
@@ -497,27 +450,19 @@ const owncast = {
497
450
  },
498
451
  users: {
499
452
  list() {
500
- const fns = Host.getFunctions();
501
- if (!fns.owncast_users_list)
502
- throw new Error(`permission '${Permissions.UsersRead}' not granted`);
453
+ const fns = hostFns("owncast_users_list", Permissions.UsersRead);
503
454
  const offset = fns.owncast_users_list();
504
455
  if (offset == 0) return [];
505
456
  return JSON.parse(Memory.find(offset).readString());
506
457
  },
507
458
  get(id) {
508
- const fns = Host.getFunctions();
509
- if (!fns.owncast_user_get)
510
- throw new Error(`permission '${Permissions.UsersRead}' not granted`);
459
+ const fns = hostFns("owncast_user_get", Permissions.UsersRead);
511
460
  const offset = fns.owncast_user_get(Memory.fromString(id).offset);
512
461
  if (offset == 0) return null;
513
462
  return JSON.parse(Memory.find(offset).readString());
514
463
  },
515
464
  setEnabled(id, enabled, reason) {
516
- const fns = Host.getFunctions();
517
- if (!fns.owncast_user_set_enabled)
518
- throw new Error(
519
- `permission '${Permissions.UsersModerate}' not granted`,
520
- );
465
+ const fns = hostFns("owncast_user_set_enabled", Permissions.UsersModerate);
521
466
  fns.owncast_user_set_enabled(
522
467
  Memory.fromString(id).offset,
523
468
  enabled ? 1 : 0,
@@ -525,21 +470,54 @@ const owncast = {
525
470
  );
526
471
  },
527
472
  banIP(ip) {
528
- const fns = Host.getFunctions();
529
- if (!fns.owncast_ban_ip)
530
- throw new Error(
531
- `permission '${Permissions.UsersModerate}' not granted`,
532
- );
473
+ const fns = hostFns("owncast_ban_ip", Permissions.UsersModerate);
533
474
  fns.owncast_ban_ip(Memory.fromString(ip).offset);
534
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
+ },
535
517
  },
536
518
  storage: {
537
519
  upload(name, data) {
538
- const fns = Host.getFunctions();
539
- if (!fns.owncast_storage_upload)
540
- throw new Error(
541
- `permission '${Permissions.StorageUpload}' not granted`,
542
- );
520
+ const fns = hostFns("owncast_storage_upload", Permissions.StorageUpload);
543
521
  const dataMem =
544
522
  data instanceof Uint8Array
545
523
  ? Memory.fromBuffer(
@@ -565,9 +543,7 @@ const owncast = {
565
543
  // Read a file's raw bytes. Returns a Uint8Array, or null if the file
566
544
  // doesn't exist (or can't be read).
567
545
  read(path) {
568
- const fns = Host.getFunctions();
569
- if (!fns.owncast_fs_read)
570
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
546
+ const fns = hostFns("owncast_fs_read", Permissions.StorageFS);
571
547
  const offset = fns.owncast_fs_read(Memory.fromString(path).offset);
572
548
  if (offset == 0) return null;
573
549
  return new Uint8Array(Memory.find(offset).readBytes());
@@ -575,9 +551,7 @@ const owncast = {
575
551
  // Read a file as UTF-8 text. Returns a string, or null if the file
576
552
  // doesn't exist. (The Extism boundary decodes the bytes as UTF-8.)
577
553
  readText(path) {
578
- const fns = Host.getFunctions();
579
- if (!fns.owncast_fs_read)
580
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
554
+ const fns = hostFns("owncast_fs_read", Permissions.StorageFS);
581
555
  const offset = fns.owncast_fs_read(Memory.fromString(path).offset);
582
556
  if (offset == 0) return null;
583
557
  return Memory.find(offset).readString();
@@ -585,9 +559,7 @@ const owncast = {
585
559
  // Write bytes (Uint8Array) or a string to a file, creating parent
586
560
  // directories as needed. Returns { ok, error? }.
587
561
  write(path, data) {
588
- const fns = Host.getFunctions();
589
- if (!fns.owncast_fs_write)
590
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
562
+ const fns = hostFns("owncast_fs_write", Permissions.StorageFS);
591
563
  const dataMem =
592
564
  data instanceof Uint8Array
593
565
  ? Memory.fromBuffer(
@@ -607,27 +579,21 @@ const owncast = {
607
579
  // List the entry names (files and subdirectories) directly inside dir.
608
580
  // A missing directory lists as empty. Returns string[].
609
581
  list(dir) {
610
- const fns = Host.getFunctions();
611
- if (!fns.owncast_fs_list)
612
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
582
+ const fns = hostFns("owncast_fs_list", Permissions.StorageFS);
613
583
  const offset = fns.owncast_fs_list(Memory.fromString(dir || "").offset);
614
584
  if (offset == 0) return [];
615
585
  return JSON.parse(Memory.find(offset).readString());
616
586
  },
617
587
  // Remove a single file or empty directory. Returns { ok, error? }.
618
588
  delete(path) {
619
- const fns = Host.getFunctions();
620
- if (!fns.owncast_fs_delete)
621
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
589
+ const fns = hostFns("owncast_fs_delete", Permissions.StorageFS);
622
590
  const offset = fns.owncast_fs_delete(Memory.fromString(path).offset);
623
591
  if (offset == 0) return { ok: false, error: "delete failed" };
624
592
  return JSON.parse(Memory.find(offset).readString());
625
593
  },
626
594
  // Report whether a path exists inside the sandbox. Returns boolean.
627
595
  exists(path) {
628
- const fns = Host.getFunctions();
629
- if (!fns.owncast_fs_exists)
630
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
596
+ const fns = hostFns("owncast_fs_exists", Permissions.StorageFS);
631
597
  return fns.owncast_fs_exists(Memory.fromString(path).offset) === 1;
632
598
  },
633
599
  },
@@ -636,11 +602,7 @@ const owncast = {
636
602
  * behalf. Returns { url } on success, null on failure (rate-limited,
637
603
  * disabled by admin, etc.). Requires `fediverse.post`. */
638
604
  post(text) {
639
- const fns = Host.getFunctions();
640
- if (!fns.owncast_fediverse_post)
641
- throw new Error(
642
- `permission '${Permissions.FediversePost}' not granted`,
643
- );
605
+ const fns = hostFns("owncast_fediverse_post", Permissions.FediversePost);
644
606
  const offset = fns.owncast_fediverse_post(Memory.fromString(text).offset);
645
607
  if (offset == 0) return null;
646
608
  return JSON.parse(Memory.find(offset).readString());
@@ -648,30 +610,18 @@ const owncast = {
648
610
  },
649
611
  notifications: {
650
612
  discord(text) {
651
- const fns = Host.getFunctions();
652
- if (!fns.owncast_notify_discord)
653
- throw new Error(
654
- `permission '${Permissions.NotificationsSend}' not granted`,
655
- );
613
+ const fns = hostFns("owncast_notify_discord", Permissions.NotificationsSend);
656
614
  fns.owncast_notify_discord(Memory.fromString(text).offset);
657
615
  },
658
616
  browserPush(payload) {
659
- const fns = Host.getFunctions();
660
- if (!fns.owncast_notify_browser_push)
661
- throw new Error(
662
- `permission '${Permissions.NotificationsSend}' not granted`,
663
- );
617
+ const fns = hostFns("owncast_notify_browser_push", Permissions.NotificationsSend);
664
618
  const obj = typeof payload === "string" ? { title: payload } : payload;
665
619
  fns.owncast_notify_browser_push(
666
620
  Memory.fromString(JSON.stringify(obj)).offset,
667
621
  );
668
622
  },
669
623
  fediverse(payload) {
670
- const fns = Host.getFunctions();
671
- if (!fns.owncast_notify_fediverse)
672
- throw new Error(
673
- `permission '${Permissions.NotificationsSend}' not granted`,
674
- );
624
+ const fns = hostFns("owncast_notify_fediverse", Permissions.NotificationsSend);
675
625
  fns.owncast_notify_fediverse(
676
626
  Memory.fromString(JSON.stringify(payload)).offset,
677
627
  );
@@ -679,17 +629,13 @@ const owncast = {
679
629
  },
680
630
  stream: {
681
631
  current() {
682
- const fns = Host.getFunctions();
683
- if (!fns.owncast_stream_current)
684
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
632
+ const fns = hostFns("owncast_stream_current", Permissions.ServerRead);
685
633
  const offset = fns.owncast_stream_current();
686
634
  if (offset == 0) return { online: false, viewers: 0 };
687
635
  return JSON.parse(Memory.find(offset).readString());
688
636
  },
689
637
  broadcaster() {
690
- const fns = Host.getFunctions();
691
- if (!fns.owncast_stream_broadcaster)
692
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
638
+ const fns = hostFns("owncast_stream_broadcaster", Permissions.ServerRead);
693
639
  const offset = fns.owncast_stream_broadcaster();
694
640
  if (offset == 0) return {};
695
641
  return JSON.parse(Memory.find(offset).readString());
@@ -697,41 +643,31 @@ const owncast = {
697
643
  },
698
644
  server: {
699
645
  info() {
700
- const fns = Host.getFunctions();
701
- if (!fns.owncast_server_info)
702
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
646
+ const fns = hostFns("owncast_server_info", Permissions.ServerRead);
703
647
  const offset = fns.owncast_server_info();
704
648
  if (offset == 0) return {};
705
649
  return JSON.parse(Memory.find(offset).readString());
706
650
  },
707
651
  socials() {
708
- const fns = Host.getFunctions();
709
- if (!fns.owncast_server_socials)
710
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
652
+ const fns = hostFns("owncast_server_socials", Permissions.ServerRead);
711
653
  const offset = fns.owncast_server_socials();
712
654
  if (offset == 0) return [];
713
655
  return JSON.parse(Memory.find(offset).readString());
714
656
  },
715
657
  emotes() {
716
- const fns = Host.getFunctions();
717
- if (!fns.owncast_server_emotes)
718
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
658
+ const fns = hostFns("owncast_server_emotes", Permissions.ServerRead);
719
659
  const offset = fns.owncast_server_emotes();
720
660
  if (offset == 0) return [];
721
661
  return JSON.parse(Memory.find(offset).readString());
722
662
  },
723
663
  federation() {
724
- const fns = Host.getFunctions();
725
- if (!fns.owncast_server_federation)
726
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
664
+ const fns = hostFns("owncast_server_federation", Permissions.ServerRead);
727
665
  const offset = fns.owncast_server_federation();
728
666
  if (offset == 0) return { enabled: false };
729
667
  return JSON.parse(Memory.find(offset).readString());
730
668
  },
731
669
  tags() {
732
- const fns = Host.getFunctions();
733
- if (!fns.owncast_server_tags)
734
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
670
+ const fns = hostFns("owncast_server_tags", Permissions.ServerRead);
735
671
  const offset = fns.owncast_server_tags();
736
672
  if (offset == 0) return [];
737
673
  return JSON.parse(Memory.find(offset).readString());
@@ -741,24 +677,16 @@ const owncast = {
741
677
  /** Read the current video/transcoding config: { latencyLevel, codec,
742
678
  * variants }. Requires `videoconfig.read`. */
743
679
  read() {
744
- const fns = Host.getFunctions();
745
- if (!fns.owncast_video_config_read)
746
- throw new Error(
747
- `permission '${Permissions.VideoConfigRead}' not granted`,
748
- );
680
+ const fns = hostFns("owncast_video_config_read", Permissions.VideoConfigRead);
749
681
  const offset = fns.owncast_video_config_read();
750
682
  if (offset == 0) return { latencyLevel: 0, codec: "", variants: [] };
751
683
  return JSON.parse(Memory.find(offset).readString());
752
684
  },
753
685
  /** Apply a partial video config change. Pass any of { latencyLevel, codec,
754
- * variants }; omitted fields are left unchanged. Throws if the host
686
+ * variants }, where omitted fields are left unchanged. Throws if the host
755
687
  * rejects the config. Requires `videoconfig.write`. */
756
688
  write(config) {
757
- const fns = Host.getFunctions();
758
- if (!fns.owncast_video_config_write)
759
- throw new Error(
760
- `permission '${Permissions.VideoConfigWrite}' not granted`,
761
- );
689
+ const fns = hostFns("owncast_video_config_write", Permissions.VideoConfigWrite);
762
690
  const offset = fns.owncast_video_config_write(
763
691
  Memory.fromString(JSON.stringify(config || {})).offset,
764
692
  );
@@ -770,17 +698,13 @@ const owncast = {
770
698
  },
771
699
  kv: {
772
700
  get(key) {
773
- const fns = Host.getFunctions();
774
- if (!fns.owncast_kv_get)
775
- throw new Error(`permission '${Permissions.StorageKV}' not granted`);
701
+ const fns = hostFns("owncast_kv_get", Permissions.StorageKV);
776
702
  const offset = fns.owncast_kv_get(Memory.fromString(key).offset);
777
703
  if (offset == 0) return null;
778
704
  return Memory.find(offset).readString();
779
705
  },
780
706
  set(key, value) {
781
- const fns = Host.getFunctions();
782
- if (!fns.owncast_kv_set)
783
- throw new Error(`permission '${Permissions.StorageKV}' not granted`);
707
+ const fns = hostFns("owncast_kv_set", Permissions.StorageKV);
784
708
  fns.owncast_kv_set(
785
709
  Memory.fromString(key).offset,
786
710
  Memory.fromString(String(value)).offset,
@@ -837,9 +761,7 @@ const owncast = {
837
761
  },
838
762
  events: {
839
763
  emit(eventType, payload) {
840
- const fns = Host.getFunctions();
841
- if (!fns.owncast_emit_event)
842
- throw new Error(`permission '${Permissions.EventsEmit}' not granted`);
764
+ const fns = hostFns("owncast_emit_event", Permissions.EventsEmit);
843
765
  fns.owncast_emit_event(
844
766
  Memory.fromString(eventType).offset,
845
767
  Memory.fromString(JSON.stringify(payload)).offset,
@@ -875,15 +797,13 @@ const owncast = {
875
797
  // send(channel, event, data) pushes one Server-Sent-Event to every
876
798
  // browser connected to this plugin's /plugins/<name>/_sse/<channel>
877
799
  // stream. `event` is the SSE event name (browser side:
878
- // source.addEventListener(event, ...)); pass "" for the default
800
+ // source.addEventListener(event, ...)). Pass "" for the default
879
801
  // "message" event. `data` is sent as-is if it's a string, otherwise
880
802
  // JSON-stringified. Fire-and-forget: returns immediately, and frames to
881
803
  // a slow client are dropped rather than blocking the plugin. Requires
882
804
  // the 'http.sse' permission.
883
805
  send(channel, event, data) {
884
- const fns = Host.getFunctions();
885
- if (!fns.owncast_sse_send)
886
- throw new Error(`permission '${Permissions.HttpSSE}' not granted`);
806
+ const fns = hostFns("owncast_sse_send", Permissions.HttpSSE);
887
807
  const payload = typeof data === "string" ? data : JSON.stringify(data);
888
808
  fns.owncast_sse_send(
889
809
  Memory.fromString(channel || "").offset,
@@ -895,7 +815,7 @@ const owncast = {
895
815
  timer: {
896
816
  // setTimeout(fn, ms) runs fn once after ~ms milliseconds. setInterval
897
817
  // repeats until clear(id). The host drives the schedule (the sandbox has
898
- // no setTimeout); your callback runs in this instance when it fires.
818
+ // no setTimeout). Your callback runs in this instance when it fires.
899
819
  // Returns an id for clear(). Very small delays are clamped up by the host,
900
820
  // and there's a per-plugin cap on pending timers (throws past it).
901
821
  // Note: timers are in-memory and do not survive a plugin reload or a host
@@ -945,11 +865,21 @@ function dispatchPageContent(req) {
945
865
  return registered.onPageContent(req) || "";
946
866
  }
947
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
+
948
878
  module.exports = {
949
879
  definePlugin,
950
- defineCommands,
951
880
  owncast,
952
881
  filter,
882
+ authCheck,
953
883
  FilterAction,
954
884
  Events,
955
885
  Permissions,
@@ -958,6 +888,9 @@ module.exports = {
958
888
  dispatchEvent,
959
889
  dispatchFilter,
960
890
  dispatchHttp,
891
+ dispatchAuthCheck,
961
892
  dispatchTabContent,
962
893
  dispatchPageContent,
894
+ dispatchPageStyles,
895
+ dispatchPageScripts,
963
896
  };