Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ file (see [build flow](#toolchain-and-build-flow)).
then sends the raw activity to `onFediverse` / `on_fediverse` and also sends
any matching specialized follow, like, repost, quote, mention, or reply event.
The `fediverse.inbound` manifest permission gates all seven subscriptions.
- Outbound Fediverse hooks are internal notify subscriptions gated by
`fediverse.outbound`. They deliver the raw ActivityPub activity once after
successful local persistence, not once per recipient or retry. Ordinary
private activities are included. The event does not promise remote delivery
success or durable replay, and authentication messages containing one-time
codes are excluded.

## Repository layout

Expand Down
57 changes: 50 additions & 7 deletions docs/PLUGIN_AUTHOR_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ module.exports = definePlugin({
/* raw ActivityPub JSON object */
},

// Locally persisted outgoing ActivityPub activity, as raw JSON
onFediverseOutbound(activity) {
/* raw ActivityPub JSON object */
},

// Filter chain (sequential, can mutate or drop)
filterChatMessage(msg) {
return filter.pass();
Expand All @@ -225,7 +230,11 @@ module.exports = definePlugin({

Only define the handlers you actually need. Missing handlers = no subscription.

All seven Fediverse handlers require `fediverse.inbound`: `onFediverse`, `onFediverseFollow`, `onFediverseLike`, `onFediverseRepost`, `onFediverseQuote`, `onFediverseMention`, and `onFediverseReply`. These are internal plugin event subscriptions, not external HTTP webhooks. `fediverse.post` is a separate permission for publishing outbound posts.
All seven inbound Fediverse handlers require `fediverse.inbound`: `onFediverse`,
`onFediverseFollow`, `onFediverseLike`, `onFediverseRepost`, `onFediverseQuote`,
`onFediverseMention`, and `onFediverseReply`. These are internal plugin event
subscriptions, not external HTTP webhooks. `fediverse.post` separately permits
publishing outbound posts.

### Chat message shape

Expand Down Expand Up @@ -286,7 +295,39 @@ interface FediverseQuote {

`onFediverse` receives the verified inbound ActivityPub activity after the host validates the HTTP signature and actor origin. In JavaScript, the payload is the raw JSON object. It fires in addition to a specialized handler when one applies. It also fires for verified activity types that have no specialized handler.

Python exposes the same hooks as `@plugin.on_fediverse`, `@plugin.on_fediverse_follow`, `@plugin.on_fediverse_like`, `@plugin.on_fediverse_repost`, `@plugin.on_fediverse_quote`, `@plugin.on_fediverse_mention`, and `@plugin.on_fediverse_reply`. Python notify payloads are non-subscriptable `_Obj` attribute views. Read normal fields as attributes, or use `.raw` for the underlying dictionary and keys that are not valid Python attributes, such as `payload.raw["@context"]`.
Python exposes the inbound hooks as `@plugin.on_fediverse`,
`@plugin.on_fediverse_follow`, `@plugin.on_fediverse_like`,
`@plugin.on_fediverse_repost`, `@plugin.on_fediverse_quote`,
`@plugin.on_fediverse_mention`, and `@plugin.on_fediverse_reply`, and the
outbound hook as `@plugin.on_fediverse_outbound`. Python notify payloads are
non-subscriptable `_Obj` attribute views. Read normal fields as attributes, or
use `.raw` for the underlying dictionary and keys that are not valid Python
attributes, such as `payload.raw["@context"]`.

### Outbound Fediverse activity

`onFediverseOutbound` receives raw ActivityPub JSON after an outbound activity
has been successfully persisted locally. It fires once per activity, even when
there are no followers. It is not a per-recipient or network-retry callback and
does not promise remote delivery success. There is no durable plugin
notification or replay promise. It covers posts, go-live and stream-status
activities, profile updates, follows and their undo/accept/reject responses,
and quote responses. Ordinary private activities are included. Authentication
messages containing one-time codes are excluded by the producer.

Before crossposting, check both `activity.type` and `activity.object.type`,
then verify that the activity and object audiences (`to` / `cc`) identify the
ActivityStreams Public collection. `fediverse.outbound` is a separate
permission from `fediverse.post` and grants access to ordinary private
activities, so only trusted plugins should receive it. Python receives the
same payload as an `_Obj`. Use its attributes or `.raw`.

JavaScript can also subscribe with `on: { "fediverse.outbound": handler }`.
Python can use `@plugin.on("fediverse.outbound")`. The named handlers are the
dedicated first-class form.

See the [JavaScript example](../examples/js/outbound-post-observer/) and
[Python example](../examples/python/outbound-post-observer/).

### Fediverse inbound posts

Expand Down Expand Up @@ -597,6 +638,7 @@ in the built-in help listing.
| `videoconfig.write` | `owncast.videoConfig.write()`, change video config, high-trust. Changes apply on the next stream start (the host does not restart a live stream). |
| `notifications.send` | `owncast.notifications.discord`, `.browserPush` |
| `fediverse.inbound` | Subscribe to `fediverse.activity`, `fediverse.follow`, `fediverse.like`, `fediverse.repost`, `fediverse.quote`, `fediverse.mention`, and `fediverse.reply` through `onFediverse` and the six specialized handlers. Required for any plugin that declares one of those handlers. |
| `fediverse.outbound` | Subscribe to `fediverse.outbound` through `onFediverseOutbound`. Includes ordinary private activities, so grant only to trusted plugins. |
| `fediverse.post` | `owncast.fediverse.post(text)`, high-trust (posts under the streamer's handle), admin should grant sparingly. |
| `ui.modify` | Place UI inside Owncast's own chrome. Required for `manifest.actions`, `manifest.styles`, `manifest.scripts`, and `manifest.extraPageContent`. |

Expand Down Expand Up @@ -1134,11 +1176,12 @@ Tests live in `__tests__/*.test.js`. They drive your built plugin through the re

Before any scenario runs, the test runner load-checks the plugin: the same
install-time path a real Owncast server runs, covering manifest validity, a
healthy `register()`, and permission-gated subscriptions (a chat filter
without `chat.filter`, or a fediverse handler without `fediverse.inbound`,
fails right there with the same error the server gives at install). The check
runs even when a plugin ships no tests. `package` runs it too, and a plugin
that would be rejected at install refuses to package.
healthy `register()`, and permission-gated subscriptions: a chat filter without
`chat.filter`, an inbound Fediverse handler without `fediverse.inbound`, or
`onFediverseOutbound` without `fediverse.outbound` fails with the same error
the server gives at install. The check runs even when a plugin ships no tests.
`package` runs it too, and a plugin that would be rejected at install refuses
to package.

```js
const { runScenarios } = require("@owncast/plugin-sdk/testing");
Expand Down
15 changes: 15 additions & 0 deletions docs/WIRE_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,20 @@ remote quote post. Content fields are present when the `QuoteRequest` embeds
the quote `Note`. Mentions and replies are verified public `Create(Note)`
activities tied to the local account or a locally authored post.

### `fediverse.outbound`

This permission gates the notify subscription `fediverse.outbound`. Its payload
is the raw ActivityPub JSON for a locally authored outbound activity. The
notification fires once after successful local persistence, even when there
are no followers. It is not emitted per recipient or for network retries, and
it does not promise successful remote delivery or durable notification/replay.

The hook covers outbound posts, go-live and stream-status activities, profile
updates, follows and their undo/accept/reject responses, and quote responses.
Ordinary private activities are included. Authentication messages containing
one-time codes are excluded by the producer. A plugin that republishes content
must check the activity and object types and their `to`/`cc` audiences first.

### ambient (no permission)

These imports are available to every plugin:
Expand Down Expand Up @@ -1065,6 +1079,7 @@ all of them as `number`, which loses integer precision above
| `tick` | `TickEvent` |
| `timer.fire` | `TimerFireEvent` |
| `fediverse.activity` | `JSONValue` |
| `fediverse.outbound` | `JSONValue` |
| `fediverse.follow`, `fediverse.like`, `fediverse.repost`, `fediverse.quote` | `FediverseEngagement` |
| `fediverse.mention`, `fediverse.reply` | `FediverseInboundPost` |
| Custom event name | `JSONValue` |
Expand Down
1 change: 1 addition & 0 deletions examples/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ One self-contained npm project per directory. Each has its own `README.md` with
| [file-manager](./file-manager/) | `storage.fs`, admin page to browse/upload/download/delete files in the plugin's private sandbox. |
| [action-buttons](./action-buttons/) | `manifest.actions` + runtime `owncast.actions.add` (ui.modify), with an admin page that adds buttons. |
| [fediverse-chat-bridge](./fediverse-chat-bridge/) | Inbound fediverse mentions → HTML chat system messages with avatars. |
| [outbound-post-observer](./outbound-post-observer/) | `onFediverseOutbound`: observe outbound activities, accept only public `Create(Note)`, and ignore private or non-Note activities. |
| [safeguard-stress](./safeguard-stress/) | Test fixture, misbehaves on demand to verify host sandbox caps. |
| [all-permissions-test](./all-permissions-test/) | Build/load canary: every permission + every handler as no-ops, trips on subscription-gate / load-rule drift. |
| [styles-demo](./styles-demo/) | `manifest.styles[]`, CSS inlined into the viewer page's customStyles. |
Expand Down
1 change: 1 addition & 0 deletions examples/js/all-permissions-test/plugin.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"users.register",
"auth.gate",
"fediverse.inbound",
"fediverse.outbound",
"fediverse.post",
"videoconfig.read",
"videoconfig.write",
Expand Down
4 changes: 3 additions & 1 deletion examples/js/all-permissions-test/src/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,16 @@ module.exports = definePlugin({
onSseConnect() {},
onSseDisconnect() {},
onTick() {},
// The seven fediverse handlers require fediverse.inbound.
// The seven inbound handlers require fediverse.inbound.
onFediverse() {},
onFediverseFollow() {},
onFediverseLike() {},
onFediverseRepost() {},
onFediverseQuote() {},
onFediverseMention() {},
onFediverseReply() {},
// Requires fediverse.outbound.
onFediverseOutbound() {},
// Requires http.serve.
onHttpRequest: () => ({ status: 204 }),
// Requires auth.gate.
Expand Down
18 changes: 18 additions & 0 deletions examples/js/outbound-post-observer/INSTRUCTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Outbound Post Observer

When enabled, this plugin watches outbound ActivityPub activities and sends an Owncast chat notice only for a public `Create(Note)`. It checks both the activity and object types, then checks `to` and `cc` for the ActivityStreams Public collection at each level that specifies an audience.

## How to use it

1. Enable the plugin in **Admin → Plugins**.
2. Publish a public post. The bot ("Outbound Observer") posts `Observed a public Fediverse post.` in chat.
3. Private posts and other activity/object types do not trigger the chat notice.

The observer runs after local persistence, not after remote delivery. Ordinary private activities can reach the outbound hook, so this example ignores them. Authentication messages containing one-time codes are excluded by the producer.

## Permissions

- **fediverse.outbound**: observes raw outbound ActivityPub activities, including ordinary private activities.
- **chat.send**: posts the demonstration notice to chat.

This example does not crosspost. Any plugin that republishes an activity should check its type, object type, and audience before forwarding it.
5 changes: 5 additions & 0 deletions examples/js/outbound-post-observer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# outbound-post-observer

Watches locally persisted outbound ActivityPub activities and posts a chat notice only for public `Create(Note)` activities. The example checks both the activity and object types, then checks the activity and object `to`/`cc` audiences before acting; private activities and other types are ignored.

**Demonstrates:** `onFediverseOutbound(activity)`, `Events.FediverseOutbound`, the raw ActivityPub payload, and the `fediverse.outbound` permission. It does not crosspost or promise remote delivery success.
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
[
{
"name": "observes a public Create(Note)",
"events": [
{
"event": "fediverse.outbound",
"payload": {
"type": "Create",
"to": "https://www.w3.org/ns/activitystreams#Public",
"cc": ["https://example.com/users/streamer/followers"],
"object": {
"type": "Note",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": "https://example.com/users/streamer/followers",
"content": "Hello from the stream"
}
}
}
],
"expect": {
"chatSends": ["Observed a public Fediverse post."]
}
},
{
"name": "ignores a private Create(Note)",
"events": [
{
"event": "fediverse.outbound",
"payload": {
"type": "Create",
"to": ["https://example.com/users/streamer/followers"],
"object": {
"type": "Note",
"to": "https://example.com/users/streamer/followers",
"content": "A private post"
}
}
}
],
"expect": {
"chatSends": []
}
},
{
"name": "ignores non-Note and non-Create activities",
"events": [
{
"event": "fediverse.outbound",
"payload": {
"type": "Create",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"object": {
"type": "Article",
"to": ["https://www.w3.org/ns/activitystreams#Public"]
}
}
},
{
"event": "fediverse.outbound",
"payload": {
"type": "Update",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"object": {
"type": "Note",
"to": ["https://www.w3.org/ns/activitystreams#Public"]
}
}
}
],
"expect": {
"chatSends": []
}
}
]
35 changes: 35 additions & 0 deletions examples/js/outbound-post-observer/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions examples/js/outbound-post-observer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "outbound-post-observer",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "owncast-plugin build",
"test": "owncast-plugin build && owncast-plugin test",
"serve": "owncast-plugin build && owncast-plugin serve"
},
"dependencies": {
"@owncast/plugin-sdk": "file:../../../sdks/js"
}
}
15 changes: 15 additions & 0 deletions examples/js/outbound-post-observer/plugin.manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"api": "1",
"name": "Outbound Post Observer",
"slug": "outbound-post-observer",
"version": "0.1.0",
"description": "Posts an Owncast chat notice only for public Create(Note) outbound activities, checking activity and object audiences. This example was written in JavaScript.",
"category": "integrations",
"permissions": [
"chat.send",
"fediverse.outbound"
],
"bot": {
"displayName": "Outbound Observer"
}
}
39 changes: 39 additions & 0 deletions examples/js/outbound-post-observer/src/plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const { definePlugin, owncast } = require("@owncast/plugin-sdk");

const PUBLIC = "https://www.w3.org/ns/activitystreams#Public";

function hasPublicRecipient(value) {
return Array.isArray(value) ? value.includes(PUBLIC) : value === PUBLIC;
}

function hasPublicAudience(activity, object) {
const activityHasAudience =
activity.to !== undefined || activity.cc !== undefined;
const objectHasAudience = object.to !== undefined || object.cc !== undefined;
return (
(activityHasAudience || objectHasAudience) &&
(!activityHasAudience ||
hasPublicRecipient(activity.to) ||
hasPublicRecipient(activity.cc)) &&
(!objectHasAudience ||
hasPublicRecipient(object.to) ||
hasPublicRecipient(object.cc))
);
}

module.exports = definePlugin({
onFediverseOutbound(activity) {
if (!activity || activity.type !== "Create") return;
const object = activity.object;
if (
!object ||
typeof object !== "object" ||
Array.isArray(object) ||
object.type !== "Note"
) {
return;
}
if (!hasPublicAudience(activity, object)) return;
owncast.chat.send("Observed a public Fediverse post.");
},
});
1 change: 1 addition & 0 deletions examples/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ One self-contained plugin per directory, authored in Python and compiled to wasm
| [file-manager](./file-manager/) | `storage.fs`, admin page to browse/upload/download/delete files in the plugin's private sandbox. |
| [action-buttons](./action-buttons/) | `manifest.actions` + runtime `owncast.actions.add` (ui.modify), with an admin page that adds buttons. |
| [fediverse-chat-bridge](./fediverse-chat-bridge/) | Inbound fediverse mentions → HTML chat system messages with avatars. |
| [outbound-post-observer](./outbound-post-observer/) | `@plugin.on_fediverse_outbound`: observe outbound activities, accept only public `Create(Note)`, and ignore private or non-Note activities. |
| [safeguard-stress](./safeguard-stress/) | Test fixture, misbehaves on demand to verify host sandbox caps. |
| [all-permissions-test](./all-permissions-test/) | Build/load canary: every permission + every handler as no-ops, trips on subscription-gate / load-rule drift. |
| [styles-demo](./styles-demo/) | `manifest.styles[]`, CSS inlined into the viewer page's customStyles. |
Expand Down
1 change: 1 addition & 0 deletions examples/python/all-permissions-test/plugin.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"users.register",
"auth.gate",
"fediverse.inbound",
"fediverse.outbound",
"fediverse.post",
"videoconfig.read",
"videoconfig.write",
Expand Down
Loading
Loading