@owncast/plugin-sdk 0.6.0 → 0.11.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/index.js CHANGED
@@ -1,22 +1,21 @@
1
1
  // @owncast/plugin-sdk runtime, bundled into every plugin.
2
2
  //
3
3
  // Authors define typed handlers (onChatMessage, filterChatMessage, ...) plus
4
- // an `on: { [customEvent]: handler }` object for plugin-emitted events. The
4
+ // an `on: { [localCustomHook]: handler }` object for plugin-owned 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(). The host qualifies custom hooks with the
7
+ // declaring plugin's slug.
7
8
 
8
9
  let registered = null;
9
10
 
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 }.
11
+ // Command registrations used for matching, dispatch, and the unified `!help`.
13
12
  const commandManifest = [];
14
13
 
15
14
  // Host-driven timers. The sandbox has no setTimeout, so owncast.timer.* asks
16
15
  // the host to schedule a callback and call back via the internal "timer.fire"
17
16
  // event. The author's callback stays here in the long-lived instance, keyed by
18
17
  // a guest-allocated id the host echoes back. State persists across calls
19
- // because the plugin instance is reused; timers are dropped on reload.
18
+ // because the plugin instance is reused. Timers are dropped on reload.
20
19
  let nextTimerId = 1;
21
20
  const timerCallbacks = new Map(); // id -> { fn, repeat }
22
21
 
@@ -43,13 +42,22 @@ const Events = Object.freeze({
43
42
  // Once-a-second tick for periodic work (opt in by defining onTick)
44
43
  Tick: "tick",
45
44
  // Fediverse, engagement (metadata only) + inbound posts (with content)
45
+ FediverseActivity: "fediverse.activity",
46
46
  FediverseFollow: "fediverse.follow",
47
47
  FediverseLike: "fediverse.like",
48
48
  FediverseRepost: "fediverse.repost",
49
+ FediverseQuote: "fediverse.quote",
49
50
  FediverseMention: "fediverse.mention",
50
51
  FediverseReply: "fediverse.reply",
51
52
  });
52
53
 
