mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
+20
-1
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)). |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user