@owncast/plugin-sdk 0.10.1 → 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.
@@ -31,6 +31,14 @@ function fail(e) {
31
31
  // Same shape the host + SDK + registry all validate against.
32
32
  const slugPattern = /^[a-z][a-z0-9-]{0,63}$/;
33
33
 
34
+ // Canonical registry browse categories for the optional manifest
35
+ // `category` field. Shared taxonomy with the registry and the admin UI.
36
+ const categories = new Set([
37
+ "chat-bots", "chat-filters", "moderation", "authentication", "themes",
38
+ "overlays", "notifications", "integrations", "video", "analytics",
39
+ "games", "admin-utilities", "examples", "other",
40
+ ]);
41
+
34
42
  // readAndResolveManifest loads plugin.manifest.json, validates the
35
43
  // required fields, and returns a manifest object with `slug` filled
36
44
  // in: either the author's explicit `slug`, or one auto-derived from
@@ -59,6 +67,13 @@ function readAndResolveManifest(manifestPath) {
59
67
  `manifest.slug ${JSON.stringify(slug)} must match ${slugPattern} (lowercase letters/digits/hyphens, starting with a letter, max 64 chars)`,
60
68
  );
61
69
  }
70
+ if (manifest.category !== undefined && !categories.has(manifest.category)) {
71
+ // Warn only: the host and registry tolerate unknown categories, they
72
+ // just won't match any browse filter.
73
+ console.warn(
74
+ `warning: manifest.category ${JSON.stringify(manifest.category)} is not a known category (${[...categories].join(", ")})`,
75
+ );
76
+ }
62
77
  manifest.slug = slug;
63
78
  return manifest;
64
79
  }
package/index.d.ts CHANGED
@@ -71,20 +71,36 @@ export interface StreamBroadcaster {
71
71
  bitrates?: number[];
72
72
  }
73
73
 
74
+ /** Viewer autoplay behavior. */
75
+ export type AutoplayMode = "off" | "always" | "sound-only";
76
+
77
+ /** H.264 encoder names accepted by Owncast video config writes.
78
+ * Hardware encoders must also be available in ffmpeg. */
79
+ export type VideoCodec =
80
+ | "libx264"
81
+ | "h264_omx"
82
+ | "h264_vaapi"
83
+ | "h264_qsv"
84
+ | "h264_nvenc"
85
+ | "h264_v4l2m2m"
86
+ | "h264_videotoolbox";
87
+
74
88
  /** One configured output rendition, part of VideoConfig (owncast.videoConfig). */
75
89
  export interface StreamVariant {
76
90
  width: number;
77
91
  height: number;
78
92
  framerate: number;
79
93
  videoBitrate: number;
80
- audioBitrate: number;
94
+ cpuUsageLevel: number;
81
95
  isPassthrough: boolean;
82
96
  }
83
97
 
84
98
  /** The current video/transcoding config returned by owncast.videoConfig.read(). */
85
99
  export interface VideoConfig {
86
100
  latencyLevel: number;
101
+ /** The configured encoder. Reads may report a legacy or newer host value. */
87
102
  codec: string;
103
+ autoplay: AutoplayMode;
88
104
  variants: StreamVariant[];
89
105
  }
90
106
 