54
+ const InternalEvents = Object.freeze({
55
+ ChatCommand: "chat.command",
56
+ TimerFire: "timer.fire",
57
+ });
58
+
59
+ const DefaultCommandPrefix = "!";
60
+
53
61
  const Permissions = Object.freeze({
54
62
  ChatSend: "chat.send",
55
63
  ChatHistory: "chat.history",
@@ -58,6 +66,7 @@ const Permissions = Object.freeze({
58
66
  StorageKV: "storage.kv",
59
67
  StorageUpload: "storage.upload",
60
68
  StorageFS: "storage.fs",
69
+ StorageSQL: "storage.sql",
61
70
  EventsEmit: "events.emit",
62
71
  NetworkFetch: "network.fetch",
63
72
  HttpServe: "http.serve",
@@ -65,7 +74,10 @@ const Permissions = Object.freeze({
65
74
  NotificationsSend: "notifications.send",
66
75
  UsersRead: "users.read",
67
76
  UsersModerate: "users.moderate",
77
+ UsersRegister: "users.register",
78
+ AuthGate: "auth.gate",
68
79
  FediversePost: "fediverse.post",
80
+ FediverseInbound: "fediverse.inbound",
69
81
  HttpSSE: "http.sse",
70
82
  VideoConfigRead: "videoconfig.read",
71
83
  VideoConfigWrite: "videoconfig.write",
@@ -84,6 +96,30 @@ const filter = Object.freeze({
84
96
  },
85
97
  });
86
98
 
99
+ // Verdict helpers for onAuthCheck (the optional re-validation hook the host runs
100
+ // on a viewer's page load). `ok` keeps the session, `refresh` keeps it and
101
+ // extends the cookie (optional `ttl` seconds), and `deny` ends it and bounces
102
+ // the viewer back to the login screen.
103
+ const authCheck = Object.freeze({
104
+ ok() {
105
+ return { action: "ok" };
106
+ },
107
+ refresh(opts) {
108
+ return { action: "refresh", ...(opts || {}) };
109
+ },
110
+ deny(reason) {
111
+ return { action: "deny", reason: reason || "" };
112
+ },
113
+ });
114
+
115
+ // dispatchAuthCheck routes the host's re-validation call to the author's
116
+ // onAuthCheck handler. No handler → always "ok" (the hook is optional, and a
117
+ // plugin that doesn't implement it simply never revokes mid-session).
118
+ function dispatchAuthCheck(req) {
119
+ if (!registered || !isFn(registered.onAuthCheck)) return { action: "ok" };
120
+ return registered.onAuthCheck(req) || { action: "ok" };
121
+ }
122
+
87
123
  // Distinguishes notification handlers from filter handlers in the HANDLERS
88
124
  // map below. Internal, not part of the public API.
89
125
  const HandlerKind = Object.freeze({
@@ -126,6 +162,11 @@ const HANDLERS = Object.freeze({
126
162
  onSseDisconnect: { event: Events.SseDisconnect, kind: HandlerKind.Notify },
127
163
  // Once-a-second tick
128
164
  onTick: { event: Events.Tick, kind: HandlerKind.Notify },
165
+ // Verified inbound ActivityPub activity (raw JSON object)
166
+ onFediverse: {
167
+ event: Events.FediverseActivity,
168
+ kind: HandlerKind.Notify,
169
+ },
129
170
  // Fediverse engagement (actor + target metadata)
130
171
  onFediverseFollow: {
131
172
  event: Events.FediverseFollow,
@@ -136,6 +177,10 @@ const HANDLERS = Object.freeze({
136
177
  event: Events.FediverseRepost,
137
178
  kind: HandlerKind.Notify,
138
179
  },
180
+ onFediverseQuote: {
181
+ event: Events.FediverseQuote,
182
+ kind: HandlerKind.Notify,
183
+ },
139
184
  // Fediverse inbound posts (with content)
140
185
  onFediverseMention: {
141
186
  event: Events.FediverseMention,
@@ -144,156 +189,70 @@ const HANDLERS = Object.freeze({
144
189
  onFediverseReply: { event: Events.FediverseReply, kind: HandlerKind.Notify },
145
190
  });
146
191
 
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;
192
+ const isFn = (x) => typeof x === "function";
193
+ const isObj = (x) => x !== null && typeof x === "object";
155
194
 
156
195
  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
196
  registered = def;
180
- return def;
181
- }
197
+ commandManifest.length = 0;
198
+ if (!def || !isObj(def.commands)) return def;
182
199
 
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 });
200
+ const prefix =
201
+ def.commandPrefix == null ? DefaultCommandPrefix : def.commandPrefix;
202
+ if (typeof prefix !== "string" || prefix.length === 0) {
203
+ throw new TypeError("commandPrefix must be a non-empty string");
204
+ }
205
+ const caseSensitive = !!def.commandsCaseSensitive;
206
+ for (const name of Object.keys(def.commands)) {
207
+ const command = def.commands[name];
208
+ if (!isObj(command)) {
209
+ throw new TypeError(`command "${name}" must be an object`);
210
+ }
211
+ const aliases = command.aliases == null ? [] : command.aliases;
212
+ if (
213
+ !Array.isArray(aliases) ||
214
+ !aliases.every((alias) => typeof alias === "string")
215
+ ) {
216
+ throw new TypeError(`command "${name}" aliases must be an array of strings`);
217
+ }
218
+ const cooldownMs =
219
+ command.cooldownMs == null ? 0 : command.cooldownMs;
220
+ if (!Number.isSafeInteger(cooldownMs) || cooldownMs < 0) {
221
+ throw new TypeError(`command "${name}" cooldownMs must be a non-negative integer`);
222
+ }
223
223
  commandManifest.push({
224
224
  name,
225
225
  prefix,
226
- description: def.description || "",
227
- usage: def.usage || "",
228
- aliases: def.aliases || [],
229
- modOnly: !!def.modOnly,
226
+ description: command.description || "",
227
+ usage: command.usage || "",
228
+ aliases,
229
+ modOnly: !!command.modOnly,
230
+ caseSensitive,
231
+ cooldownMs,
230
232
  });
231
233
  }
234
+ return def;
235
+ }
232
236
 
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
- }
237
+ function dispatchCommand(event) {
238
+ if (!isObj(event)) return;
239
+ if (!registered || !isObj(registered.commands)) return;
240
+ const command = registered.commands[event.command];
241
+ if (!command || !isFn(command.run)) return;
293
242
 
294
- if (isFn(def.run)) def.run(ctx);
295
- return true;
296
- };
243
+ const msg = event.message;
244
+ command.run({
245
+ msg,
246
+ user: msg && msg.user,
247
+ command: event.command,
248
+ invokedAs: event.invokedAs || event.command,
249
+ args: event.args || [],
250
+ argString: event.argString || "",
251
+ reply: (text) => owncast.chat.send(text),
252
+ replyPrivately: (text) => {
253
+ if (!owncast.chat.replyTo(msg, text)) owncast.chat.send(text);
254
+ },
255
+ });
297
256
  }
298
257
 
299
258
  // Used by the build-generated entry to compute subscriptions for register().
@@ -324,9 +283,8 @@ function describeSubscriptions() {
324
283
  return { notify, filter: filterSubs };
325
284
  }
326
285
 
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.
286
+ // Used by the build-generated entry to report command registrations to the
287
+ // host for matching, dispatch, and the unified `!help`.
330
288
  function describeCommands() {
331
289
  return commandManifest;
332
290
  }
@@ -336,7 +294,7 @@ function dispatchEvent(envelope) {
336
294
  // Internal: a host-scheduled timer elapsed. Run the author's callback,
337
295
  // dropping one-shot entries first so a throw still cleans up. Not routed to
338
296
  // user handlers or the `on` map.
339
- if (eventType === "timer.fire") {
297
+ if (eventType === InternalEvents.TimerFire) {
340
298
  const id = payload && payload.id;
341
299
  const entry = timerCallbacks.get(id);
342
300
  if (entry) {
@@ -345,6 +303,10 @@ function dispatchEvent(envelope) {
345
303
  }
346
304
  return;
347
305
  }
306
+ if (eventType === InternalEvents.ChatCommand) {
307
+ dispatchCommand(payload);
308
+ return;
309
+ }
348
310
  if (!registered) return;
349
311
  for (const [method, info] of Object.entries(HANDLERS)) {
350
312
  if (
@@ -391,16 +353,6 @@ function dispatchHttp(request) {
391
353
  };
392
354
  }
393
355
 
394
- // permError builds an actionable Error and logs it to stderr (which the
395
- // host runtime captures), so a plugin author running `owncast-plugin
396
- // serve` or hitting the host's logs sees exactly which permission to
397
- // add to their manifest. apiName is the SDK call the author wrote
398
- // (e.g. "owncast.actions.set"); perm is the manifest permission string.
399
- function permError(apiName, perm) {
400
- const msg = `${apiName} requires the '${perm}' permission. Add it to your plugin.manifest.json's "permissions" array.`;
401
- console.error(`[owncast-plugin] ${msg}`);
402
- return new Error(msg);
403
- }
404
356
 
405
357
  // scheduleTimer registers a callback and asks the host to schedule it. The id
406
358
  // is guest-allocated and echoed back on "timer.fire". Throws if the host
@@ -423,50 +375,110 @@ function scheduleTimer(fn, ms, repeat) {
423
375
  return id;
424
376
  }
425
377
 
378
+ // hostFns returns the complete host import table. Missing imports indicate an
379
+ // incompatible host. Permission denials are reported by result-returning calls.
380
+ function hostFns(name, perm) {
381
+ const fns = Host.getFunctions();
382
+ if (!fns[name]) throw new Error(`permission '${perm}' not granted`);
383
+ return fns;
384
+ }
385
+
386
+ function operationResult(offset, failureMessage) {
387
+ if (offset == 0) return { error: failureMessage };
388
+ try {
389
+ const result = JSON.parse(Memory.find(offset).readString());
390
+ if (result === null || typeof result !== "object" || Array.isArray(result)) {
391
+ return { error: failureMessage };
392
+ }
393
+ return result;
394
+ } catch {
395
+ return { error: failureMessage };
396
+ }
397
+ }
398
+
399
+ function requireOperationResult(offset, failureMessage) {
400
+ const result = operationResult(offset, failureMessage);
401
+ if (Object.prototype.hasOwnProperty.call(result, "error")) {
402
+ throw new Error(result.error || failureMessage);
403
+ }
404
+ return result;
405
+ }
406
+
407
+ function sqlResult(offset) {
408
+ return requireOperationResult(offset, "SQL host call failed");
409
+ }
410
+
411
+ function sqlRows(result) {
412
+ if (!Array.isArray(result.columns) || !Array.isArray(result.rows)) {
413
+ throw new Error("SQL host returned an invalid result");
414
+ }
415
+ return result.rows.map((values) => {
416
+ if (!Array.isArray(values)) {
417
+ throw new Error("SQL host returned an invalid result");
418
+ }
419
+ return Object.fromEntries(result.columns.map((column, i) => [column, values[i]]));
420
+ });
421
+ }
422
+
423
+ function sqlQuery(sql, params, maxRows) {
424
+ const fns = hostFns("owncast_sql_query", Permissions.StorageSQL);
425
+ const payload = { sql: String(sql), params: Array.from(params || []) };
426
+ if (maxRows) payload.maxRows = maxRows;
427
+ const request = Memory.fromString(JSON.stringify(payload));
428
+ return sqlResult(fns.owncast_sql_query(request.offset));
429
+ }
430
+
431
+ function logToHost(name, message) {
432
+ const fn = Host.getFunctions()[name];
433
+ if (!fn) throw new Error("owncast.log is unavailable in this host");
434
+ fn(Memory.fromString(String(message)).offset);
435
+ }
436
+
426
437
  const owncast = {
438
+ log: {
439
+ info(message) {
440
+ logToHost("owncast_log_info", message);
441
+ },
442
+ warning(message) {
443
+ logToHost("owncast_log_warning", message);
444
+ },
445
+ error(message) {
446
+ logToHost("owncast_log_error", message);
447
+ },
448
+ },
427
449
  chat: {
428
450
  send(text) {
429
- const fns = Host.getFunctions();
430
- if (!fns.owncast_send_chat)
431
- throw new Error(`permission '${Permissions.ChatSend}' not granted`);
451
+ const fns = hostFns("owncast_send_chat", Permissions.ChatSend);
432
452
  fns.owncast_send_chat(Memory.fromString(text).offset);
433
453
  },
434
454
  sendAction(text) {
435
- const fns = Host.getFunctions();
436
- if (!fns.owncast_send_chat_action)
437
- throw new Error(`permission '${Permissions.ChatSend}' not granted`);
455
+ const fns = hostFns("owncast_send_chat_action", Permissions.ChatSend);
438
456
  fns.owncast_send_chat_action(Memory.fromString(text).offset);
439
457
  },
440
458
  system(body) {
441
- const fns = Host.getFunctions();
442
- if (!fns.owncast_send_chat_system)
443
- throw new Error(`permission '${Permissions.ChatSend}' not granted`);
459
+ const fns = hostFns("owncast_send_chat_system", Permissions.ChatSend);
444
460
  fns.owncast_send_chat_system(Memory.fromString(body).offset);
445
461
  },
446
462
  history(limit) {
447
- const fns = Host.getFunctions();
448
- if (!fns.owncast_chat_history)
449
- throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
463
+ const fns = hostFns("owncast_chat_history", Permissions.ChatHistory);
450
464
  const offset = fns.owncast_chat_history(limit || 0);
451
465
  if (offset == 0) return [];
452
466
  return JSON.parse(Memory.find(offset).readString());
453
467
  },
454
468
  deleteMessage(messageId) {
455
- const fns = Host.getFunctions();
456
- if (!fns.owncast_delete_message)
457
- throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
458
- fns.owncast_delete_message(Memory.fromString(String(messageId)).offset);
469
+ const fns = hostFns("owncast_delete_message", Permissions.ChatModerate);
470
+ const offset = fns.owncast_delete_message(
471
+ Memory.fromString(String(messageId)).offset,
472
+ );
473
+ requireOperationResult(offset, "chat.deleteMessage failed");
459
474
  },
460
475
  kick(clientId) {
461
- const fns = Host.getFunctions();
462
- if (!fns.owncast_kick_client)
463
- throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
464
- fns.owncast_kick_client(BigInt(clientId));
476
+ const fns = hostFns("owncast_kick_client", Permissions.ChatModerate);
477
+ const offset = fns.owncast_kick_client(BigInt(clientId));
478
+ requireOperationResult(offset, "chat.kick failed");
465
479
  },
466
480
  sendTo(clientId, text) {
467
- const fns = Host.getFunctions();
468
- if (!fns.owncast_send_chat_to)
469
- throw new Error(`permission '${Permissions.ChatSend}' not granted`);
481
+ const fns = hostFns("owncast_send_chat_to", Permissions.ChatSend);
470
482
  fns.owncast_send_chat_to(
471
483
  BigInt(clientId),
472
484
  Memory.fromString(text).offset,
@@ -487,9 +499,7 @@ const owncast = {
487
499
  return true;
488
500
  },
489
501
  clients() {
490
- const fns = Host.getFunctions();
491
- if (!fns.owncast_chat_clients)
492
- throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
502
+ const fns = hostFns("owncast_chat_clients", Permissions.ChatHistory);
493
503
  const offset = fns.owncast_chat_clients();
494
504
  if (offset == 0) return [];
495
505
  return JSON.parse(Memory.find(offset).readString());
@@ -497,49 +507,79 @@ const owncast = {
497
507
  },
498
508
  users: {
499
509
  list() {
500
- const fns = Host.getFunctions();
501
- if (!fns.owncast_users_list)
502
- throw new Error(`permission '${Permissions.UsersRead}' not granted`);
510
+ const fns = hostFns("owncast_users_list", Permissions.UsersRead);
503
511
  const offset = fns.owncast_users_list();
504
512
  if (offset == 0) return [];
505
513
  return JSON.parse(Memory.find(offset).readString());
506
514
  },
507
515
  get(id) {
508
- const fns = Host.getFunctions();
509
- if (!fns.owncast_user_get)
510
- throw new Error(`permission '${Permissions.UsersRead}' not granted`);
516
+ const fns = hostFns("owncast_user_get", Permissions.UsersRead);
511
517
  const offset = fns.owncast_user_get(Memory.fromString(id).offset);
512
518
  if (offset == 0) return null;
513
519
  return JSON.parse(Memory.find(offset).readString());
514
520
  },
515
521
  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
- );
521
- fns.owncast_user_set_enabled(
522
+ const fns = hostFns("owncast_user_set_enabled", Permissions.UsersModerate);
523
+ const offset = fns.owncast_user_set_enabled(
522
524
  Memory.fromString(id).offset,
523
525
  enabled ? 1 : 0,
524
526
  Memory.fromString(reason || "").offset,
525
527
  );
528
+ requireOperationResult(offset, "users.setEnabled failed");
526
529
  },
527
530
  banIP(ip) {
528
- const fns = Host.getFunctions();
529
- if (!fns.owncast_ban_ip)
530
- throw new Error(
531
- `permission '${Permissions.UsersModerate}' not granted`,
532
- );
533
- fns.owncast_ban_ip(Memory.fromString(ip).offset);
531
+ const fns = hostFns("owncast_ban_ip", Permissions.UsersModerate);
532
+ const offset = fns.owncast_ban_ip(Memory.fromString(ip).offset);
533
+ requireOperationResult(offset, "users.banIP failed");
534
+ },
535
+ // Find or create an authenticated Owncast user for an external identity.
536
+ // profileUrl and handle describe a verified profile. public opts that
537
+ // identity into public display. Returns { userId }. Throws on host error.
538
+ // Requires `users.register`.
539
+ register(opts) {
540
+ const fns = hostFns("owncast_users_register", Permissions.UsersRegister);
541
+ const source = typeof opts === "string" ? { authId: opts } : opts || {};
542
+ const req = {
543
+ authId: source.authId,
544
+ displayName: source.displayName,
545
+ scopes: source.scopes,
546
+ profileUrl: source.profileUrl,
547
+ handle: source.handle,
548
+ public: source.public,
549
+ };
550
+ const offset = fns.owncast_users_register(
551
+ Memory.fromString(JSON.stringify(req)).offset,
552
+ );
553
+ return requireOperationResult(offset, "users.register failed"); // { userId }
554
+ },
555
+ },
556
+ // Viewer-authentication gate. Only a plugin holding `auth.gate` (and enabled by
557
+ // an admin) can issue sessions, and these are valid only inside onHttpRequest,
558
+ // where the host attaches/clears the signed session cookie on the response.
559
+ // The admin selects the cumulative, host-owned access mode. Plugins cannot
560
+ // read or change it.
561
+ auth: {
562
+ // Issue a gate session for an already-registered user (see users.register).
563
+ // `ttl` is optional seconds, and 0/omitted uses the host default. Throws on
564
+ // host error. Requires `auth.gate`.
565
+ grantSession(opts) {
566
+ const fns = hostFns("owncast_auth_grant_session", Permissions.AuthGate);
567
+ const req = typeof opts === "string" ? { userId: opts } : opts || {};
568
+ const offset = fns.owncast_auth_grant_session(
569
+ Memory.fromString(JSON.stringify(req)).offset,
570
+ );
571
+ requireOperationResult(offset, "auth.grantSession failed");
572
+ },
573
+ // Clear the current viewer's gate session (logout). The plugin still owns the
574
+ // response/redirect. Requires `auth.gate`.
575
+ endSession() {
576
+ const fns = hostFns("owncast_auth_end_session", Permissions.AuthGate);
577
+ fns.owncast_auth_end_session();
534
578
  },
535
579
  },
536
580
  storage: {
537
581
  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
- );
582
+ const fns = hostFns("owncast_storage_upload", Permissions.StorageUpload);
543
583
  const dataMem =
544
584
  data instanceof Uint8Array
545
585
  ? Memory.fromBuffer(
@@ -557,7 +597,7 @@ const owncast = {
557
597
  return JSON.parse(Memory.find(offset).readString());
558
598
  },
559
599
  },
560
- // Private, sandboxed filesystem under data/plugin-data/<slug>/. Unlike
600
+ // Private, sandboxed filesystem under data/plugin-storage/<slug>/files/. Unlike
561
601
  // storage.upload (which publishes browser-accessible files), these bytes
562
602
  // stay server-side. The host confines every path to this plugin's own
563
603
  // directory. All methods require the 'storage.fs' permission.
@@ -565,9 +605,7 @@ const owncast = {
565
605
  // Read a file's raw bytes. Returns a Uint8Array, or null if the file
566
606
  // doesn't exist (or can't be read).
567
607
  read(path) {
568
- const fns = Host.getFunctions();
569
- if (!fns.owncast_fs_read)
570
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
608
+ const fns = hostFns("owncast_fs_read", Permissions.StorageFS);
571
609
  const offset = fns.owncast_fs_read(Memory.fromString(path).offset);
572
610
  if (offset == 0) return null;
573
611
  return new Uint8Array(Memory.find(offset).readBytes());
@@ -575,19 +613,15 @@ const owncast = {
575
613
  // Read a file as UTF-8 text. Returns a string, or null if the file
576
614
  // doesn't exist. (The Extism boundary decodes the bytes as UTF-8.)
577
615
  readText(path) {
578
- const fns = Host.getFunctions();
579
- if (!fns.owncast_fs_read)
580
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
616
+ const fns = hostFns("owncast_fs_read", Permissions.StorageFS);
581
617
  const offset = fns.owncast_fs_read(Memory.fromString(path).offset);
582
618
  if (offset == 0) return null;
583
619
  return Memory.find(offset).readString();
584
620
  },
585
621
  // Write bytes (Uint8Array) or a string to a file, creating parent
586
- // directories as needed. Returns { ok, error? }.
622
+ // directories as needed. Returns { error? }.
587
623
  write(path, data) {
588
- const fns = Host.getFunctions();
589
- if (!fns.owncast_fs_write)
590
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
624
+ const fns = hostFns("owncast_fs_write", Permissions.StorageFS);
591
625
  const dataMem =
592
626
  data instanceof Uint8Array
593
627
  ? Memory.fromBuffer(
@@ -601,46 +635,51 @@ const owncast = {
601
635
  Memory.fromString(path).offset,
602
636
  dataMem.offset,
603
637
  );
604
- if (offset == 0) return { ok: false, error: "write failed" };
605
- return JSON.parse(Memory.find(offset).readString());
638
+ return operationResult(offset, "write failed");
606
639
  },
607
640
  // List the entry names (files and subdirectories) directly inside dir.
608
641
  // A missing directory lists as empty. Returns string[].
609
642
  list(dir) {
610
- const fns = Host.getFunctions();
611
- if (!fns.owncast_fs_list)
612
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
643
+ const fns = hostFns("owncast_fs_list", Permissions.StorageFS);
613
644
  const offset = fns.owncast_fs_list(Memory.fromString(dir || "").offset);
614
645
  if (offset == 0) return [];
615
646
  return JSON.parse(Memory.find(offset).readString());
616
647
  },
617
- // Remove a single file or empty directory. Returns { ok, error? }.
648
+ // Remove a single file or empty directory. Returns { error? }.
618
649
  delete(path) {
619
- const fns = Host.getFunctions();
620
- if (!fns.owncast_fs_delete)
621
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
650
+ const fns = hostFns("owncast_fs_delete", Permissions.StorageFS);
622
651
  const offset = fns.owncast_fs_delete(Memory.fromString(path).offset);
623
- if (offset == 0) return { ok: false, error: "delete failed" };
624
- return JSON.parse(Memory.find(offset).readString());
652
+ return operationResult(offset, "delete failed");
625
653
  },
626
654
  // Report whether a path exists inside the sandbox. Returns boolean.
627
655
  exists(path) {
628
- const fns = Host.getFunctions();
629
- if (!fns.owncast_fs_exists)
630
- throw new Error(`permission '${Permissions.StorageFS}' not granted`);
656
+ const fns = hostFns("owncast_fs_exists", Permissions.StorageFS);
631
657
  return fns.owncast_fs_exists(Memory.fromString(path).offset) === 1;
632
658
  },
633
659
  },
660
+ sql: {
661
+ exec(sql, params = []) {
662
+ const fns = hostFns("owncast_sql_exec", Permissions.StorageSQL);
663
+ const request = Memory.fromString(
664
+ JSON.stringify({ sql: String(sql), params: Array.from(params || []) }),
665
+ );
666
+ return sqlResult(fns.owncast_sql_exec(request.offset));
667
+ },
668
+ query(sql, params = []) {
669
+ return sqlRows(sqlQuery(sql, params));
670
+ },
671
+ queryRow(sql, params = []) {
672
+ // Asking the host for one row keeps a first-row read off the result
673
+ // budget, so this works against a table `query` would be too big for.
674
+ return sqlRows(sqlQuery(sql, params, 1))[0] || null;
675
+ },
676
+ },
634
677
  fediverse: {
635
678
  /** Publish a public text-only post to the fediverse on the streamer's
636
679
  * behalf. Returns { url } on success, null on failure (rate-limited,
637
680
  * disabled by admin, etc.). Requires `fediverse.post`. */
638
681
  post(text) {
639
- const fns = Host.getFunctions();
640
- if (!fns.owncast_fediverse_post)
641
- throw new Error(
642
- `permission '${Permissions.FediversePost}' not granted`,
643
- );
682
+ const fns = hostFns("owncast_fediverse_post", Permissions.FediversePost);
644
683
  const offset = fns.owncast_fediverse_post(Memory.fromString(text).offset);
645
684
  if (offset == 0) return null;
646
685
  return JSON.parse(Memory.find(offset).readString());
@@ -648,30 +687,18 @@ const owncast = {
648
687
  },
649
688
  notifications: {
650
689
  discord(text) {
651
- const fns = Host.getFunctions();
652
- if (!fns.owncast_notify_discord)
653
- throw new Error(
654
- `permission '${Permissions.NotificationsSend}' not granted`,
655
- );
690
+ const fns = hostFns("owncast_notify_discord", Permissions.NotificationsSend);
656
691
  fns.owncast_notify_discord(Memory.fromString(text).offset);
657
692
  },
658
693
  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
- );
694
+ const fns = hostFns("owncast_notify_browser_push", Permissions.NotificationsSend);
664
695
  const obj = typeof payload === "string" ? { title: payload } : payload;
665
696
  fns.owncast_notify_browser_push(
666
697
  Memory.fromString(JSON.stringify(obj)).offset,
667
698
  );
668
699
  },
669
700
  fediverse(payload) {
670
- const fns = Host.getFunctions();
671
- if (!fns.owncast_notify_fediverse)
672
- throw new Error(
673
- `permission '${Permissions.NotificationsSend}' not granted`,
674
- );
701
+ const fns = hostFns("owncast_notify_fediverse", Permissions.NotificationsSend);
675
702
  fns.owncast_notify_fediverse(
676
703
  Memory.fromString(JSON.stringify(payload)).offset,
677
704
  );
@@ -679,17 +706,13 @@ const owncast = {
679
706
  },
680
707
  stream: {
681
708
  current() {
682
- const fns = Host.getFunctions();
683
- if (!fns.owncast_stream_current)
684
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
709
+ const fns = hostFns("owncast_stream_current", Permissions.ServerRead);
685
710
  const offset = fns.owncast_stream_current();
686
711
  if (offset == 0) return { online: false, viewers: 0 };
687
712
  return JSON.parse(Memory.find(offset).readString());
688
713
  },
689
714
  broadcaster() {
690
- const fns = Host.getFunctions();
691
- if (!fns.owncast_stream_broadcaster)
692
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
715
+ const fns = hostFns("owncast_stream_broadcaster", Permissions.ServerRead);
693
716
  const offset = fns.owncast_stream_broadcaster();
694
717
  if (offset == 0) return {};
695
718
  return JSON.parse(Memory.find(offset).readString());
@@ -697,41 +720,31 @@ const owncast = {
697
720
  },
698
721
  server: {
699
722
  info() {
700
- const fns = Host.getFunctions();
701
- if (!fns.owncast_server_info)
702
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
723
+ const fns = hostFns("owncast_server_info", Permissions.ServerRead);
703
724
  const offset = fns.owncast_server_info();
704
725
  if (offset == 0) return {};
705
726
  return JSON.parse(Memory.find(offset).readString());
706
727
  },
707
728
  socials() {
708
- const fns = Host.getFunctions();
709
- if (!fns.owncast_server_socials)
710
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
729
+ const fns = hostFns("owncast_server_socials", Permissions.ServerRead);
711
730
  const offset = fns.owncast_server_socials();
712
731
  if (offset == 0) return [];
713
732
  return JSON.parse(Memory.find(offset).readString());
714
733
  },
715
734
  emotes() {
716
- const fns = Host.getFunctions();
717
- if (!fns.owncast_server_emotes)
718
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
735
+ const fns = hostFns("owncast_server_emotes", Permissions.ServerRead);
719
736
  const offset = fns.owncast_server_emotes();
720
737
  if (offset == 0) return [];
721
738
  return JSON.parse(Memory.find(offset).readString());
722
739
  },
723
740
  federation() {
724
- const fns = Host.getFunctions();
725
- if (!fns.owncast_server_federation)
726
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
741
+ const fns = hostFns("owncast_server_federation", Permissions.ServerRead);
727
742
  const offset = fns.owncast_server_federation();
728
743
  if (offset == 0) return { enabled: false };
729
744
  return JSON.parse(Memory.find(offset).readString());
730
745
  },
731
746
  tags() {
732
- const fns = Host.getFunctions();
733
- if (!fns.owncast_server_tags)
734
- throw new Error(`permission '${Permissions.ServerRead}' not granted`);
747
+ const fns = hostFns("owncast_server_tags", Permissions.ServerRead);
735
748
  const offset = fns.owncast_server_tags();
736
749
  if (offset == 0) return [];
737
750
  return JSON.parse(Memory.find(offset).readString());
@@ -741,50 +754,36 @@ const owncast = {
741
754
  /** Read the current video/transcoding config: { latencyLevel, codec,
742
755
  * variants }. Requires `videoconfig.read`. */
743
756
  read() {
744
- const fns = Host.getFunctions();
745
- if (!fns.owncast_video_config_read)
746
- throw new Error(
747
- `permission '${Permissions.VideoConfigRead}' not granted`,
748
- );
757
+ const fns = hostFns("owncast_video_config_read", Permissions.VideoConfigRead);
749
758
  const offset = fns.owncast_video_config_read();
750
759
  if (offset == 0) return { latencyLevel: 0, codec: "", variants: [] };
751
760
  return JSON.parse(Memory.find(offset).readString());
752
761
  },
753
762
  /** Apply a partial video config change. Pass any of { latencyLevel, codec,
754
- * variants }; omitted fields are left unchanged. Throws if the host
763
+ * variants }, where omitted fields are left unchanged. Throws if the host
755
764
  * rejects the config. Requires `videoconfig.write`. */
756
765
  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
- );
766
+ const fns = hostFns("owncast_video_config_write", Permissions.VideoConfigWrite);
762
767
  const offset = fns.owncast_video_config_write(
763
768
  Memory.fromString(JSON.stringify(config || {})).offset,
764
769
  );
765
- if (offset == 0) throw new Error("videoConfig.write failed");
766
- const result = JSON.parse(Memory.find(offset).readString());
767
- if (!result.ok)
768
- throw new Error(result.error || "videoConfig.write failed");
770
+ requireOperationResult(offset, "videoConfig.write failed");
769
771
  },
770
772
  },
771
773
  kv: {
772
774
  get(key) {
773
- const fns = Host.getFunctions();
774
- if (!fns.owncast_kv_get)
775
- throw new Error(`permission '${Permissions.StorageKV}' not granted`);
775
+ const fns = hostFns("owncast_kv_get", Permissions.StorageKV);
776
776
  const offset = fns.owncast_kv_get(Memory.fromString(key).offset);
777
777
  if (offset == 0) return null;
778
778
  return Memory.find(offset).readString();
779
779
  },
780
780
  set(key, value) {
781
- const fns = Host.getFunctions();
782
- if (!fns.owncast_kv_set)
783
- throw new Error(`permission '${Permissions.StorageKV}' not granted`);
784
- fns.owncast_kv_set(
781
+ const fns = hostFns("owncast_kv_set", Permissions.StorageKV);
782
+ const offset = fns.owncast_kv_set(
785
783
  Memory.fromString(key).offset,
786
784
  Memory.fromString(String(value)).offset,
787
785
  );
786
+ requireOperationResult(offset, "kv.set failed");
788
787
  },
789
788
  // getJSON/setJSON are convenience wrappers over the string-only store, so
790
789
  // plugins don't reimplement JSON.parse/stringify for every stored object.
@@ -837,9 +836,7 @@ const owncast = {
837
836
  },
838
837
  events: {
839
838
  emit(eventType, payload) {
840
- const fns = Host.getFunctions();
841
- if (!fns.owncast_emit_event)
842
- throw new Error(`permission '${Permissions.EventsEmit}' not granted`);
839
+ const fns = hostFns("owncast_emit_event", Permissions.EventsEmit);
843
840
  fns.owncast_emit_event(
844
841
  Memory.fromString(eventType).offset,
845
842
  Memory.fromString(JSON.stringify(payload)).offset,
@@ -847,43 +844,40 @@ const owncast = {
847
844
  },
848
845
  },
849
846
  actions: {
850
- // Append one or more action buttons to the plugin's effective list
851
- // (manifest.actions ++ runtime additions). Accepts a single button
852
- // object or an array. The host validates each entry (title
853
- // required, exactly one of url/html, relative URLs rewritten into
854
- // this plugin's namespace, cross-plugin URLs rejected) and persists
855
- // the result, so the next /api/config request returns the longer
856
- // list. Requires 'ui.modify'.
847
+ // Append one or more action buttons to the plugin's effective list.
848
+ // The host validates and persists the list, returning { error? }.
857
849
  add(actions) {
858
- const fns = Host.getFunctions();
859
- if (!fns.owncast_add_actions)
860
- throw permError("owncast.actions.add", Permissions.UIModify);
850
+ const fns = hostFns("owncast_add_actions", Permissions.UIModify);
861
851
  const list = Array.isArray(actions) ? actions : [actions];
862
- fns.owncast_add_actions(Memory.fromString(JSON.stringify(list)).offset);
852
+ requireOperationResult(
853
+ fns.owncast_add_actions(
854
+ Memory.fromString(JSON.stringify(list)).offset,
855
+ ),
856
+ "owncast.actions.add failed",
857
+ );
863
858
  },
864
859
  // Drop the runtime additions so only manifest.actions remain in
865
860
  // the effective list on the next /api/config request. Requires
866
861
  // 'ui.modify'.
867
862
  clear() {
868
- const fns = Host.getFunctions();
869
- if (!fns.owncast_clear_actions)
870
- throw permError("owncast.actions.clear", Permissions.UIModify);
871
- fns.owncast_clear_actions();
863
+ const fns = hostFns("owncast_clear_actions", Permissions.UIModify);
864
+ requireOperationResult(
865
+ fns.owncast_clear_actions(),
866
+ "owncast.actions.clear failed",
867
+ );
872
868
  },
873
869
  },
874
870
  sse: {
875
871
  // send(channel, event, data) pushes one Server-Sent-Event to every
876
872
  // browser connected to this plugin's /plugins/<name>/_sse/<channel>
877
873
  // stream. `event` is the SSE event name (browser side:
878
- // source.addEventListener(event, ...)); pass "" for the default
874
+ // source.addEventListener(event, ...)). Pass "" for the default
879
875
  // "message" event. `data` is sent as-is if it's a string, otherwise
880
876
  // JSON-stringified. Fire-and-forget: returns immediately, and frames to
881
877
  // a slow client are dropped rather than blocking the plugin. Requires
882
878
  // the 'http.sse' permission.
883
879
  send(channel, event, data) {
884
- const fns = Host.getFunctions();
885
- if (!fns.owncast_sse_send)
886
- throw new Error(`permission '${Permissions.HttpSSE}' not granted`);
880
+ const fns = hostFns("owncast_sse_send", Permissions.HttpSSE);
887
881
  const payload = typeof data === "string" ? data : JSON.stringify(data);
888
882
  fns.owncast_sse_send(
889
883
  Memory.fromString(channel || "").offset,
@@ -895,7 +889,7 @@ const owncast = {
895
889
  timer: {
896
890
  // setTimeout(fn, ms) runs fn once after ~ms milliseconds. setInterval
897
891
  // repeats until clear(id). The host drives the schedule (the sandbox has
898
- // no setTimeout); your callback runs in this instance when it fires.
892
+ // no setTimeout). Your callback runs in this instance when it fires.
899
893
  // Returns an id for clear(). Very small delays are clamped up by the host,
900
894
  // and there's a per-plugin cap on pending timers (throws past it).
901
895
  // Note: timers are in-memory and do not survive a plugin reload or a host
@@ -945,11 +939,21 @@ function dispatchPageContent(req) {
945
939
  return registered.onPageContent(req) || "";
946
940
  }
947
941
 
942
+ function dispatchPageStyles() {
943
+ if (!registered || !isFn(registered.onPageStyles)) return "";
944
+ return registered.onPageStyles() || "";
945
+ }
946
+
947
+ function dispatchPageScripts() {
948
+ if (!registered || !isFn(registered.onPageScripts)) return "";
949
+ return registered.onPageScripts() || "";
950
+ }
951
+
948
952
  module.exports = {
949
953
  definePlugin,
950
- defineCommands,
951
954
  owncast,
952
955
  filter,
956
+ authCheck,
953
957
  FilterAction,
954
958
  Events,
955
959
  Permissions,
@@ -958,6 +962,9 @@ module.exports = {
958
962
  dispatchEvent,
959
963
  dispatchFilter,
960
964
  dispatchHttp,
965
+ dispatchAuthCheck,
961
966
  dispatchTabContent,
962
967
  dispatchPageContent,
968
+ dispatchPageStyles,
969
+ dispatchPageScripts,
963
970
  };