docs(wiki): document the plugin notification-channel surface

Covers the pieces added in the previous commits, in the pages a reader would
actually reach for:

- Plugins.md (the admin-facing page) had none of it: notification channels,
  settings actions, and a full "Allowed hosts" section — including what
  operator-supplied egress deliberately does NOT let anyone do.
- Plugin-Development.md: the notificationChannel hook (and why it is the one hook
  with no acting user), settings-page actions, operatorEgress, and the manifest
  reference rows.
- Plugin-Cookbook.md: a "become a notification channel" recipe and a
  "Test connection button" recipe.
- Plugin-Permissions.md: hook:notification-channel, operatorEgress under the
  outbound section, and settings actions under "not a permission".
- Notifications.md: plugin channels alongside the four built-ins.
This commit is contained in:
jubnl
2026-07-11 19:27:02 +02:00
parent c9627af239
commit a4972f8f13
5 changed files with 358 additions and 3 deletions
+20 -1
View File
@@ -8,7 +8,7 @@ The Notifications tab (Settings → Notifications) lets you choose which events
## Notification channels
TREK supports four delivery channels. Which channels appear depends on what the admin has enabled server-side.
TREK ships four delivery channels, and a plugin can add more. Which channels appear depends on what the admin has enabled server-side.
| Channel | Description |
|---------|-------------|
@@ -17,6 +17,25 @@ TREK supports four delivery channels. Which channels appear depends on what the
| **Webhook** | TREK POSTs a JSON payload to a URL you specify. Discord and Slack webhook URLs are auto-detected and receive a natively formatted payload. |
| **ntfy** | Push notifications via [ntfy.sh](https://ntfy.sh) or a self-hosted ntfy server. |
### Plugin channels
A plugin can register an **additional channel** — Gotify, Pushover, Telegram, anything that
takes a message — by implementing the `notificationChannel` hook. See
[Plugin-Development](Plugin-Development.md#notification-channels).
Once the admin installs the plugin and switches its channel on, it appears as a new column in
the preferences matrix beside Email and In-App, and behaves like any other channel: each user
supplies their own credentials (in the plugin's own settings) and picks per-event which
notifications they want on it.
Two things are true of plugin channels specifically:
- **They are user-scoped.** Admin-only events (like `version_available`) always go out over the
built-in admin channels, never a plugin's.
- **The plugin never sees your trips.** The notification is rendered by TREK — in your language,
with the deep link already built — before the plugin is handed it. The plugin gets that message
and your own credentials for its service, and nothing else.
## Notification events
The following events are configurable in user settings:
+94
View File
@@ -205,6 +205,100 @@ The recipient is **forced** by the host: `scope:'user'` can only reach the actin
---
## Become a notification channel (Gotify, Pushover, …)
**Needs:** `hook:notification-channel` + `http:outbound:<host>` (and a matching `egress`)
The recipe above *produces* a notification. This one **delivers** one — your plugin becomes a
channel next to email / webhook / ntfy in the user's notification preferences.
```bash
npx create-trek-plugin my-gotify --type integration --template notification-channel
```
```js
module.exports = definePlugin({
hooks: {
notificationChannel: {
async send(msg, config, ctx) {
// msg is ALREADY rendered in the recipient's language, deep link included.
// config is that recipient's own scope:'user' settings, decrypted for you.
const res = await fetch(`${config.serverUrl}/message`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-Gotify-Key': config.appToken },
body: JSON.stringify({ title: msg.title, message: msg.body }),
})
if (!res.ok) throw new Error(`Gotify responded ${res.status}`) // host logs + isolates
},
async test(config) { /* backs the "Send test" button */ },
},
},
})
```
```json
"permissions": ["hook:notification-channel", "http:outbound:gotify.example.com"],
"egress": ["gotify.example.com"],
"capabilities": { "notificationChannel": { "title": "Gotify" } },
"settings": [
{ "key": "serverUrl", "scope": "user", "required": true },
{ "key": "appToken", "scope": "user", "required": true, "secret": true }
]
```
The gotcha worth knowing up front: this hook is **host-initiated for an arbitrary recipient**, so
unlike every other hook it runs with **no acting user**. `ctx.settings.get()` returns `undefined`
and trip reads are refused — the recipient's credentials come in as `config` instead. That's the
whole point: you can be handed someone's push token without being handed their trips.
Targeting a **self-hosted** Gotify? Your manifest can't name the user's hostname, so add
`"operatorEgress": true` and let the admin add the real host after install
(Admin → Plugins → Allowed hosts). See
[Plugin-Development → Operator-supplied egress hosts](Plugin-Development.md#operator-supplied-egress-hosts-operatoregress),
plus [Notification channels](Plugin-Development.md#notification-channels) for the event list, the
"configured" rule, and the `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS` flag you need if the service runs
on your own LAN.
---
## Put a "Test connection" button on your settings page
**Needs:** nothing — an action is your own code, run for the user who clicked it.
Declare the buttons in the manifest, implement them on the definition:
```json
"actions": [
{ "key": "testConnection", "label": "Test connection", "hint": "Pings the API." },
{ "key": "purge", "label": "Delete my data", "danger": true }
]
```
```js
module.exports = definePlugin({
actions: {
async testConnection(ctx) {
// USER-INITIATED: the acting user is whoever clicked, so ctx.settings.get()
// returns THEIR value — which is what makes "test MY credentials" possible.
const token = await ctx.settings.get('appToken')
const res = await fetch('https://api.example.com/ping', { headers: { authorization: token } })
return { ok: res.ok, message: res.ok ? 'Connected' : `Failed: ${res.status}` }
},
},
})
```
The buttons render under your fields in **Settings → Plugins**, with the result shown
beside them. Return `{ ok, message? }`; throwing is the same as `{ ok: false }` carrying
the error text. `danger: true` asks for confirmation first. The host refuses any key your
manifest didn't declare, and bounds the message it shows (200 chars, emoji stripped).
Contrast with the `notificationChannel` hook, which is **host**-initiated and therefore
has *no* acting user — there, `ctx.settings.get()` returns `undefined` and the recipient's
credentials arrive as an argument instead.
---
## Ask the configured LLM
**Needs:** `ai:invoke`
+154 -1
View File
@@ -612,9 +612,159 @@ module.exports = definePlugin({
| `tripCardProvider.getCards(tripIds, ctx)` → `TripCardContribution[]` | `hook:trip-card-provider` | **live** — small badges on the dashboard trip cards. Called ONCE with all visible `tripIds` (each already access-checked for the acting user), returns `{ tripId, id, label, value?, icon?, tone?, url? }[]`; the host bounds every field (label 64, value 256, tone enum, url http/https/mailto-only), caps the count (≤40/plugin) and drops any badge whose `tripId` wasn't requested. Declarative only. Also `GET /api/trip-card-contributions?tripIds=…` |
| `photoProvider.search(query, {page, limit}, ctx)` / `.getById(id, ctx)` | `hook:photo-provider` | **live** — plugin photo sources aggregated at `GET /api/plugin-photos/search` (+ `/sources`, `/item`) for the picker. Each `{id, title?, thumbnailUrl, fullUrl, takenAt?}`; thumbnail/full URLs must be http/https, per-source count capped, a failing source skipped |
| `calendarSource.getName(ctx)` / `.getEvents(userId, start, end, ctx)` | `hook:calendar-source` | **live** — plugin calendar events aggregated for the signed-in user at `GET /api/plugin-calendar?start=&end=`. Each `{id, title, start, end, allDay}` (ISO dates); count capped, a failing source skipped |
| `notificationChannel.send(msg, config, ctx)` / `.test(config, ctx)` | `hook:notification-channel` | **live** — registers a new notification channel. **Userless** (see below). See [Notification channels](#notification-channels) |
Each hook method receives its args plus the per-invocation `ctx`, so any `ctx.trips.*`
read it makes is membership-checked against the current user (like a route handler).
read it makes is membership-checked against the current user (like a route handler) —
with **one exception**, the notification channel, which has no acting user at all.
## Notification channels
`hook:notification-channel` lets your plugin become a delivery channel alongside TREK's
built-in email / webhook / ntfy — Gotify, Pushover, Telegram, whatever takes a message.
Scaffold one with:
```bash
npx create-trek-plugin my-gotify --type integration --template notification-channel
```
```js
module.exports = definePlugin({
hooks: {
notificationChannel: {
// msg is ALREADY RENDERED in the recipient's language, with the deep link built.
// config is that recipient's own scope:'user' settings, decrypted by the host.
async send(msg, config, ctx) {
const res = await fetch(`${config.serverUrl}/message`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-Gotify-Key': config.appToken },
body: JSON.stringify({ title: msg.title, message: msg.body + '\n\n' + (msg.url ?? '') }),
})
// THROW on failure — the host logs and isolates it, so a dead channel
// can never stop email/in-app/the other channels from being delivered.
if (!res.ok) throw new Error(`Gotify responded ${res.status}`)
},
// Optional — backs the "Send test" button in the user's notification settings.
async test(config, ctx) { /* … */ },
},
},
})
```
```json
{
"type": "integration",
"permissions": ["hook:notification-channel", "http:outbound:gotify.example.com"],
"egress": ["gotify.example.com"],
"capabilities": { "notificationChannel": { "title": "Gotify" } },
"settings": [
{ "key": "serverUrl", "label": "Server URL", "scope": "user", "required": true },
{ "key": "appToken", "label": "App token", "scope": "user", "required": true, "secret": true }
]
}
```
**This hook is host-initiated, so it runs with NO acting user.** A notification is dispatched
*to* an arbitrary recipient — nobody is "calling" your plugin — so:
- `ctx.settings.get()` returns `undefined` here, and `ctx.trips.*` reads are refused.
- The recipient's credentials arrive as the `config` argument instead: the host reads your
plugin's `scope:'user'` settings for that recipient, decrypts them, and hands them over.
That is deliberate. It is what lets a channel plugin be given someone's push token *without*
also being given the right to read their trips as them.
Notes:
- **`title`** names the column in the notification preferences matrix (defaults to your
plugin's name). **`events`** may *narrow* which events the channel carries; the default is
every non-admin event. Admin-scoped events (`version_available`) are never deliverable to a
plugin channel — those go over the admin's own credentials.
- **Configured-ness is inferred, not asked.** A user's column is "not configured" until every
`required`, `scope:'user'` field has a value. A plugin with no required user fields counts as
configured for everyone (an instance-wide channel, e.g. a shared workspace webhook).
- **Per-host egress is mandatory.** Bare `http:outbound` does **not** open any host at runtime —
you need `http:outbound:<host>` per host (see below).
- **For a self-hosted target, set `operatorEgress`** — see the next section. Your manifest can't
name the operator's Gotify; the admin does it after install.
- **Reaching a service on your own LAN** (a Gotify next to TREK) additionally needs
`TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on` on the TREK process — plugins may not reach private
addresses by default. It relaxes the policy for *every* installed plugin, so enable it only
if you trust them all.
## Settings-page actions
A plugin can put **buttons on its own settings page** — "Test connection", "Sync now",
"Clear cache". Declare them in the manifest and implement them on the definition:
```json
"actions": [
{ "key": "testConnection", "label": "Test connection", "hint": "Pings the API." },
{ "key": "purge", "label": "Delete my data", "danger": true }
]
```
```js
module.exports = definePlugin({
actions: {
async testConnection(ctx) {
const token = await ctx.settings.get('appToken') // the CLICKING user's own
const res = await fetch('https://api.example.com/ping', { headers: { authorization: token } })
return { ok: res.ok, message: res.ok ? 'Connected' : `Failed: ${res.status}` }
},
},
})
```
An action is **user-initiated**, which is what makes it different from the
`notificationChannel` hook: the acting user is *whoever clicked the button*. So
`ctx.settings.get()` returns **their** value and any trip read is membership-checked
against them — exactly what a "test my credentials" button needs.
Notes:
- Return `{ ok, message? }`. Throwing is the same as `{ ok: false }` with the error text.
The message is bounded (200 chars) and emoji-stripped host-side before it's shown.
- `danger: true` renders it destructively and asks for confirmation first.
- The key must be a valid settings key, and the host refuses any key the manifest didn't
declare — the key arrives from the URL, so it is never forwarded to your plugin blindly.
- Max 8 actions; label capped at 60 chars, hint at 200.
## Operator-supplied egress hosts (`operatorEgress`)
A plugin's `egress` list is fixed in its manifest **at publish time**. That works for a cloud
API (`api.pushover.net`), but not for a **self-hosted** service — you cannot know that a user's
Gotify lives at `gotify.alice.net`. Without a way out, a community plugin for a self-hosted
target could serve nobody.
Declare `operatorEgress` and the **admin** supplies the real hosts after install:
```json
{
"permissions": ["hook:notification-channel", "http:outbound:gotify.net"],
"egress": ["gotify.net"], // hosts you DO know (the cloud offering)
"operatorEgress": true // …plus whatever the admin adds
}
```
The admin then opens **Admin → Plugins → ⋯ → Allowed hosts** and adds `gotify.alice.net`. The
runtime unions that into the child's allow-list and **re-spawns the plugin** — the egress guard
is installed once at child start and a second `init` is deliberately refused, so a live child's
allow-list can never be widened in place.
What this deliberately does *not* do:
- **An end user can never widen egress.** Only an admin can, and only for a plugin that
*declared* `operatorEgress` — so the consent given at install still bounds what is possible.
A plugin that never asked for it can never be given a host.
- **Hosts get the same validation as manifest egress**: no bare `*`, no whole-TLD `*.com`, no
scheme, no spaces. A wildcard needs a real multi-label suffix (`*.mydomain.com`).
- **Uninstalling drops the hosts**, so a later plugin reusing the id can't inherit consent
granted to a different one.
Users then point their own settings at one of the approved hosts. Anything else is refused by
the guard with `egress: <host> is not in the plugin's declared hosts`.
## Event subscriptions
@@ -851,6 +1001,9 @@ so a test catches a missing `scheduled`/`deleteUserData`/job before release.
| `egress` | string[] | allowed outbound hosts; required (non-empty, no bare `*`) when any `http:outbound` permission is present. |
| `capabilities.widget` | object | `{ title, slot, defaultSize }` — `slot` is `sidebar` (default), `hero`, `place-detail`, `day-detail`, or `reservation-detail`. |
| `capabilities.tripPage` | object | `{ replaces?, position? }` for `trip-page` plugins — `replaces` names core planner tabs to hide while active (`transports`, `buchungen`, `listen`, `finanzplan`, `dateien`, `collab`; never `plan`), `position` is the tab's 0-based index in the bar (0–50; omitted = appended). |
| `actions` | array | Buttons on the plugin's own settings page — `{ key, label, hint?, danger? }` (max 8). Implement each as `actions[key](ctx)` on the definition. **User-initiated**, so `ctx.settings.get()` returns the clicking user's value. See [Settings-page actions](#settings-page-actions). |
| `operatorEgress` | boolean | The plugin talks to a **self-hosted** service whose hostname only the operator knows. The admin adds the real hosts after install (Admin → Plugins → Allowed hosts) and the runtime unions them into the egress allow-list. Requires an `http:outbound` permission. See [Operator-supplied egress hosts](#operator-supplied-egress-hosts-operatoregress). |
| `capabilities.notificationChannel` | object | `{ title?, events? }` for a plugin implementing the `notificationChannel` hook — `title` names the column in the notification preferences matrix (default: the plugin's `name`), `events` **narrows** which events the channel carries (default: every non-admin event; admin-scoped events are never deliverable). Requires the `hook:notification-channel` permission. See [Notification channels](#notification-channels). |
| `capabilities.provides` | string[] | function names this plugin exposes to its dependents via `ctx.plugins.call` (see [Talking to other plugins](#talking-to-other-plugins)). |
| `capabilities.emits` | string[] | event names this plugin publishes to its dependents via `ctx.events.emit`. |
| `requiredAddons` | string[] | addon ids that must be **enabled** for the plugin to activate (see [Dependencies](#dependencies)). |
+31
View File
@@ -65,6 +65,7 @@ ungranted capability is physically unreachable**, not just disallowed. See
| `hook:atlas-layer-provider` | Draw country **tint layers** over the Atlas world map via the `hooks.atlasLayerProvider` provider hook | Implement `AtlasLayerProvider` in `hooks` — returns `{id, name?, countries: [{code, tone?, label?}]}[]` for the **acting user** (the hook takes no target parameter, so a plugin can't ask for anyone else's map). Codes must be ISO-3166 alpha-2 (uppercase-coerced, anything else dropped), tone is enum-whitelisted, counts capped (≤3 layers, ≤300 countries each). **Declarative only** — plugin JS never runs on the map canvas. Exposed at `GET /api/atlas-layers`. |
| `hook:journal-entry-provider` | Contribute extra **rows** under a journal entry via the `hooks.journalEntryProvider` provider hook | Implement `JournalEntryProvider` in `hooks` — returns `{label, value?, url?}[]` per entry, rendered under the entry card. The entry's journey is access-checked against the acting user (owner/contributor, like the journal detail routes) and the Journey addon must be on. The host caps the row count (≤12) + lengths (label 60, value 200) and allowlists the url (http/https/mailto); a failing provider is skipped. Exposed at `GET /api/journal-entry-rows/:entryId`. |
| `hook:trip-card-provider` | Add small **badges** to the dashboard trip cards via the `hooks.tripCardProvider` provider hook | Implement `TripCardProvider` in `hooks` — `getCards(tripIds, ctx)` is called ONCE with all the trip cards currently on the user's dashboard (each already access-checked for the acting user), returns `{tripId, id, label, value?, icon?, tone?, url?}[]`. **Declarative only** — plugin JS never runs on the dashboard. The host String-coerces + length-caps every field, enum-whitelists the tone, allowlists the url (http/https/mailto), caps the count (≤40/plugin) and drops any badge whose `tripId` the dashboard didn't ask about; a failing provider is skipped. Exposed at `GET /api/trip-card-contributions?tripIds=…`. |
| `hook:notification-channel` | Register a new **notification channel** (Gotify, Pushover, …) via the `hooks.notificationChannel` provider hook | Implement `NotificationChannel` in `hooks` — `send(msg, config, ctx)` receives a notification TREK has **already rendered** into the recipient's language (`{event, title, body, url?, tripName?}`) plus that recipient's own decrypted `scope:'user'` settings as `config`. Unlike every other hook this one is **host-initiated for an arbitrary recipient**, so it runs **userless**: `ctx.settings.get()` returns `undefined` and trip reads are refused — the recipient's credentials arrive as `config` precisely so the plugin never gains the right to read anything *as* them. Optional `test(config, ctx)` backs the "Send test" button. Declare the channel with `capabilities.notificationChannel: { title?, events? }`; `title` names the column in the preferences matrix (default: the plugin's name) and `events` may **narrow** which events it carries (default: every non-admin event — admin-scoped events are never deliverable to a plugin). Throw on failure: the host logs it and isolates it, so a dead channel can't stop the others. |
| `hook:user-data` | Honour GDPR **data-subject rights** — erase and export the data the plugin stores about a user — via the `deleteUserData` / `exportUserData` handlers | Implement `deleteUserData({userId}, ctx)` and/or `exportUserData({userId}, ctx)` on the plugin definition (not on `ctx`). Both are **userless** (no acting user; the plugin only learns the `userId` and touches its OWN db). When a TREK account is deleted, the host queues an erasure for every plugin holding this grant and retries it **durably** until the plugin ACKs — even across restarts — so implement `deleteUserData` idempotently. `exportUserData` returns a JSON-serialisable value the host aggregates for an admin at `GET /api/admin/plugins/user-data/:userId/export`. |
| `http:outbound` / `http:outbound:<host>` | Make outbound network requests | **Requires** a non-empty `egress[]`. Only a **per-host** `http:outbound:<host>` actually opens a host at runtime — see below. |
@@ -88,6 +89,26 @@ Two independent guards restrict a plugin's network, and **both are built from th
must be **non-empty**.
- `egress[]` may not contain a bare `*`.
### `operatorEgress` — hosts only the operator knows
A plugin that talks to a **self-hosted** service (a Gotify, an ntfy) cannot name the
operator's hostname at publish time. Setting `"operatorEgress": true` in the manifest lets
an **admin** add hosts after install (**Admin → Plugins → ⋯ → Allowed hosts**); the runtime
unions them into the child's allow-list and re-spawns the plugin.
It is not an escape hatch:
- Only a plugin whose manifest **declared** `operatorEgress` can be given hosts — so the
consent given at install still bounds what is possible.
- Only an **admin** can add one. An end user never widens a plugin's egress, even for a
plugin whose credentials they supply themselves.
- Added hosts are validated exactly like manifest egress (no bare `*`, no whole-TLD
wildcard, no scheme), and are dropped when the plugin is uninstalled.
- It requires an `http:outbound` permission — the manifest rejects it otherwise.
A LAN/loopback host additionally needs `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on`, which relaxes
private-address egress for **every** installed plugin.
Because the validator never cross-checks `egress[]` against the granted hosts:
> [!WARNING]
@@ -150,6 +171,16 @@ flow is in [[Publishing a Plugin|Plugin-Publishing]].
installs on `sha256` alone — but once a plugin has shipped signed, an unsigned
update for it is refused. See [[Publishing a Plugin|Plugin-Publishing]].
## Not a permission — settings-page actions
A plugin can contribute buttons to its own settings page (`actions` in the manifest — a
"Test connection", a "Sync now"). These need **no permission**: an action is the plugin's
own code, and it is run **for the user who clicked it**, so `ctx.settings.get()` returns
that user's value and any trip read is membership-checked against them — the same gates as
a route handler. Anything the action *does* (an outbound call, a trip write) still needs
that capability's own permission. The host refuses any action key the manifest didn't
declare. See [[Plugin Development#settings-page-actions|Plugin-Development]].
## Not a permission — inter-plugin calls & events
Calling another plugin (`ctx.plugins.call`) and exchanging events
+59 -1
View File
@@ -21,7 +21,36 @@ A plugin declares one `type` in its manifest, which decides where it surfaces:
| **widget** | On the dashboard — in the sidebar, as a boarding-pass style hero overlay (`slot: "hero"`), or docked into a **detail panel** inside the trip planner: the open place (`slot: "place-detail"`), day (`slot: "day-detail"`), or reservation (`slot: "reservation-detail"`) | At-a-glance info like flight status or weather; a detail-panel widget is scoped to just the one place/day/reservation you have open |
| **page** | As its own entry in the top navigation | A full self-contained tool |
| **trip-page** | As a tab **inside every trip planner** (alongside Plan / Transports / Files), scoped to the open trip | A tool that works against one trip at a time |
| **integration** | Nowhere visible — registers into TREK via hooks (e.g. a photo provider or calendar source) | Feed data into existing TREK features |
| **integration** | Nowhere visible — registers into TREK via hooks (e.g. a photo provider, a calendar source, or a **notification channel**) | Feed data into existing TREK features |
### Notification channels
An `integration` plugin can add a whole new **notification channel** — Gotify, Pushover,
Telegram, anything that takes a message — alongside TREK's built-in email, webhook and
ntfy.
Once you install and enable such a plugin, switch its channel on in
**Admin → Notifications**. It then appears as a new column in every user's
**Settings → Notifications** matrix, and each user supplies their own credentials on the
plugin's own settings page and picks per-event what they want pushed — exactly like a
built-in channel.
Two guarantees hold for a plugin channel specifically:
- **It is user-scoped.** Admin-only events (like *version available*) always go out over
your built-in admin channels, never a plugin's.
- **The plugin never sees anyone's trips.** TREK renders the notification — in the
recipient's language, deep link already built — *before* handing it over. The plugin
receives that finished message plus that user's own credentials for its service, and
nothing else. It is given no acting user, so the trip-reading APIs other plugins use are
refused to it outright.
### Plugin settings actions
A plugin can put **buttons on its own settings page** — a "Test connection", a "Sync now".
Users find them under **Settings → Plugins**, beneath that plugin's fields. An action runs
**as the user who clicked it**, so a "Test connection" checks *their* credentials and can
never see anyone else's. TREK refuses any action the plugin didn't declare in its manifest.
### Trip-page plugins: placement and tab takeover
@@ -190,9 +219,38 @@ The **⋯** menu on each row:
- **Restart** — stop and re-spawn the process (shown only while active).
- **View error log** — the plugin's own crash/failed-request log.
- **Allowed hosts** — add the hosts a plugin may reach, for a plugin that talks to a
service only *you* can name. See [Allowed hosts](#allowed-hosts) below.
- **Source repository** — opens the plugin's GitHub repo (registry installs only).
- **Delete** — uninstalls: removes the code and lets you keep or delete its data.
## Allowed hosts
A plugin's outbound hosts are normally fixed in its manifest, and you consent to them at
install. But a plugin that talks to a **self-hosted service** — a Gotify, an ntfy, an
Uptime Kuma — cannot know *your* hostname when it is published. Such a plugin declares
`operatorEgress`, and you supply the hosts yourself.
The plugin card shows a **"+ hosts"** chip when it works this way. Open **⋯ → Allowed
hosts** and add the hostname (e.g. `gotify.mydomain.com`). TREK restarts the plugin so it
picks up the new list.
What this does *not* let anyone do:
- **A plugin that never asked for it can never be given a host.** Only a plugin whose
manifest declares `operatorEgress` is eligible, so the consent you gave at install still
bounds what is possible.
- **Only you — an admin — can widen egress.** An end user can never add a host, even for
a plugin whose credentials they supply themselves.
- Hosts are validated like manifest egress: no bare `*`, no whole-TLD wildcard, no scheme.
Remove a host and the plugin loses it immediately (it restarts again).
- Uninstalling drops the hosts, so a later plugin reusing the id can't inherit them.
If the service runs on the **same machine or LAN** as TREK (a `localhost` or `192.168.x.x`
address), you also need `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on` — plugins may not reach
private addresses by default. That relaxes the policy for *every* installed plugin, so
only enable it if you trust them all.
## Reviewing what a plugin did — the activity log
Every user can see exactly what plugins have done **in their name**. Under