@@ -92,7 +108,8 @@ export interface VideoConfig {
92
108
  * are left unchanged. */
93
109
  export interface VideoConfigUpdate {
94
110
  latencyLevel?: number;
95
- codec?: string;
111
+ codec?: VideoCodec;
112
+ autoplay?: AutoplayMode;
96
113
  variants?: StreamVariant[];
97
114
  }
98
115
 
@@ -147,6 +164,23 @@ export interface FediverseTargetedEngagement extends FediverseEngagement {
147
164
  target: { url: string };
148
165
  }
149
166
 
167
+ /** An accepted quote request. `target` identifies the local post being quoted,
168
+ * while `url` identifies the remote quote post. Content metadata is present
169
+ * when the requesting server embeds the quote Note in its request. */
170
+ export interface FediverseQuote extends FediverseTargetedEngagement {
171
+ content?: string; // HTML from the source instance
172
+ contentText?: string; // HTML stripped to plain text
173
+ url: string; // permalink to the remote quote post
174
+ postedAt?: string; // ISO-8601
175
+ inReplyTo?: string;
176
+ attachments?: {
177
+ url: string;
178
+ mediaType: string;
179
+ alt?: string;
180
+ }[];
181
+ language?: string;
182
+ }
183
+
150
184
  /** Inbound fediverse post, a mention or reply that contains content the
151
185
  * plugin can act on. Carries both the rendered content (which has the
152
186
  * source instance's HTML) and a plain-text version (HTML stripped). */
@@ -173,6 +207,7 @@ export const Permissions: {
173
207
  readonly StorageKV: "storage.kv";
174
208
  readonly StorageUpload: "storage.upload";
175
209
  readonly StorageFS: "storage.fs";
210
+ readonly StorageSQL: "storage.sql";
176
211
  readonly EventsEmit: "events.emit";
177
212
  readonly NetworkFetch: "network.fetch";
178
213
  readonly HttpServe: "http.serve";
@@ -192,13 +227,18 @@ export const Permissions: {
192
227
 
193
228
  /** Request for `owncast.users.register`. */
194
229
  export interface UserRegisterRequest {
195
- /** Stable, provider-scoped external identity (e.g. `"github:583231"`). The
196
- * host namespaces it by the calling plugin's slug. */
230
+ /** Stable external identity within this plugin's provider namespace. */
197
231
  authId: string;
198
- /** Optional display name to seed on the user. */
199
- displayName?: string;
232
+ /** Optional display name to seed on the user. Omit or pass `null` to generate one. */
233
+ displayName?: string | null;
200
234
  /** Optional scopes to grant the user (e.g. `["MODERATOR"]`). */
201
235
  scopes?: string[];
236
+ /** Verified public profile URL. The host accepts only absolute HTTP(S) URLs. */
237
+ profileUrl?: string;
238
+ /** Label for the verified identity, such as a GitHub login or fediverse handle. */
239
+ handle?: string;
240
+ /** Surface the verified identity publicly only when the viewer opted in. */
241
+ public?: boolean;
202
242
  }
203
243
 
204
244
  /** Result of `owncast.users.register`: the resolved Owncast user ID. */
@@ -277,13 +317,38 @@ export interface UploadResult {
277
317
  url: string;
278
318
  }
279
319
 
280
- /** Result of a mutating owncast.fs call (write/delete). `ok` is false and
281
- * `error` is set when the host rejected the operation. */
320
+ /** Result of a mutating owncast.fs call (write/delete). An empty object means
321
+ * success. `error` is set when the host rejected the operation. */
282
322
  export interface FsResult {
283
- ok: boolean;
284
323
  error?: string;
285
324
  }
286
325
 
326
+ /** A value a plugin can bind to a statement parameter, or read back out of a
327
+ * column. Blobs arrive base64-encoded as strings. */
328
+ export type SQLValue = null | boolean | number | string;
329
+
330
+ /** Result of `owncast.sql.exec`. Absence of `error` means success. Both
331
+ * counters are SQLite 64-bit integers, so they lose precision in JavaScript
332
+ * above `Number.MAX_SAFE_INTEGER`. */
333
+ export interface SQLExecResult {
334
+ error?: string;
335
+ rowsAffected: number;
336
+ lastInsertId: number;
337
+ }
338
+
339
+ /** One row as `owncast.sql.query` hands it back: column name to value. */
340
+ export type SQLRow = Record<string, SQLValue>;
341
+
342
+ /** The host's raw query response, before the SDK keys rows by column name.
343
+ * Absence of `error` means success. `rows` holds values in `columns` order,
344
+ * and `truncated` is set when more rows matched than the caller's row limit. */
345
+ export interface SQLQueryResult {
346
+ error?: string;
347
+ columns: string[];
348
+ rows: SQLValue[][];
349
+ truncated?: boolean;
350
+ }
351
+
287
352
  export const filter: {
288
353
  pass(): FilterResult;
289
354
  modify(payload: any): FilterResult;
@@ -415,8 +480,8 @@ export interface PluginDef {
415
480
  onFediverseLike?(event: FediverseTargetedEngagement): void | Promise<void>;
416
481
  /** Someone on the fediverse boosted (reposted) a streamer post. Requires `fediverse.inbound`. */
417
482
  onFediverseRepost?(event: FediverseTargetedEngagement): void | Promise<void>;
418
- /** Someone on the fediverse quoted a locally authored post. `target.url` identifies that quoted post. Requires `fediverse.inbound`. */
419
- onFediverseQuote?(event: FediverseTargetedEngagement): void | Promise<void>;
483
+ /** Someone on the fediverse quoted a locally authored post. `target.url` identifies the local post and `url` identifies the remote quote post. Requires `fediverse.inbound`. */
484
+ onFediverseQuote?(event: FediverseQuote): void | Promise<void>;
420
485
  /** Someone @-mentioned the streamer in a public post. Requires `fediverse.inbound`. */
421
486
  onFediverseMention?(post: FediverseInboundPost): void | Promise<void>;
422
487
  /** Someone replied to one of the streamer's federated posts. Requires `fediverse.inbound`. */
@@ -470,9 +535,10 @@ export interface PluginDef {
470
535
  * Requires `ui.modify`. */
471
536
  onPageScripts?(): string | null | void;
472
537
 
473
- /** Handlers for plugin-emitted custom events. The key is the event type
474
- * string (e.g. "announcement.broadcast"). Notifications only, to filter
475
- * custom events, additional API will be needed. */
538
+ /** Handlers for custom events owned by this plugin. Keys are local hook names
539
+ * such as "announcement.broadcast". The host registers each hook as
540
+ * `<your-slug>.<hook>`, which emitters use as the target event type.
541
+ * Notifications only. Filtering custom events requires additional API. */
476
542
  on?: { [eventType: string]: (payload: any) => void | Promise<void> };
477
543
 
478
544
  /** Filter chain priority (lower = earlier). Applies to every filter*
@@ -528,9 +594,16 @@ export interface CommandEvent {
528
594
  argString: string;
529
595
  }
530
596
 
531
- /** Typed wrappers around the Owncast host. Each method throws if the
532
- * corresponding permission was not declared in plugin.manifest.json. */
597
+ /** Typed wrappers around the Owncast host. Methods that require a permission
598
+ * say so in their documentation and throw when it was not declared. */
533
599
  export const owncast: {
600
+ /** Write a plugin-attributed entry to Owncast's server log. No permission
601
+ * is required. */
602
+ log: {
603
+ info(message: string): void;
604
+ warning(message: string): void;
605
+ error(message: string): void;
606
+ };
534
607
  chat: {
535
608
  /** Post as the plugin's own chat bot (display name = the plugin's name). */
536
609
  send(text: string): void;
@@ -552,9 +625,11 @@ export const owncast: {
552
625
  /** Recent chat history (most recent last). Requires `chat.history`.
553
626
  * Default limit is 50. Pass a smaller number to get fewer. */
554
627
  history(limit?: number): ChatMessage[];
555
- /** Hide a chat message by ID. Requires `chat.moderate`. */
628
+ /** Hide a chat message by ID. Throws when the host rejects the operation.
629
+ * Requires `chat.moderate`. */
556
630
  deleteMessage(messageId: string): void;
557
- /** Disconnect a chat client by its numeric ID. Requires `chat.moderate`. */
631
+ /** Disconnect a chat client by its numeric ID. Throws when the host rejects
632
+ * the operation. Requires `chat.moderate`. */
558
633
  kick(clientId: number | bigint): void;
559
634
  /** List currently-connected chat clients. Requires `chat.history`. */
560
635
  clients(): ChatClient[];
@@ -565,20 +640,24 @@ export const owncast: {
565
640
  list(): User[];
566
641
  /** Fetch one user by ID. Requires `users.read`. */
567
642
  get(id: string): User | null;
568
- /** Enable/disable a user, with an optional reason. Requires `users.moderate`. */
643
+ /** Enable or disable a user, with an optional reason. Throws when the host
644
+ * rejects the operation. Requires `users.moderate`. */
569
645
  setEnabled(id: string, enabled: boolean, reason?: string): void;
570
- /** Ban an IP address. Requires `users.moderate`. */
646
+ /** Ban an IP address. Throws when the host rejects the operation. Requires
647
+ * `users.moderate`. */
571
648
  banIP(ip: string): void;
572
- /** Find-or-create an authenticated user for an external identity. `authId`
573
- * is the stable provider-scoped id (e.g. `"github:583231"`). The host
574
- * namespaces it by this plugin's slug so plugins can't collide on or spoof
575
- * each other's users. Returns `{ userId }`. Throws on host error.
649
+ /** Find or create an authenticated user for an external identity. The host
650
+ * scopes `authId` to this plugin's slug. `profileUrl` and `handle`
651
+ * describe a verified external profile, and `public` opts that identity
652
+ * into public display. Returns `{ userId }`. Throws on host error.
576
653
  * Requires `users.register`. */
577
654
  register(opts: UserRegisterRequest | string): UserRegisterResult;
578
655
  };
579
656
  /** Viewer-authentication gate. Only a plugin holding `auth.gate` (and enabled
580
657
  * by an admin) can issue sessions, and only inside `onHttpRequest`, where the
581
- * host attaches or clears the signed session cookie on the response. */
658
+ * host attaches or clears the signed session cookie on the response. The
659
+ * admin selects the cumulative, host-owned access mode. Plugins cannot read
660
+ * or change it. */
582
661
  auth: {
583
662
  /** Issue a gate session for an already-registered user (see
584
663
  * `users.register`). `ttl` is optional seconds (0/omitted = host default).
@@ -593,7 +672,7 @@ export const owncast: {
593
672
  storage: {
594
673
  upload(name: string, data: Uint8Array | string): UploadResult | null;
595
674
  };
596
- /** Private, sandboxed filesystem under data/plugin-data/<slug>/. The bytes
675
+ /** Private, sandboxed filesystem under data/plugin-storage/<slug>/files/. The bytes
597
676
  * stay server-side (never served over HTTP) and the host confines every
598
677
  * path to this plugin's own directory. All methods require `storage.fs`. */
599
678
  fs: {
@@ -610,6 +689,29 @@ export const owncast: {
610
689
  /** Report whether a path exists inside the sandbox. */
611
690
  exists(path: string): boolean;
612
691
  };
692
+ /** Private SQLite database, one per plugin, stored in `db/` next to the
693
+ * `storage.fs` sandbox in `files/`, outside anything `owncast.fs.*` can name,
694
+ * and quota'd separately. Every call runs with a 2 second timeout. Absence
695
+ * of `error` means success. An error, missing response, or non-object
696
+ * response throws. JavaScript loses unsafe integers before `JSON.stringify`
697
+ * on writes and during `JSON.parse` on reads, so store values above
698
+ * `Number.MAX_SAFE_INTEGER` (2^53 - 1) as TEXT when they must remain exact.
699
+ * Requires `storage.sql`. */
700
+ sql: {
701
+ /** Execute one statement batch as a single transaction: it commits whole
702
+ * or leaves the database untouched. A transaction cannot stay open
703
+ * across calls. */
704
+ exec(sql: string, params?: SQLValue[]): SQLExecResult;
705
+ /** Query rows as objects keyed by column name. Alias duplicate columns.
706
+ * The result is never silently shortened: a query returning more than
707
+ * 10000 rows, or more than 1 MiB of encoded data, throws asking for a
708
+ * LIMIT. */
709
+ query(sql: string, params?: SQLValue[]): SQLRow[];
710
+ /** Return the first matching row, or null. Only that row is read back, so
711
+ * this stays under the result budget on a table `query` is too big
712
+ * for. */
713
+ queryRow(sql: string, params?: SQLValue[]): SQLRow | null;
714
+ };
613
715
  /** Post to the fediverse on the streamer's behalf. Requires `fediverse.post`,
614
716
  * which is high-trust (posts go out under the streamer's own handle), so
615
717
  * admins should grant it sparingly. */
@@ -632,6 +734,7 @@ export const owncast: {
632
734
  };
633
735
  kv: {
634
736
  get(key: string): string | null;
737
+ /** Store a value. Throws when the host rejects the operation. */
635
738
  set(key: string, value: string | number): void;
636
739
  /** Read a JSON value, parsed. Returns `fallback` (default `undefined`)
637
740
  * when the key is unset or holds invalid JSON. Requires `storage.kv`. */
@@ -659,6 +762,8 @@ export const owncast: {
659
762
  readText(path: string): string | null;
660
763
  };
661
764
  events: {
765
+ /** Emit to a custom hook using its fully qualified
766
+ * `<recipient-plugin-slug>.<hook>` name. Requires `events.emit`. */
662
767
  emit(eventType: string, payload: unknown): void;
663
768
  };
664
769
  /** Control over the viewer action buttons this plugin contributes.
@@ -670,11 +775,11 @@ export const owncast: {
670
775
  * entry is validated with the same rules as `manifest.actions`
671
776
  * (title required, exactly one of `url` or `html`, relative URLs
672
777
  * rewritten into this plugin's namespace, cross-plugin URLs
673
- * rejected). The next viewer `/api/config` request returns
674
- * `manifest.actions` ++ the runtime list. */
778
+ * rejected). Throws when the host rejects the action list. */
675
779
  add(actions: ActionButton | ActionButton[]): void;
676
780
  /** Drop the runtime additions, so only `manifest.actions` remain on
677
- * the next viewer `/api/config` request. */
781
+ * the next viewer `/api/config` request. Throws when the host rejects the
782
+ * operation. */
678
783
  clear(): void;
679
784
  };
680
785
  sse: {
@@ -718,7 +823,8 @@ export const owncast: {
718
823
  tags(): string[];
719
824
  };
720
825
  /** Read/change video/transcoding configuration. read() requires
721
- * `videoconfig.read`, and write() requires `videoconfig.write`. */
826
+ * `videoconfig.read`. write() requires `videoconfig.write` and throws when
827
+ * the host rejects the update or does not return an operation result. */
722
828
  videoConfig: {
723
829
  read(): VideoConfig;
724
830
  write(config: VideoConfigUpdate): void;
@@ -778,3 +884,38 @@ export interface NetworkConfig {
778
884
  * host but must be written explicitly. */
779
885
  allowedHosts: string[];
780
886
  }
887
+
888
+ /** `manifest.category` (optional), the plugin's registry browse category.
889
+ * One of the canonical slugs below. The plugin registry uses it to filter
890
+ * the browse listing; unknown values are tolerated but won't match any
891
+ * filter.
892
+ *
893
+ * - `chat-bots`: Chat bots
894
+ * - `chat-filters`: Chat filters
895
+ * - `moderation`: Moderation
896
+ * - `authentication`: Authentication
897
+ * - `themes`: Themes
898
+ * - `overlays`: Overlays & widgets
899
+ * - `notifications`: Notifications
900
+ * - `integrations`: Integrations
901
+ * - `video`: Video & streaming
902
+ * - `analytics`: Analytics & stats
903
+ * - `games`: Games & fun
904
+ * - `admin-utilities`: Admin utilities
905
+ * - `examples`: Examples
906
+ * - `other`: Other */
907
+ export type PluginCategory =
908
+ | "chat-bots"
909
+ | "chat-filters"
910
+ | "moderation"
911
+ | "authentication"
912
+ | "themes"
913
+ | "overlays"
914
+ | "notifications"
915
+ | "integrations"
916
+ | "video"
917
+ | "analytics"
918
+ | "games"
919
+ | "admin-utilities"
920
+ | "examples"
921
+ | "other";
package/index.js CHANGED
@@ -1,9 +1,10 @@
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
 
@@ -65,6 +66,7 @@ const Permissions = Object.freeze({
65
66
  StorageKV: "storage.kv",
66
67
  StorageUpload: "storage.upload",
67
68
  StorageFS: "storage.fs",
69
+ StorageSQL: "storage.sql",
68
70
  EventsEmit: "events.emit",
69
71
  NetworkFetch: "network.fetch",
70
72
  HttpServe: "http.serve",
@@ -351,16 +353,6 @@ function dispatchHttp(request) {
351
353
  };
352
354
  }
353
355
 
354
- // permError builds an actionable Error and logs it to stderr (which the
355
- // host runtime captures), so a plugin author running `owncast-plugin
356
- // serve` or hitting the host's logs sees exactly which permission to
357
- // add to their manifest. apiName is the SDK call the author wrote
358
- // (e.g. "owncast.actions.set"). perm is the manifest permission string.
359
- function permError(apiName, perm) {
360
- const msg = `${apiName} requires the '${perm}' permission. Add it to your plugin.manifest.json's "permissions" array.`;
361
- console.error(`[owncast-plugin] ${msg}`);
362
- return new Error(msg);
363
- }
364
356
 
365
357
  // scheduleTimer registers a callback and asks the host to schedule it. The id
366
358
  // is guest-allocated and echoed back on "timer.fire". Throws if the host
@@ -383,16 +375,77 @@ function scheduleTimer(fn, ms, repeat) {
383
375
  return id;
384
376
  }
385
377
 
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.
378
+ // hostFns returns the complete host import table. Missing imports indicate an
379
+ // incompatible host. Permission denials are reported by result-returning calls.
389
380
  function hostFns(name, perm) {
390
381
  const fns = Host.getFunctions();
391
382
  if (!fns[name]) throw new Error(`permission '${perm}' not granted`);
392
383
  return fns;
393
384
  }
394
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
+
395
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
+ },
396
449
  chat: {
397
450
  send(text) {
398
451
  const fns = hostFns("owncast_send_chat", Permissions.ChatSend);
@@ -414,11 +467,15 @@ const owncast = {
414
467
  },
415
468
  deleteMessage(messageId) {
416
469
  const fns = hostFns("owncast_delete_message", Permissions.ChatModerate);
417
- fns.owncast_delete_message(Memory.fromString(String(messageId)).offset);
470
+ const offset = fns.owncast_delete_message(
471
+ Memory.fromString(String(messageId)).offset,
472
+ );
473
+ requireOperationResult(offset, "chat.deleteMessage failed");
418
474
  },
419
475
  kick(clientId) {
420
476
  const fns = hostFns("owncast_kick_client", Permissions.ChatModerate);
421
- fns.owncast_kick_client(BigInt(clientId));
477
+ const offset = fns.owncast_kick_client(BigInt(clientId));
478
+ requireOperationResult(offset, "chat.kick failed");
422
479
  },
423
480
  sendTo(clientId, text) {
424
481
  const fns = hostFns("owncast_send_chat_to", Permissions.ChatSend);
@@ -463,37 +520,44 @@ const owncast = {
463
520
  },
464
521
  setEnabled(id, enabled, reason) {
465
522
  const fns = hostFns("owncast_user_set_enabled", Permissions.UsersModerate);
466
- fns.owncast_user_set_enabled(
523
+ const offset = fns.owncast_user_set_enabled(
467
524
  Memory.fromString(id).offset,
468
525
  enabled ? 1 : 0,
469
526
  Memory.fromString(reason || "").offset,
470
527
  );
528
+ requireOperationResult(offset, "users.setEnabled failed");
471
529
  },
472
530
  banIP(ip) {
473
531
  const fns = hostFns("owncast_ban_ip", Permissions.UsersModerate);
474
- fns.owncast_ban_ip(Memory.fromString(ip).offset);
532
+ const offset = fns.owncast_ban_ip(Memory.fromString(ip).offset);
533
+ requireOperationResult(offset, "users.banIP failed");
475
534
  },
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`.
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`.
481
539
  register(opts) {
482
540
  const fns = hostFns("owncast_users_register", Permissions.UsersRegister);
483
- const req =
484
- typeof opts === "string" ? { authId: opts } : opts || {};
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
+ };
485
550
  const offset = fns.owncast_users_register(
486
551
  Memory.fromString(JSON.stringify(req)).offset,
487
552
  );
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 }
553
+ return requireOperationResult(offset, "users.register failed"); // { userId }
492
554
  },
493
555
  },
494
556
  // Viewer-authentication gate. Only a plugin holding `auth.gate` (and enabled by
495
557
  // an admin) can issue sessions, and these are valid only inside onHttpRequest,
496
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.
497
561
  auth: {
498
562
  // Issue a gate session for an already-registered user (see users.register).
499
563
  // `ttl` is optional seconds, and 0/omitted uses the host default. Throws on
@@ -504,9 +568,7 @@ const owncast = {
504
568
  const offset = fns.owncast_auth_grant_session(
505
569
  Memory.fromString(JSON.stringify(req)).offset,
506
570
  );
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);
571
+ requireOperationResult(offset, "auth.grantSession failed");
510
572
  },
511
573
  // Clear the current viewer's gate session (logout). The plugin still owns the
512
574
  // response/redirect. Requires `auth.gate`.
@@ -535,7 +597,7 @@ const owncast = {
535
597
  return JSON.parse(Memory.find(offset).readString());
536
598
  },
537
599
  },
538
- // Private, sandboxed filesystem under data/plugin-data/<slug>/. Unlike
600
+ // Private, sandboxed filesystem under data/plugin-storage/<slug>/files/. Unlike
539
601
  // storage.upload (which publishes browser-accessible files), these bytes
540
602
  // stay server-side. The host confines every path to this plugin's own
541
603
  // directory. All methods require the 'storage.fs' permission.
@@ -557,7 +619,7 @@ const owncast = {
557
619
  return Memory.find(offset).readString();
558
620
  },
559
621
  // Write bytes (Uint8Array) or a string to a file, creating parent
560
- // directories as needed. Returns { ok, error? }.
622
+ // directories as needed. Returns { error? }.
561
623
  write(path, data) {
562
624
  const fns = hostFns("owncast_fs_write", Permissions.StorageFS);
563
625
  const dataMem =
@@ -573,8 +635,7 @@ const owncast = {
573
635
  Memory.fromString(path).offset,
574
636
  dataMem.offset,
575
637
  );
576
- if (offset == 0) return { ok: false, error: "write failed" };
577
- return JSON.parse(Memory.find(offset).readString());
638
+ return operationResult(offset, "write failed");
578
639
  },
579
640
  // List the entry names (files and subdirectories) directly inside dir.
580
641
  // A missing directory lists as empty. Returns string[].
@@ -584,12 +645,11 @@ const owncast = {
584
645
  if (offset == 0) return [];
585
646
  return JSON.parse(Memory.find(offset).readString());
586
647
  },
587
- // Remove a single file or empty directory. Returns { ok, error? }.
648
+ // Remove a single file or empty directory. Returns { error? }.
588
649
  delete(path) {
589
650
  const fns = hostFns("owncast_fs_delete", Permissions.StorageFS);
590
651
  const offset = fns.owncast_fs_delete(Memory.fromString(path).offset);
591
- if (offset == 0) return { ok: false, error: "delete failed" };
592
- return JSON.parse(Memory.find(offset).readString());
652
+ return operationResult(offset, "delete failed");
593
653
  },
594
654
  // Report whether a path exists inside the sandbox. Returns boolean.
595
655
  exists(path) {
@@ -597,6 +657,23 @@ const owncast = {
597
657
  return fns.owncast_fs_exists(Memory.fromString(path).offset) === 1;
598
658
  },
599
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
+ },
600
677
  fediverse: {
601
678
  /** Publish a public text-only post to the fediverse on the streamer's
602
679
  * behalf. Returns { url } on success, null on failure (rate-limited,
@@ -690,10 +767,7 @@ const owncast = {
690
767
  const offset = fns.owncast_video_config_write(
691
768
  Memory.fromString(JSON.stringify(config || {})).offset,
692
769
  );
693
- if (offset == 0) throw new Error("videoConfig.write failed");
694
- const result = JSON.parse(Memory.find(offset).readString());
695
- if (!result.ok)
696
- throw new Error(result.error || "videoConfig.write failed");
770
+ requireOperationResult(offset, "videoConfig.write failed");
697
771
  },
698
772
  },
699
773
  kv: {
@@ -705,10 +779,11 @@ const owncast = {
705
779
  },
706
780
  set(key, value) {
707
781
  const fns = hostFns("owncast_kv_set", Permissions.StorageKV);
708
- fns.owncast_kv_set(
782
+ const offset = fns.owncast_kv_set(
709
783
  Memory.fromString(key).offset,
710
784
  Memory.fromString(String(value)).offset,
711
785
  );
786
+ requireOperationResult(offset, "kv.set failed");
712
787
  },
713
788
  // getJSON/setJSON are convenience wrappers over the string-only store, so
714
789
  // plugins don't reimplement JSON.parse/stringify for every stored object.
@@ -769,28 +844,27 @@ const owncast = {
769
844
  },
770
845
  },
771
846
  actions: {
772
- // Append one or more action buttons to the plugin's effective list
773
- // (manifest.actions ++ runtime additions). Accepts a single button
774
- // object or an array. The host validates each entry (title
775
- // required, exactly one of url/html, relative URLs rewritten into
776
- // this plugin's namespace, cross-plugin URLs rejected) and persists
777
- // the result, so the next /api/config request returns the longer
778
- // 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? }.
779
849
  add(actions) {
780
- const fns = Host.getFunctions();
781
- if (!fns.owncast_add_actions)
782
- throw permError("owncast.actions.add", Permissions.UIModify);
850
+ const fns = hostFns("owncast_add_actions", Permissions.UIModify);
783
851
  const list = Array.isArray(actions) ? actions : [actions];
784
- 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
+ );
785
858
  },
786
859
  // Drop the runtime additions so only manifest.actions remain in
787
860
  // the effective list on the next /api/config request. Requires
788
861
  // 'ui.modify'.
789
862
  clear() {
790
- const fns = Host.getFunctions();
791
- if (!fns.owncast_clear_actions)
792
- throw permError("owncast.actions.clear", Permissions.UIModify);
793
- 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
+ );
794
868
  },
795
869
  },
796
870
  sse: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owncast/plugin-sdk",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "SDK for authoring Owncast plugins in JavaScript",
5
5
  "license": "MIT",
6
6
  "author": "Owncast",