From a4972f8f136b75df45de4cbcd5fafbf45af95d0d Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 11 Jul 2026 19:27:02 +0200 Subject: [PATCH] docs(wiki): document the plugin notification-channel surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- wiki/Notifications.md | 21 ++++- wiki/Plugin-Cookbook.md | 94 ++++++++++++++++++++++ wiki/Plugin-Development.md | 155 ++++++++++++++++++++++++++++++++++++- wiki/Plugin-Permissions.md | 31 ++++++++ wiki/Plugins.md | 60 +++++++++++++- 5 files changed, 358 insertions(+), 3 deletions(-) diff --git a/wiki/Notifications.md b/wiki/Notifications.md index 65339d15c..c984f2fb8 100644 --- a/wiki/Notifications.md +++ b/wiki/Notifications.md @@ -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: diff --git a/wiki/Plugin-Cookbook.md b/wiki/Plugin-Cookbook.md index 20c912d2c..df7dcdda8 100644 --- a/wiki/Plugin-Cookbook.md +++ b/wiki/Plugin-Cookbook.md @@ -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:` (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` diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index dc6e15bab..c17e39e7f 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -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:` 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: 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)). | diff --git a/wiki/Plugin-Permissions.md b/wiki/Plugin-Permissions.md index 6f63c4848..ed9b50416 100644 --- a/wiki/Plugin-Permissions.md +++ b/wiki/Plugin-Permissions.md @@ -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:` | Make outbound network requests | **Requires** a non-empty `egress[]`. Only a **per-host** `http:outbound:` 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 diff --git a/wiki/Plugins.md b/wiki/Plugins.md index eab27fd94..6bce900fa 100644 --- a/wiki/Plugins.md +++ b/wiki/Plugins.md @@ -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