@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.
- package/bin/owncast-plugin.js +15 -0
- package/index.d.ts +171 -30
- package/index.js +134 -60
- package/package.json +1 -1
package/bin/owncast-plugin.js
CHANGED
|
@@ -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
|
-
|
|
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?:
|
|
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
|
|
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).
|
|
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
|
|
419
|
-
onFediverseQuote?(event:
|
|
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
|
|
474
|
-
*
|
|
475
|
-
*
|
|
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.
|
|
532
|
-
*
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
646
|
+
/** Ban an IP address. Throws when the host rejects the operation. Requires
|
|
647
|
+
* `users.moderate`. */
|
|
571
648
|
banIP(ip: string): void;
|
|
572
|
-
/** Find
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
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-
|
|
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).
|
|
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
|
|
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: { [
|
|
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().
|
|
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
|
|
387
|
-
//
|
|
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(
|
|
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
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
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
|
|
484
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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: {
|