From 643cdaf8733c13d988f65d455d9d86b3d240ad3a Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 24 Aug 2026 23:42:23 +0200 Subject: [PATCH] docs: bring the skill in line with TREK 4 and SDK 1.6.0 --- skills/trek-plugin-dev/SKILL.md | 41 +++++---- skills/trek-plugin-dev/references/cli.md | 23 +++-- .../references/client-bridge.md | 64 +++++++++++++- skills/trek-plugin-dev/references/manifest.md | 41 +++++---- .../trek-plugin-dev/references/publishing.md | 38 +++++--- .../trek-plugin-dev/references/server-api.md | 87 +++++++++++++------ skills/trek-plugin-dev/references/testing.md | 14 ++- 7 files changed, 226 insertions(+), 82 deletions(-) diff --git a/skills/trek-plugin-dev/SKILL.md b/skills/trek-plugin-dev/SKILL.md index cdb5da3..c5815ee 100644 --- a/skills/trek-plugin-dev/SKILL.md +++ b/skills/trek-plugin-dev/SKILL.md @@ -1,6 +1,6 @@ --- name: trek-plugin-dev -description: Build, test, sign, and publish plugins for TREK, the self-hosted travel planner (github.com/liketrek/TREK). Covers the trek-plugin.json manifest, the definePlugin server API and ctx object, the sandboxed iframe postMessage bridge for widget/page UIs, permissions and egress rules, the enforced `trek` TREK-version range, local development with trek-plugin-sdk (create/dev/status/shot/validate/pack), author signing (keygen/--sign, Ed25519 trust-on-first-use), and publishing to the TREK-Plugins community registry including every CI gate. Use when creating or modifying a TREK plugin, working with trek-plugin-sdk or trek-plugin.json, signing a plugin or handling a signature/key-rotation problem (SIGNATURE_KEY_CHANGED, re-trust, allow-key-change), debugging PERMISSION_DENIED / RESOURCE_FORBIDDEN / TREK_VERSION_INCOMPATIBLE / TREK_VERSION_UNKNOWN or a plugin that will not install or activate on a given TREK version, or preparing a TREK-Plugins registry entry or PR. +description: Build, test, sign, and publish plugins for TREK, the self-hosted travel planner (github.com/liketrek/TREK). Covers the trek-plugin.json manifest, the definePlugin server API and ctx object, the sandboxed iframe postMessage bridge for widget/page UIs, permissions and egress rules, the enforced `trek` TREK-version range, local development with trek-plugin-sdk, author signing (keygen/--sign, Ed25519 trust-on-first-use), and publishing to the TREK-Plugins community registry including every CI gate. Use when creating or modifying a TREK plugin, working with trek-plugin-sdk or trek-plugin.json, signing a plugin or handling a signature/key-rotation problem (SIGNATURE_KEY_CHANGED, re-trust, allow-key-change), debugging PERMISSION_DENIED / RESOURCE_FORBIDDEN / TREK_VERSION_INCOMPATIBLE / TREK_VERSION_UNKNOWN / API_VERSION_INCOMPATIBLE or a plugin that will not install or activate on a given TREK version, recovering from a failed publish (rollback, unrelease), or preparing a TREK-Plugins registry entry or PR. --- # TREK Plugin Development @@ -64,8 +64,10 @@ npx trek-plugin-sdk shot # → docs/screenshot.png, 1600×900, in the npx trek-plugin-sdk publish --repo you/trek-plugin-my-widget --tag v1.0.0 --sign # = ① check (every offline registry gate) → ② pack → ③ git tag + GitHub release # → ④ preflight (the gates that need the release to exist) → ⑤ registry PR. -# If a step-① gate fails, NOTHING is packed, tagged, pushed or released — so you -# fix it and re-run against the SAME version. Requires git + gh (authed). +# If a step-① gate fails, NOTHING is packed, tagged, pushed or released — and if a +# LATER step fails, publish ROLLS BACK the release/tags it created, so either way +# you fix and re-run against the SAME version (--keep-release opts out; +# `unrelease ` cleans up an already-stranded state). Requires git + gh (authed). # In a TERMINAL, publish OFFERS to sign and creates the key for you — --sign is # only needed in scripts/CI, which are never prompted. (keygen makes the key by # hand: ~/.trek-plugin/signing.key — ONCE, ever, for all your plugins. BACK IT UP.) @@ -86,7 +88,9 @@ from *you*, not merely that the registry vouched for some bytes — so a comprom registry can't ship code under your name. In a terminal `publish` proposes it and makes the key for you; in scripts/CI, which are never prompted, pass `--sign`. Signing **late is fine** — unsigned → signed at v1.4.0 breaks nobody, because -nothing is pinned until a signed version installs. The thing you cannot do is +nothing is pinned until a signed version installs; the SDK **retro-signs your +older versions automatically** on the first signed update (the registry requires +every version signed once a key is present). The thing you cannot do is *stop* (see rule 12), so the only decision that ever really binds is whether you'll keep the key safe. Back it up and sign. @@ -149,18 +153,20 @@ See [references/testing.md](references/testing.md). | `widget` | Dashboard card (`sidebar` slot — glassy auto-height) or a **non-interactive** boarding-pass hero strip (`hero` slot, ~110px, desktop-only); plus the scoped planner slots `place-detail` (gets `placeId`), `day-detail` (`dayId`), and `reservation-detail` (`reservationId`) | At-a-glance info (flight status, weather, mascot); a per-place/day/reservation add-on | | `page` | Own entry in the top navigation → full-page iframe (you own the layout) | A self-contained tool | | `trip-page` | A tab **inside every trip planner**, scoped to the open trip (`tripId` always set); full-frame like `page`, no dashboard nav. `capabilities.tripPage` can replace core tabs / set tab position (tab-takeover) | A per-trip tool | -| `integration` | No UI; background routes, jobs, events, plus **wired provider hooks** (place-detail / trip-warning / table / map-marker / pdf-section / atlas-layer / journal-entry / trip-card / photo / calendar) | Feeding/syncing data; enriching core UI natively | +| `integration` | No UI; background routes, jobs, events, plus **wired provider hooks** (place-detail / trip-warning / table / map-marker / map-layer / route / day-schedule / day-tint / pdf-section / atlas-layer / journal-entry / trip-card / photo / calendar / notification-channel) | Feeding/syncing data; enriching core UI natively | Note: **`jobs[]` need the `jobs:run` grant** — with it, declared cron jobs run -via node-cron (userless); without it they never fire. The persistent +on their cron schedule (userless); without it they never fire. The persistent **`ctx.scheduler`** (`at`/`in`/`every`/`cancel`, same grant) adds restart-surviving one-shot/recurring callbacks into a `scheduled` handler. **To react to core activity, declare `events`:** `events: [{ on, handler }]` + `events:subscribe`; the handler gets `{ event, tripId, entity?, entityId?, snapshot? }` (`snapshot` only when you also hold the family's `db:read:*`), runs -with no user, fire-and-forget. All ten **provider hooks are wired** -(place-detail / trip-warning / table / map-marker / pdf-section / atlas-layer / -journal-entry / trip-card / photo / calendar), plus the GDPR **`hook:user-data`** +with no user, fire-and-forget (the SDK exports the family catalog as +`EVENT_FAMILIES` / `EVENT_SNAPSHOT_GRANT`). All fifteen **provider hooks are +wired** (place-detail / trip-warning / table / map-marker / map-layer / route / +day-schedule / day-tint / pdf-section / atlas-layer / journal-entry / trip-card / +photo / calendar / notification-channel), plus the GDPR **`hook:user-data`** (`deleteUserData`/`exportUserData`, userless, own-db) — so an `integration` can inject native UI or honour data-rights with no iframe. See [references/server-api.md](references/server-api.md). @@ -220,8 +226,8 @@ inject native UI or honour data-rights with no iframe. See `trips.update`) can be `undefined` on a host that predates them.** Your `"trek"` range is what prevents that, and **since TREK 3.4.0 it is enforced**: an instance outside the range refuses to install *or* activate your plugin. So - declare it honestly — `"trek": ">=3.4.0 <4.0.0"` — and the namespaces you need - are guaranteed present on every host that can run you. + declare it honestly — the scaffold writes `"trek": ">=4.0.0 <5.0.0"` — and the + namespaces you need are guaranteed present on every host that can run you. **Still guard anyway**, for the one hole the gate leaves: a host whose `APP_VERSION` is not a semver version (the Docker default is the literal `dev`) cannot be compared to a range, so the check is skipped and an unversioned build @@ -287,7 +293,10 @@ inject native UI or honour data-rights with no iframe. See it is the one security property only you can supply. It is a **one-way door you may walk through late**: unsigned → signed at any version breaks nobody (nothing is pinned until a signed version installs), so - "sign from v1.0.0 or never" is simply false. But once a plugin has shipped + "sign from v1.0.0 or never" is simply false — and the first signed update + **retro-signs the older versions for you** (each pinned artifact is downloaded, + verified against its sha256, and signed with the same key; the registry requires + every version signed once a key appears). But once a plugin has shipped signed, TREK refuses — on every instance that already has it — an update that drops the key, changes the key, or ships an unsigned version, and **registry CI blocks all three before merge**. `publish` also refuses an unsigned release of @@ -383,9 +392,11 @@ inject native UI or honour data-rights with no iframe. See that the plugin won't work there. `"install latest"` resolves to the newest version this TREK can run, and an update that would drag a working plugin *out* of compatibility is refused rather than performed. -- Current plugin API: `apiVersion: 1` (`PLUGIN_API_VERSION`) — declared, but **not - enforced** at install (no version negotiation; the `trek` range is what actually - gates). Artifact limits: 25 MB/file, 50 MB total, 4000 zip entries. +- Current plugin API: `apiVersion: 1` (`PLUGIN_API_VERSION`) — **enforced**: it must + be a positive integer, and a manifest declaring a version newer than the host + supports is refused at install and won't activate (`API_VERSION_INCOMPATIBLE`, + no admin override). The `trek` range remains the day-to-day compatibility gate. + Artifact limits: 25 MB/file, 50 MB total, 4000 zip entries. ## Canonical examples diff --git a/skills/trek-plugin-dev/references/cli.md b/skills/trek-plugin-dev/references/cli.md index def78f1..bf53074 100644 --- a/skills/trek-plugin-dev/references/cli.md +++ b/skills/trek-plugin-dev/references/cli.md @@ -12,25 +12,27 @@ npx trek-plugin-sdk [args] **Four commands are the job** — `create`, `dev`, `status`, `publish` (this is the list `trek-plugin help` prints as "The path"); `shot` is the fifth you'll almost always run, because `status` points you at it. Everything else (`validate`, -`pack`, `keygen`, `sign`, `entry`, `preflight`, `submit`, `release`) is a step -one of those already does, exposed for scripts and hand-assembled PRs. +`pack`, `keygen`, `sign`, `entry`, `preflight`, `submit`, `release`, `unrelease`) +is a step one of those already does — or, for `unrelease`, undoes — exposed for +scripts and hand-assembled PRs. **When you don't know what to do next, run `trek-plugin status`.** It answers "where am I, what's left" and names exactly one next command. | Command | Needs | What it does | |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `create [name] [--type t] [--icon Name] [--slot s] [--author x] [--description x] [--permissions "a b"] [--template blank\|notification-channel] [--egress host,host] [--required-addons a,b] [--interactive]` | — | Scaffold a plugin. No name (or `--interactive`) → a **Clack wizard** (id, **location**, type, **widget slot**, **icon** — validated against lucide as you type — author, **description**, permissions, and — if `http:outbound` is picked — **egress hosts**), then offers `git init` + `npm install`. Permissions are a **single grouped multiselect** (SDK ≥ 1.5.0): one scrollable screen with all **58** grants under **13 area headings** (storage · trips · itinerary · bookings · budget · packing · files · collab · journal/atlas/vacay/collections · hooks · background · realtime · services) — space toggles a grant, an area header toggles its whole group, `db:own` starts pre-selected. (A two-step areas-then-grants flow was considered and rejected: Clack has no "back", so a wrong area pick would be a dead end.) ⚠️ **On SDK ≤ 1.4.x the wizard offered only 18 of the 58** — `jobs:run`, `events:subscribe` and 7 of the 12 `hook:*` among the missing — so a plugin needing one had to hand-edit the manifest. Nothing rejected those permissions (the validator always knew all 58); they were simply unofferable. Upgrade, or write them in by hand. With a name it's non-interactive and still requires the name. The page/widget scaffold emits a **design-kit client** (`` marker + a `window.trek` UI); a **notification-channel template** is available (wizard option / `--template notification-channel`). It writes an `icon` (a sensible lucide default per type if you don't pick one), the widget `slot` **explicitly**, `"trek": ">=3.4.0 <4.0.0"`, a `docs/` dir, and a `.gitattributes` (`eol=lf`, so a Windows re-pack hashes the same). ⚠️ A **valueless** flag counts as absent — a bare `--permissions` grants nothing (it does *not* create a permission named `true`). **A fresh scaffold RUNS and PACKS but does not pass `validate`** — the README is a stub and there's no screenshot. That's deliberate; `status` says what's missing. | +| `create [name] [--type t] [--icon Name] [--slot s] [--author x] [--description x] [--permissions "a b"] [--template blank\|notification-channel] [--egress host,host] [--required-addons a,b] [--interactive]` | — | Scaffold a plugin. No name (or `--interactive`) → a **Clack wizard** (id, **location**, type, **widget slot**, **icon** — validated against lucide as you type — author, **description**, permissions, and — if `http:outbound` is picked — **egress hosts**), then offers `git init` + `npm install`. Permissions are a **single grouped multiselect** (SDK ≥ 1.5.0): one scrollable screen with all **63** grants under **13 area headings** (storage · trips · itinerary · bookings · budget · packing · files · collab · journal/atlas/vacay/collections · hooks · background · realtime · services) — space toggles a grant, an area header toggles its whole group, `db:own` starts pre-selected; picking `oauth:client` also scaffolds the five instance-scope OAuth-broker settings. (A two-step areas-then-grants flow was considered and rejected: Clack has no "back", so a wrong area pick would be a dead end.) ⚠️ **On SDK ≤ 1.4.x the wizard offered only 18** of the then-58 grants — `jobs:run`, `events:subscribe` and most `hook:*` among the missing — so a plugin needing one had to hand-edit the manifest. Nothing rejected those permissions (the validator always knew the full list); they were simply unofferable. Upgrade, or write them in by hand. With a name it's non-interactive and still requires the name. The page/widget scaffold emits a **design-kit client** (`` marker + a `window.trek` UI); a **notification-channel template** is available (wizard option / `--template notification-channel`). It writes an `icon` (a sensible lucide default per type if you don't pick one), the widget `slot` **explicitly**, `"trek": ">=4.0.0 <5.0.0"`, a `docs/` dir, and a `.gitattributes` (`eol=lf`, so a Windows re-pack hashes the same). ⚠️ A **valueless** flag counts as absent — a bare `--permissions` grants nothing (it does *not* create a permission named `true`). **A fresh scaffold RUNS and PACKS but does not pass `validate`** — the README is a stub and there's no screenshot. That's deliberate; `status` says what's missing. | | `dev [dir] [--port 4317]` | — | Local dev server (default `http://localhost:4317`, 127.0.0.1 only) with hot reload, SDK injection, permission-enforcing `ctx` (`db:own` backed by a real SQLite file under `.trek-dev/`). Serves a dashboard at `/`, your UI in the themed sandboxed frame at **`/preview`**, the raw unframed UI at `/ui` (expanding the `` marker), your routes at `/api/*`, and `/__dev/fire/` for non-route entry points. **Enforces the permissions TREK gates OUTSIDE `ctx` too** (SDK ≥ 1.5.0): it warns at load about any hook/event/job you implement but never granted (production would **silently never call it**), 403s if you fire one, and runs the **real egress guard** so an undeclared host is refused here rather than after install. Honours `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on` like the host. See [testing.md](testing.md#what-dev-enforces-hooks-events-jobs-egress). | | `status [dir]` | — | **"Where am I? What's left?"** Runs every registry gate that can be answered without a network — which is nearly all of them — and prints the journey as a checklist grouped **Manifest / Code / Docs / Release**, plus a **Repo** line (is `vX.Y.Z` tagged locally, is the tree dirty — *the registry grades the commit, not your working tree*), then names **one** next command. **It never exits non-zero**: it is for orientation, not gating (`validate` is the gate), so it's safe to run constantly. Takes no flags. | | `shot [dir] [--port 4317] [--out docs/screenshot.png] [--dark] [--no-serve]` | Playwright | Boots `dev`, renders your plugin in the themed `/preview` frame, writes a **1600×900** `docs/screenshot.png` (2× DPI, waits ~1.2 s so it doesn't photograph a loading skeleton). This is the built-in way to satisfy the registry's screenshot gate. **Playwright is deliberately NOT an SDK dependency** (it ships a ~300 MB browser) — `npm i -D playwright && npx playwright install chromium`; without it the command tells you exactly that instead of throwing a resolution error. `--no-serve` shoots a dev server you're already running (fails if nothing is on the port; and *without* `--no-serve` it refuses if the port is already busy). ⚠️ An **`integration` has no UI**, so `shot` refuses and tells you to screenshot the TREK surface your plugin *changes* instead (the notification it sends, the badge it adds, its settings page). | -| `validate [dir]` | — | **The gate.** Runs the same offline checks as `status` and exits **1** if any would be rejected by the registry — this is the scripts/CI form, `status` is the human form. It is no longer a token subset: **errors** now on an invalid manifest, a missing/unsatisfiable **`trek` range**, a missing `server/index.js`, an **`icon` lucide doesn't have**, `name`/`description`/`author` outside the registry's length limits (2–60 / 5–200 / ≤80 — and a **missing** description, which the registry requires), a **homoglyph** name (Latin mixed with Cyrillic/Greek), an **`egress[]` host with no matching `http:outbound:` permission** (see below), a missing README, a README missing any of the **four required sections**, **template placeholders**, **< 400 chars of prose**, a **screenshot that doesn't resolve to a real file on disk**, and any **permission not explained in the README**. **Warns** (never fatal) on dir name ≠ id, an unbounded `trek` (`"*"`), an unknown `requiredAddons` id, **emoji** in name/description (TREK strips them from host-rendered text), a reachable host absent from `egress[]`, and a raw `` without the design kit. A green `validate` means every registry gate that doesn't need the tag/release to exist. | | `pack [dir] [--out plugin.zip] [--json]` | — | Builds `plugin.zip` in the installer's exact layout; prints **sha256 + byte size**. `--json` for machine-readable output. It refuses only what makes the plugin **unloadable** — a broken manifest, no `server/index.js`, a native binary — and **deliberately does *not* enforce the publish gates** (unwritten README, missing screenshot), because packing is how you install a plugin into a local TREK to try it; blocking the dev loop on a docs gate would be absurd. **So a green `pack` is not a green `validate`.** ⚠️ Zip mod dates are fixed (a same-machine, same-SDK re-pack is byte-identical), but re-packs elsewhere can differ (walk order, per-SDK inlined kit, CRLF) — the registry `sha256`/`size` must come from the **uploaded release asset**, never a re-pack (see [publishing.md](publishing.md)). | -| `entry [dir] --repo --tag [--dir d] [--zip plugin.zip] [--commit ] [--asset ] [--merge ] [--out ] [--sign [key]]` | git | Emits the ready-to-PR registry entry: resolves `commitSha` from the tag (`git rev-parse ^{commit}`), fills `downloadUrl`, `sha256`, `size`, `apiVersion`, `icon` (copied from your manifest — it's what the store tile draws; on `--merge` it refreshes from the manifest but won't wipe an icon the entry already has), `operatorEgress`, **`requiredAddons` and `pluginDependencies`** (mirrored from the manifest — they were silently omitted until recently, which failed the registry's parity gate for every plugin with an addon dependency; **nothing to hand-add any more**), and `trek` (your manifest range, verbatim — the one compat field an entry carries, and what TREK gates on). Refuses to build an entry without a usable `trek` range. It no longer emits `minTrekVersion`: that field only restated the range's lower bound, and is deprecated. `--merge` prepends the new version (newest-first) and refuses a key switch / unsigned update to a signed plugin. ⚠️ `entry` hashes the **local** zip (`--zip`, default `plugin.zip`) — it never contacts GitHub; `artifact not found: ` means the local zip is missing (run `pack` first). What matters is that the file you hash is **byte-identical to the uploaded release asset**: generate the entry from the same `plugin.zip` you uploaded, then run `preflight`, which *does* download and verify the asset. | +| `entry [dir] --repo --tag [--dir d] [--zip plugin.zip] [--commit ] [--asset ] [--merge ] [--out ] [--sign [key]]` | git | Emits the ready-to-PR registry entry: resolves `commitSha` from the tag (`git rev-parse ^{commit}`), fills `downloadUrl`, `sha256`, `size`, `apiVersion`, `icon` (copied from your manifest — it's what the store tile draws; on `--merge` it refreshes from the manifest but won't wipe an icon the entry already has), `operatorEgress`, **`requiredAddons` and `pluginDependencies`** (mirrored from the manifest — they were silently omitted until recently, which failed the registry's parity gate for every plugin with an addon dependency; **nothing to hand-add any more**), and `trek` (your manifest range, verbatim — the one compat field an entry carries, and what TREK gates on). Refuses to build an entry without a usable `trek` range. It no longer emits `minTrekVersion`: that field only restated the range's lower bound, and is deprecated. `--merge` prepends the new version (newest-first — registry CI enforces that ordering) and refuses a key switch / unsigned update to a signed plugin; on the **first signed** update it **retro-signs the older unsigned versions** with your key (each pinned artifact downloaded, verified against its sha256, then signed — a byte mismatch refuses; without `--sign` it errors telling you to pass it). ⚠️ `entry` hashes the **local** zip (`--zip`, default `plugin.zip`) — it never contacts GitHub; `artifact not found: ` means the local zip is missing (run `pack` first). What matters is that the file you hash is **byte-identical to the uploaded release asset**: generate the entry from the same `plugin.zip` you uploaded, then run `preflight`, which *does* download and verify the asset. | | `release [dir] --repo --tag [--out] [--notes] [--commit] [--merge] [--sign [key]]` | git + `gh` (authed) | One shot: `pack` → `gh release create` (uploads the zip) → prints the entry, but does **not** open the registry PR. For releasing now and submitting later. `publish` is the whole thing. | | `preflight [dir] --repo --tag [--all] [--entry ] [--zip] [--commit] [--sign] [--key] [--registry ]` | network | Runs the **whole check registry**: every offline gate `validate` runs, **plus** the six that genuinely need GitHub — tag→commit resolution (annotated tags are dereferenced), **manifest at the pinned commit** vs the entry (incl. `trek`/`operatorEgress`/`requiredAddons`/`pluginDependencies`), the **released artifact** (downloads, hashes to the pinned sha256, native scan, **signature verification**), the **README at the pinned commit** (so an uncommitted README is caught: a green tree and a red tag is the classic), **owner binding** (is this id already bound to another GitHub owner in the registry's `OWNERS.json`), and the **signing-downgrade guard** (does this update drop or rotate a key you already published under — merging one bricks the update on every instance that has the plugin). The last two are new; older docs said preflight didn't replay them. **Default checks only the newest version; `--all` checks every `versions[]`.** Green preflight ⇒ green CI, bar the entry's full **JSON-schema** validation (unknown/extra keys, field formats), which only bites a hand-edited entry. | -| `submit --repo --tag [--branch ] [--keep] [--draft] [--registry ] [--zip] [--commit] [--sign [key]]` | `gh` (authed) | Forks TREK-Plugins (once), branches (`plugin--` unless `--branch`), writes/merges the entry, pushes, opens the PR. `--keep` keeps the temp clone dir. **`submit` does NOT run preflight** (a clean path if you ever need to skip it). | -| `publish [dir] --repo --tag [--sign [key]] [--no-checks] [--no-preflight] [--force] [--draft] [--registry ] [--notes ]` | git + `gh` (authed) | **The whole release, in five steps — and the order is the point:** ① **check** (every registry gate answerable from the working tree) → ② **pack** → ③ tag + push + **GitHub release** with the artifact → ④ **preflight** (the gates that need the tag/release to exist) → ⑤ **submit** the registry PR. **If a step-1 gate fails, nothing is packed, tagged, pushed or released** — so you fix it and re-run against the *same* version. (It used to pack and release *before* checking, which burned your tag on an unwritten README: a release is effectively immutable, because the registry pins its sha256.) `--no-checks` skips step 1 — an escape hatch for a re-run, **never right on a first publish**; `--no-preflight` skips step 4. **In a terminal, without `--sign`, it OFFERS to sign** — creating the key inline if you have none — and shows a `sign` line in the confirmation summary; **non-interactive runs are never prompted**, so scripts/CI still pass `--sign`. **Step 1 also refuses an unsigned release of an already-signed plugin** (TREK would reject that update on every instance that has it): that guard used to live in `preflight`, i.e. step 4, *after* the immutable release was cut — which burned the tag. **Refuses to overwrite an existing release** (`release vX.Y.Z already exists on …`); **`--force`** overrides and is only ever safe if that release was *never merged into the registry*. It **keeps `plugin.zip`** on exit — those are the exact bytes the release and the pin were computed from, so re-run `entry`/`sign` against *that* file, never a rebuild. | +| `submit --repo --tag [--branch ] [--keep] [--draft] [--registry ] [--zip] [--commit] [--sign [key]]` | `gh` (authed) | Forks TREK-Plugins (once), **fast-forwards your fork from the registry** (a diverged fork warns with the `gh repo sync --force` fix and the submit continues — the branch is based on the registry's `main` either way), branches (`plugin--` unless `--branch`), writes/merges the entry — **retro-signing older unsigned versions** on a first signed update, same as `entry --merge` — pushes, opens the PR. `--keep` keeps the temp clone dir. **`submit` does NOT run preflight** (a clean path if you ever need to skip it). | +| `publish [dir] --repo --tag [--sign [key]] [--no-checks] [--no-preflight] [--force] [--keep-release] [--draft] [--registry ] [--notes ]` | git + `gh` (authed) | **The whole release, in five steps — and the order is the point:** ① **check** (every registry gate answerable from the working tree) → ② **pack** → ③ tag + push + **GitHub release** with the artifact → ④ **preflight** (the gates that need the tag/release to exist) → ⑤ **submit** the registry PR. **If a step-1 gate fails, nothing is packed, tagged, pushed or released** — and **if step 4 or 5 fails, publish ROLLS BACK exactly what the run created** (the release, the pushed tag, the local tag; anything that existed before the run is never touched), so either way you fix and re-run against the *same* version. `--keep-release` opts out of the rollback (the error then prints the manual cleanup commands). A **leftover tag from an earlier failed run** — no release, not on `HEAD` — is recognised as debris and **moved to your current commit** instead of silently releasing the stale one. `--no-checks` skips step 1 — an escape hatch for a re-run, **never right on a first publish**; `--no-preflight` skips step 4. **In a terminal, without `--sign`, it OFFERS to sign** — creating the key inline if you have none; **non-interactive runs are never prompted**, so scripts/CI still pass `--sign`. **Step 1 also refuses an unsigned release of an already-signed plugin** (TREK would reject that update on every instance that has it). **Refuses to overwrite an existing release** (`release vX.Y.Z already exists on …`); **`--force`** overrides and is only ever safe if that release was *never merged into the registry*. It **keeps `plugin.zip`** on exit — those are the exact bytes the release and the pin were computed from, so re-run `entry`/`sign` against *that* file, never a rebuild. | +| `unrelease [dir] --repo [--registry ] [--yes]` | git + `gh` (authed) | **Undo a stranded release**: deletes the GitHub release, the remote tag, and the local tag in one go — each reported as deleted/absent, so a partial earlier cleanup doesn't stop the rest. Hard safety rail: it fetches the registry's published `dist/index.json` first and **refuses, with no override, a version that is actually published** (the registry pins that artifact's sha256 — deleting it breaks install/update for everyone; ship a new version instead). If the registry index can't be reached, it refuses unless you pass `--yes` (a TTY confirm counts). Rarely needed now that `publish` rolls back its own failures — reach for it for older strandings or a change of heart before the PR merges. | | `keygen [--key ]` | — | Creates a dependency-free Ed25519 signing key (default `~/.trek-plugin/signing.key`, mode 0600). **Run this once, ever — one key covers all your plugins — and back it up off-machine.** It **refuses to overwrite** an existing key, so you can't clobber it by accident; you can only lose it. | | `sign [zip] [--key ]` | key | **Prints** `signature` + `authorPublicKey` for an artifact (default `plugin.zip`) — does **not** modify any entry. Usually you want `publish --sign`. | @@ -63,7 +65,7 @@ refusing it — so if you get this error, the flag genuinely does nothing on tha **Don't guess flags** — the table above is the complete set. **Help is tiered.** `trek-plugin help` prints *the path* (create / dev / status / -publish) and an "Also" line for the other nine; **`trek-plugin help ` and +publish) and an "Also" line for the other ten; **`trek-plugin help ` and `trek-plugin --help` print a real per-command page** — what it does, when you'd reach for it, and its flags. Help goes to **stdout** and exits **0** (it's intercepted before the unknown-flag check, so it always works). `trek-plugin --version` / `-v` / @@ -77,7 +79,7 @@ unknown flag) · **2** no command / unrecognized command (usage on stderr). Running `npx trek-plugin-sdk` with **no command** in a terminal opens a menu — Create / dev / **Status** / **Screenshot** / Publish, plus an **Advanced…** submenu -(validate, pack, keygen, sign, entry, preflight, submit, release). Menu-launched +(validate, pack, keygen, sign, entry, preflight, submit, release, unrelease). Menu-launched directory commands **ask which directory** (they used to silently run against the cwd, which is very often not a plugin). Any command missing required args (`--repo`/`--tag`) **prompts** for them instead of erroring, and @@ -158,6 +160,9 @@ Update (v1.1.0): # bump "version" in trek-plugin.json to 1.1.0 first npx trek-plugin-sdk publish --repo you/trek-plugin-my-widget --tag v1.1.0 --sign # or manually: pack → release v1.1.0 → entry --merge existing.json → PR +# First signed update onto an unsigned history? The merge retro-signs the older +# versions with your key automatically (the registry requires every version +# signed once a key is present). ``` Failure behavior worth knowing: the git tag must **equal** the manifest diff --git a/skills/trek-plugin-dev/references/client-bridge.md b/skills/trek-plugin-dev/references/client-bridge.md index 56cfcd4..aa6ad30 100644 --- a/skills/trek-plugin-dev/references/client-bridge.md +++ b/skills/trek-plugin-dev/references/client-bridge.md @@ -63,22 +63,32 @@ window.parent.postMessage( | `trek:invoke` | `{ requestId, sub, method, body }` | Call your own route (`sub` is the path below `/api/plugins/`, query string allowed); resolves as `trek:response` or `trek:error` | | `trek:openExternal` | `{ url }` | Ask the host to open an `http`/`https` URL in a new browser tab — the **only** way to open a link: the sandbox has no `allow-popups`, so `window.open()`/`target="_blank"` are blocked (see [Opening external links](#opening-external-links-trekopenexternal)) | | `trek:confirm` | `{ requestId, title?, message, confirmLabel?, cancelLabel?, danger? }` (host-truncated: title ≤120, message ≤500, labels ≤40) | Ask the host to render a **native** confirm dialog (one at a time). The shown title is always `" — "` (just the plugin name if no title), so a plugin can't spoof a TREK system dialog. ⚠️ **`danger` defaults to TRUE** (`danger: msg.danger !== false`) — an unspecified confirm renders **destructive/red**; pass `danger:false` for a neutral one. Host answers with `trek:confirm:result` (correlate by `requestId`). ⚠️ A **second confirm while one is open is auto-answered `{confirmed:false}`** without the user seeing anything — don't fire confirms concurrently | +| `trek:geolocation` | `{ requestId, action?: 'get'\|'watch'\|'clear' }` | Ask the HOST for the browser's live position — **needs the `geolocation:read` permission** (else `trek:error` code `forbidden`). The host reads `navigator.geolocation` (the browser's own site prompt still applies) and posts plain data back: `get` answers once (`trek:geolocation:result` with `position`), `watch` streams `trek:geolocation:update` fixes until `clear` (one watch per frame; the host force-stops it when the frame closes; the grant is re-checked on every fix). Nothing is sent to the server | +| `trek:session:get` | `{ requestId, key, scope?: 'plugin'\|'trip' }` | Read host-managed **per-browser-tab** JSON state; answered as `trek:response` (`data` is `undefined` when unset). `scope:'plugin'` (default) is per plugin; `scope:'trip'` additionally partitions by the trip in view and fails with `NO_TRIP_CONTEXT` outside one | +| `trek:session:set` | `{ requestId, key, value, scope? }` | Store a JSON-serialisable value. Quotas per scope: **32 keys, 64-char keys, 1 KiB per serialized value** — over → `trek:error` `SESSION_KEY_LIMIT` / `SESSION_INVALID_KEY` / `SESSION_VALUE_TOO_LARGE` (plus `SESSION_INVALID_VALUE` for unserialisable, `SESSION_STORAGE_ERROR` when the browser refuses). The storage key is host-owned and includes the signed-in user + your plugin id, so scopes never collide. **Not for secrets** | +| `trek:session:remove` | `{ requestId, key, scope? }` | Drop one key; answered as `trek:response` | +| `trek:session:clear` | `{ requestId, scope? }` | Drop every key in that scope; answered as `trek:response` | ### Messages TREK sends you (host bridge) | Message | Payload | |-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `trek:context` | `{ tripId, userId, theme, locale, dir, hostOrigin, user, formats, tokens, appearance, placeId, dayId, reservationId }`. `tripId` is **`string \| null`** — `null` for a `page` plugin and a widget with no spotlighted trip, **set for a `trip-page` tab and scoped detail widgets**. `placeId` / `dayId` / `reservationId` (`string \| null`) — each set only for its own scoped slot (`place-detail` → `placeId`, `day-detail` → `dayId`, `reservation-detail` → `reservationId`), else `null`. `userId` is a string or `null`; `user` is `{name, avatar, isAdmin}` or `null` — **never email**. `theme` is `'light'`/`'dark'`; `dir` is `'ltr'`/`'rtl'` (the kit sets `lang` + `dir` on `<html>` from it — hand-rolled UIs should mirror it). `formats` = `{locale, currency, timeFormat, distanceUnit, temperatureUnit, timezone}`; `tokens` = the global palette for the current theme (see §1); `appearance` = `{scheme, density, reducedMotion, noTransparency}`. The host also pushes context **proactively on iframe load** (so `trek:ready` is belt-and-braces) and **re-sends it live** on any theme/appearance change (accent/density/high-contrast/reduced-motion) **and on locale/format-settings/trip-or-entity-id changes** — handle **repeated** `trek:context`, not just the first. See [Making the UI feel native](#making-the-ui-feel-native). | +| `trek:context` | `{ tripId, userId, theme, locale, dir, hostOrigin, user, formats, tokens, appearance, placeId, dayId, reservationId }`. `tripId` is **`string \| null`** — `null` for a `page` plugin and a widget with no spotlighted trip, **set for a `trip-page` tab and scoped detail widgets**. `placeId` / `dayId` / `reservationId` (`string \| null`) — each set only for its own scoped slot (`place-detail` → `placeId`, `day-detail` → `dayId`, `reservation-detail` → `reservationId`), else `null`. `userId` is a string or `null`; `user` is `{name, avatar, isAdmin}` or `null` — **never email**. `theme` is `'light'`/`'dark'`; `dir` is `'ltr'`/`'rtl'` (the kit sets `lang` + `dir` on `<html>` from it — hand-rolled UIs should mirror it). `formats` = `{locale, currency, timeFormat, distanceUnit, temperatureUnit, timezone, blurBookingCodes}` (`blurBookingCodes` mirrors the user's "blur booking codes" preference — a **display hint, not redaction**: codes still arrive in full over `trek:invoke`); `tokens` = the global palette for the current theme (see §1); `appearance` = `{scheme, density, reducedMotion, noTransparency}`; **`viewport`** = `{ surface, formFactor: 'phone'|'desktop', fill, insets: {top, bottom} }` — see [On a phone](#on-a-phone--viewport-surface-fill-insets). The host also pushes context **proactively on iframe load** (so `trek:ready` is belt-and-braces) and **re-sends it live** on any theme/appearance change (accent/density/high-contrast/reduced-motion) **and on locale/format-settings/trip-or-entity-id changes** — handle **repeated** `trek:context`, not just the first. See [Making the UI feel native](#making-the-ui-feel-native). | | `trek:response` | `{ requestId, data }` — successful `trek:invoke` | | `trek:error` | `{ requestId, code, message }` — failed `trek:invoke`; `code` is the HTTP status or `"error"` | | `trek:confirm:result` | `{ requestId, confirmed }` — reply to your `trek:confirm` | +| `trek:geolocation:result` | `{ requestId, position? \| watching? \| cleared? \| error? }` — the answer to a `trek:geolocation` request; `position` = `{ lat, lng, accuracy, heading, speed, timestamp }`, `error` ∈ `forbidden`/`denied`/`timeout`/`unavailable`/`unsupported` | +| `trek:geolocation:update` | `{ position?, error? }` — a streamed watch fix | | `trek:event` | `{ event, tripId }` — live push of a trip event into the frame for the trip in view: **core events** (`place:created`, `day:updated`, …) **and your own `plugin:<id>:*` broadcasts**. Carries **only** the event name + `tripId`, never the payload — fetch details via `trek:invoke`. This is the **client-side counterpart of the server `events` surface**, and the path that lets your own `ctx.ws.broadcast*` reach your iframe (see [server-api.md](server-api.md)). Treat it as an accelerator on top of an initial fetch, not a replacement — it is **only forwarded to frames mounted with a `tripId`** and only while a planner has the trip joined, so **dashboard `sidebar`/`hero` widgets should still poll** | > The wire protocol above is the real contract — you can send/handle every > message with hand-rolled `postMessage` (many plugins roll the bridge > themselves; `window.trek` is just sugar over exactly these messages). The kit -> helpers `trek.openExternal(url)`, `trek.confirm(opts)` (→ `Promise<boolean>`), -> and `trek.onEvent(cb)` wrap the three newest ones. +> helpers wrap them: `trek.openExternal(url)`, `trek.confirm(opts)` +> (→ `Promise<boolean>`), `trek.onEvent(cb)`, +> `trek.session.get/set/remove/clear(…, { scope })`, and +> `trek.geolocation.get()` / `.watch(cb)` (→ unsubscribe fn; needs +> `geolocation:read`). ### Opening external links (`trek:openExternal`) @@ -176,6 +186,48 @@ Caveats and consequences: > choices against the real CSP (or a harness that applies it), not just `dev`. > See [testing.md](testing.md). +## On a phone — `viewport` (surface, fill, insets) + +TREK's phone experience is its own design, and your plugin mounts inside it. +`trek:context.viewport` tells you where you are and how to size: + +```js +m.viewport = { + surface: 'trip-tab', // where you are mounted — see the table + formFactor: 'phone', // 'phone' | 'desktop' + fill: true, // true = fill the host container, false = report your height + insets: { top: 82, bottom: 106 }, // px the host ALREADY keeps clear around you +} +``` + +| `surface` | Shape | Notes | +|--------------------|----------------|--------------------------------------------------------| +| `trip-tab` | fills | Your own tab in a trip. You scroll yourself. | +| `plugin-page` | fills | `/plugins/:id`. You scroll yourself. | +| `dashboard-widget` | reports height | A card on the dashboard. | +| `user-settings` | reports height | Your `settings.html` (`capabilities.settingsUi`). | +| `detail-slot` | reports height | A narrow column in a place/day/reservation panel. | +| `action-frame` | fills | The modal a table action opens. | + +The contract differs by shape: on a **filling** surface your `trek:resize` height +is ignored — give the document `height:100%` and scroll an inner element; on a +**reporting** surface let content decide the height and never set `100%`. The +design kit handles both from `viewport.fill`. **`insets` is space the host +already keeps clear** (floating controls above, a translucent dock below on the +mobile trip tab) — use it to align your own sticky chrome, never as margin to +add (that doubles the gap). + +Inside the mobile shell, `tokens` carries a **second family** alongside the +global one — `--m-ink`, `--m-muted`, `--m-bg`, `--m-card`, `--m-cbr`, +`--m-glass`, `--m-act`, the `--m-st-*` status canon, `--m-safe-top`, … — which is +what the native mobile screens are built from. They are **absent on desktop**, +so branch on presence: `background: var(--m-card, var(--bg-card))`. + +Four rules that decide whether it feels native on a phone: inputs **≥ 16px** +(below that iOS zooms on focus and stays zoomed), touch feedback on `:active` +not `:hover`, targets **≥ 44px**, and **no drop shadows** on the translucent +mobile surface. The kit applies all four once `data-form-factor="phone"` is set. + ## Making the UI feel native The frame inherits **none** of TREK's styling — not its stylesheet, theme class, @@ -199,7 +251,11 @@ You then get the native TREK look for free: arrived; returns an unsubscribe fn), `context`, `invoke(sub, {method, body})` → Promise (rejects with an `Error` whose `.code` = the HTTP status), `notify`, `navigate`, `resize`, `ready`, `requestContext`, `openExternal(url)`, - `confirm(opts)` → `Promise<boolean>`, `onEvent(cb)` (details below). + `confirm(opts)` → `Promise<boolean>`, `onEvent(cb)`, + `session.get/set/remove/clear(…, { scope: 'plugin'|'trip' })` (host-managed + per-tab JSON state — 32 keys / 64-char keys / 1 KiB values per scope), and + `geolocation.get()` / `.watch(cb)` (needs the `geolocation:read` permission; + details below). - **`window.trek.ui`** — bundler-free DOM builders that emit the kit's `trek-*` classes, so you can build themed UI with **no CSS and no build step**: `ui.el(tag, props, children)` (the general builder — `props` take diff --git a/skills/trek-plugin-dev/references/manifest.md b/skills/trek-plugin-dev/references/manifest.md index d1ae219..79fdab1 100644 --- a/skills/trek-plugin-dev/references/manifest.md +++ b/skills/trek-plugin-dev/references/manifest.md @@ -12,8 +12,8 @@ registry CI, and the TREK install loader all apply the **same** rules | `name` | string | **yes** | Display name; also the nav label for `page` plugins. | | `version` | string | **yes** | Semver `\d+.\d+.\d+` with optional pre-release (`1.2.3`, `1.2.3-beta.1`). Must equal the git tag (`v` prefix) of the release. | | `type` | string | **yes** | `integration` \| `page` \| `widget` \| `trip-page` — `trip-page` mounts a full-frame tab inside every trip planner (scoped to the open trip), no dashboard presence. | -| `apiVersion` | number | no | Plugin API version; currently `1` (SDK constant `PLUGIN_API_VERSION`). Defaults to `1`. Must be a number, not a string. **Not enforced at install** — the server accepts any numeric value with no version negotiation. `trek` (below) is what actually gates compatibility. | -| `trek` | string | **yes** | The TREK versions you support, as a semver **range**: `">=3.4.0 <4.0.0"`. **Enforced since TREK 3.4.0, at install *and* activation** — see below. Validated as a *satisfiable* range (`">=4.0.0 <3.0.0"` parses but nothing can satisfy it → rejected). It is the **only** compatibility field the registry entry needs — `entry` copies it verbatim, and the older `minTrekVersion`/`maxTrekVersion` are deprecated (they only restated the lower bound, and could not express an exclusive ceiling). | +| `apiVersion` | number | no | Plugin API version; currently `1` (SDK constant `PLUGIN_API_VERSION`). Defaults to `1`. **Enforced:** it must be a **positive integer** (`0`, `1.5`, `"1"` are rejected at parse), and a value **newer than the host supports** is refused at install and won't activate (`API_VERSION_INCOMPATIBLE`, no admin override). `trek` (below) remains the day-to-day compatibility gate; `apiVersion` only bites when a future API v2 exists. | +| `trek` | string | **yes** | The TREK versions you support, as a semver **range**: `">=4.0.0 <5.0.0"` (the scaffold default). **Enforced since TREK 3.4.0, at install *and* activation** — see below. Validated as a *satisfiable* range (`">=4.0.0 <3.0.0"` parses but nothing can satisfy it → rejected). It is the **only** compatibility field the registry entry needs — `entry` copies it verbatim, and the older `minTrekVersion`/`maxTrekVersion` are deprecated (they only restated the lower bound, and could not express an exclusive ceiling). | | `author` | string | no | Shown in the store. | | `description` | string | no | One-line store summary. **The 200-char cap binds the registry *entry*, not the manifest** — manifest parity never compares `description`, so entry and manifest may legitimately differ. But `buildEntry` copies it verbatim, so a manifest description outside **5–200 chars** (or missing entirely — `entry` defaults it to `""`, which trips the schema's `minLength: 5` with an error that names the *entry*, not the manifest that caused it) would fail registry CI. **`validate`/`status` now error on all three cases locally** (they used to check nothing), so it no longer reaches CI. Either keep it 5–200 chars here, or hand-shorten the entry's `description` afterwards (allowed). Same offline check also caps `name` at 2–60 and `author` at 80, and rejects a **homoglyph** `name` (Latin mixed with Cyrillic/Greek). | | `icon` | string | no | A **lucide** icon name in PascalCase (`"Stethoscope"`, `"Luggage"`) — **any** of lucide's ~1435 icons, not a curated subset. Defaults to `Blocks`. TREK resolves it wherever it draws your plugin: the desktop navbar and mobile bottom nav (page plugins), the trip-planner tab (trip-page), the dashboard widget header, your settings card, the Admin → Plugins row, **and the store tile** (that last one reads the *registry entry's* `icon`, which `entry`/`publish` copy from this field — see [publishing.md](publishing.md)). **An unknown name silently falls back to `Blocks`** — nothing errors, so a typo just makes your plugin look generic. That silence is why **`trek-plugin validate` now *errors*** on a name lucide doesn't have (it used to warn), matching registry CI, which rejects one. `create` prompts for an icon, validates it against lucide as you type, and writes a sensible default for the type if you skip it — so a scaffolded plugin no longer ships icon-less. | @@ -29,8 +29,10 @@ registry CI, and the TREK install loader all apply the **same** rules | `capabilities.tripPage` | object | no | For a `type:"trip-page"` plugin. `replaces: string[]` **hides** core planner tabs while the plugin is active — only these are replaceable: `transports`, `buchungen`, `listen`, `finanzplan`, `dateien`, `collab` (the **`plan` tab can never be replaced**; an unlisted value throws at install). `position: number` sets the plugin tab's preferred 0-based index (integer 0–50; omitted = appended after core tabs). This is the mechanism for a full **tab-takeover** trip-page. | | `capabilities.provides` | string[] | no | Callable export names this plugin exposes to its **dependents** via `ctx.plugins.call` (safe identifiers, de-duplicated + name-validated at install). The counterpart to `pluginDependencies` (the consuming side). | | `capabilities.emits` | string[] | no | Event names this plugin publishes to dependents via `ctx.events.emit` (dotted names like `rate.updated` allowed). Drives the host's `subscribersOf` routing. | -| `settings` | array | no | Settings fields (below). Declared here; **the SDK validator and CI do not validate `settings[]`** — the runtime host renders/enforces them. Plugins write no settings UI. | +| `settings` | array | no | Settings fields (below). **Validated by the SDK and the host alike**: `key` must match `^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$` (never `constructor`/`prototype`/`__proto__`), `scope` is `instance`/`user`, an `options` list must be an array whose object entries carry a non-empty `value` (bare strings/numbers are accepted and coerced), and `oauth` must be an object with string paths — a manifest failing any of these is refused at install, and `validate` errors on it first. Plugins write no settings UI (but see `capabilities.settingsUi` below). | | `actions` | object[] | no | Up to **8 settings-page buttons** ("Test connection"-style). Each renders on the plugin's user-settings page and runs **user-bound** via `POST /api/plugin-settings/<id>/action`; the handler's result is normalized to `{ ok, message }` (message emoji-stripped, ≤ 200 chars). Test with the mock host's `declaredActions` + driver `action(key)` — see [testing.md](testing.md). | +| `capabilities.settingsUi` | boolean | no | Ship a **custom settings page**: set `true` and provide a `client/settings.html` — TREK frames it as a card on the user's Settings → Plugins page, in the same opaque-origin sandbox + postMessage bridge as your widget (it can only reach your own routes via `trek:invoke`; persist through a route into `db:own`). For when declared `settings[]` fields aren't enough. Hosts that predate the flag ignore it. | +| `capabilities.routeProfiles` | array | no | Up to **3** routing profiles `{ id, label, icon? }` for a plugin implementing the **`routeProvider`** hook — each becomes a selectable mode in the planner's route toggle next to Driving/Walking. `id` is lowercase `[a-z][a-z0-9-]` (≤ 24 chars, unique) and is what `getRoute` receives as `request.profile`; `label` required, ≤ 40 chars; `icon` an optional lucide name (a non-string icon is dropped, not rejected). **Requires the `hook:route-provider` permission** (validation fails without it). See [server-api.md](server-api.md). | | `capabilities.notificationChannel` | object | no | Declares an `integration` as a **notification delivery channel** — `{ title?: string, events: [...] }`. Each event must be one of the **10 plugin-deliverable** events (`trip_invite`, `booking_change`, `trip_reminder`, `todo_due`, `vacay_invite`, `collection_invite`, `photos_shared`, `collab_message`, `packing_tagged`, `plugin_notification`) — admin-scoped/in-app-only events are excluded. **Requires the `hook:notification-channel` permission** (validation fails without it) and a `hooks.notificationChannel` implementation; `create` ships a `notification-channel` template. See [server-api.md](server-api.md). | **Declarative-only keys the installed-manifest parser does not consume:** @@ -42,7 +44,8 @@ still meet them in older plugins and in example manifests (koffi's `routes[]` bl is documentation for readers). Adding them is harmless — and pointless. **What a fresh scaffold gives you:** a manifest with a real `icon`, an explicit -widget `slot`, `"trek": ">=3.4.0 <4.0.0"`, empty `requiredAddons`/`pluginDependencies`, +widget `slot`, `"trek": ">=4.0.0 <5.0.0"` (and, when you pick `oauth:client`, the +five instance-scope OAuth-broker settings pre-declared), empty `requiredAddons`/`pluginDependencies`, a `docs/` dir, and a `.gitattributes` (`eol=lf`, so an artifact re-packed on Windows still hashes the same). It **runs** (`dev`) and **packs** immediately, but it does **not** pass `validate` — the README is a stub and there is no screenshot. That is @@ -90,13 +93,14 @@ Failures surface as `TREK_VERSION_INCOMPATIBLE` (range excludes this TREK) or ## Permissions catalog (complete) -All **58** grantable permissions. TREK hard-rejects any string not in this list at -activation, so this is also the full set `validate` accepts. +All **63** grantable permissions. TREK hard-rejects any string not in this list at +install (`unknown permission(s): …`), so this is also the full set `validate` — and, +since the registry vendored the same list, **registry CI** — accepts. `create`'s wizard offers **every one of them**, grouped into the 13 areas below so you -aren't scrolling a flat list of 58 (SDK ≥ 1.5.0 — **earlier versions offered only 18**, -silently omitting `jobs:run`, `events:subscribe` and 7 of the 12 hooks; the validator -always accepted them, the wizard just couldn't offer them): +aren't scrolling a flat list of 63 (SDK ≥ 1.5.0 — **earlier versions offered only 18** +of the then-58, silently omitting `jobs:run`, `events:subscribe` and most hooks; the +validator always accepted them, the wizard just couldn't offer them): | Area | Permissions | |-------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -109,10 +113,10 @@ always accepted them, the wizard just couldn't offer them): | **files** | `db:read:files`, `db:read:files:content`, `db:write:files` | | **collab** | `db:read:collab`, `db:write:collab`, `db:read:daynotes`, `db:write:daynotes` | | **journal/atlas/vacay/collections** | `db:read:journal`, `db:write:journal`, `db:read:atlas`, `db:write:atlas`, `db:read:vacay`, `db:write:vacay`, `db:read:collections`, `db:write:collections` | -| **hooks** | the 12 `hook:*` (see below) | +| **hooks** | the 16 `hook:*` (see below) | | **background** | `jobs:run`, `events:subscribe` | | **realtime** | `ws:broadcast:trip`, `ws:broadcast:user` | -| **services** | `http:outbound`, `weather:read`, `rates:read`, `notify:send`, `ai:invoke`, `oauth:client` | +| **services** | `http:outbound`, `weather:read`, `rates:read`, `notify:send`, `ai:invoke`, `oauth:client`, `geolocation:read` | ⚠️ A `hook:*`, `events:subscribe` or `jobs:run` you **forget** is not an error — TREK simply **never calls that entry point**, silently. `trek-plugin dev` warns about it at @@ -164,23 +168,28 @@ load; nothing else will. See [testing.md](testing.md#what-dev-enforces-hooks-eve | `hook:place-detail-provider` | `hooks.placeDetailProvider.getDetails(placeId, ctx)` → `{ label, value?, url? }[]` | **Wired.** Core calls every active implementer to enrich a place's detail panel (additive, fail-safe). Server-side, no UI of its own. | | `hook:trip-warning-provider` | `hooks.warningProvider.getWarnings(tripId, ctx)` → `{ level, message, dayId?, placeId? }[]` | **Wired.** TREK surfaces the returned warnings in the trip planner (`level` = `info`/`warning`/`error`). | | `hook:table-contributor` | `hooks.tableContributor` | **Wired.** Contributes columns/actions to core table views (reservations/places/day/costs/packing/files). Declarative shape, host-sanitized (emoji-stripped), fail-safe. | -| `hook:map-marker-provider` | `hooks.mapMarkerProvider` | **Wired.** Contributes markers to the trip map (labels emoji-stripped). | +| `hook:map-marker-provider` | `hooks.mapMarkerProvider` | **Wired.** Contributes markers to the trip map (labels emoji-stripped; ≤ 200 markers/plugin). | +| `hook:map-layer-provider` | `hooks.mapLayerProvider` | **Wired.** Bounded **vector overlays** on the trip map — polylines/polygons/metric circles (routes, corridors, zones). Declarative only; per-plugin budgets ≤ 4 layers / ≤ 150 features / ≤ 8000 vertices; width/opacity/radius clamped; drawn beneath TREK's own routes. | +| `hook:route-provider` | `hooks.routeProvider.getRoute({tripId, dayId, profile, waypoints}, ctx)` | **Wired.** Offer **routing profiles** for the planner's route toggle (declared in `capabilities.routeProfiles`, ≤ 3). Targeted, not a fan-out: TREK calls exactly the profile the user picked, 20 s timeout. Result `{coordinates ≤ 10000, distance, duration, legs, viaPoints? ≤ 40}` is validated **whole** (`legs` must equal `waypoints−1`); malformed → discarded, planner falls back to straight lines. | +| `hook:day-schedule-provider` | `hooks.dayScheduleProvider.getSchedule(tripId, ctx)` | **Wired.** **Time contributions** in the day plan (`{id, dayId, assignmentId?/reservationId?/position?, minutes?, label, tone?}`): rows render under their anchor and `minutes` (1–1440) is folded into the day's route-footer total. ≤ 60 items/plugin, label ≤ 120. | +| `hook:day-tint-provider` | `hooks.dayTintProvider.getDayTints(tripId, ctx)` | **Wired.** **Colour-code whole days** in the Plan sidebar (and the mobile day chip): one entry per day with `tone`/`color` (`#rrggbb` only) shorthands or per-region `badge*`/`header*`/`activity*` values. You pick the hue, the host picks the weight (alpha/lightness clamped per theme). A day takes at most one contribution, resolved whole — first entry per day wins, first granted provider wins across plugins. Bounded by the trip's day count, not a fixed cap. | | `hook:pdf-section-provider` | `hooks.pdfSectionProvider` | **Wired.** Adds sections to the exported trip PDF. | | `hook:atlas-layer-provider` | `hooks.atlasLayerProvider` | **Wired.** Adds labelled layers to the atlas. | | `hook:journal-entry-provider` | `hooks.journalEntryProvider` | **Wired.** Contributes rows to journal entries. | | `hook:trip-card-provider` | `hooks.tripCardProvider` | **Wired.** Adds badges/content to dashboard trip cards. | -| `hook:user-data` | definition-level `deleteUserData(userId)` / `exportUserData(userId)` | **Wired, GDPR.** Host calls these durably on account erasure/export. **Userless** — you get only a `userId` and act on your **own `db:own`** data; grants **no** core read. Implement it if you store personal data. | +| `hook:user-data` | definition-level `deleteUserData({userId}, ctx)` / `exportUserData({userId}, ctx)` — note the **object argument** | **Wired, GDPR.** Host calls these durably on account erasure/export. **Userless** — you get only a `userId` and act on your **own `db:own`** data; grants **no** core read. Implement it if you store personal data. | | `hook:notification-channel` | `hooks.notificationChannel` — deliver TREK notifications through your own channel (Telegram, Gotify, …) | **Wired.** Requires `capabilities.notificationChannel.events`. The hook runs **USERLESS**, with the recipient's decrypted per-user settings passed as its config — so a channel plugin works for every user who configured it. | | `ai:invoke` | `ctx.ai.complete` / `ctx.ai.extract` | **Broker.** Host-brokered LLM using the **acting user's** configured provider — the plugin never holds a key. 20 000-char cap; output is **data-only**. | | `notify:send` | `ctx.notify.send` | **Broker.** Recipient **forced** to the acting user or a trip they're in (admin scope refused); emoji-stripped; `title` ≤ 200 / `body` ≤ 1000; `link` must be an in-app `/…` path. | | `oauth:client` | `ctx.oauth.getAccessToken()` | **Broker.** Short-lived token for a service the user connected via **Settings → Plugins → Connect**; the plugin never sees the secret. `null` when userless/unconnected. | | `rates:read` | `ctx.rates.get` | **Broker.** Currency exchange rates. Tenant-free (no acting user needed). | | `weather:read` | `ctx.weather.get` | **Broker.** Weather by coordinates (host-cached). Tenant-free. | -| `jobs:run` | Declared cron `jobs[]` **and** `ctx.scheduler.set` / `cancel` | **Opt-in gate** for background execution. Covers both node-cron-scheduled declared jobs *and* the persistent `ctx.scheduler`. Callbacks run **userless** (trip reads refused) — see [server-api.md](server-api.md). | +| `jobs:run` | Declared cron `jobs[]` **and** `ctx.scheduler.set` / `cancel` | **Opt-in gate** for background execution. Covers both cron-scheduled declared jobs *and* the persistent `ctx.scheduler`. Callbacks run **userless** (trip reads refused) — see [server-api.md](server-api.md). | +| `geolocation:read` | `window.trek.geolocation.get()` / `.watch(cb)` in the plugin's sandboxed frames | **Bridge-level** — unlocks no `ctx` RPC method. The HOST reads `navigator.geolocation` (the browser's own site prompt still applies) and posts plain position data into the frame; the sandbox itself never gains the API, and nothing is sent to the server. See [client-bridge.md](client-bridge.md). | | `http:outbound` | Marker: plugin does outbound HTTP | Satisfies the "egress required" rule but grants **no host** by itself. | | `http:outbound:<host>` | Opens `<host>` in the runtime egress guard **and** the iframe CSP `connect-src` | This is what actually allows a request. | -> **~50 grants, two special families.** The **broker +> **63 grants, two special families.** The **broker > permissions** (`ai:invoke` / `notify:send` / `oauth:client` / `rates:read` / > `weather:read` / `jobs:run`) are host services, not DB scopes — `rates`/ > `weather`/`ai` are tenant-free while `notify`/`oauth` are acting-user-scoped; @@ -257,7 +266,7 @@ deactivate→activate). `scope: user` settings are **not** surfaced to server "version": "1.0.0", "apiVersion": 1, "type": "widget", - "trek": ">=3.4.0 <4.0.0", + "trek": ">=4.0.0 <5.0.0", "author": "You", "description": "Live flight status on the dashboard.", "icon": "Plane", diff --git a/skills/trek-plugin-dev/references/publishing.md b/skills/trek-plugin-dev/references/publishing.md index 1d3c5a2..15c46d5 100644 --- a/skills/trek-plugin-dev/references/publishing.md +++ b/skills/trek-plugin-dev/references/publishing.md @@ -82,7 +82,7 @@ Per version — required: `version`, `gitTag`, `commitSha`, `downloadUrl`, | `commitSha` | 40-hex commit the tag resolves to (tags are movable; the commit pins what was reviewed). | | `downloadUrl` | Must start with `https://github.com/`, `https://codeload.github.com/`, or `https://objects.githubusercontent.com/`. | | `sha256` | 64-hex of the exact artifact bytes. | -| `trek` | The manifest's `trek` **range**, verbatim (`">=3.2.0 <4.0.0"`). **The only compatibility field a new entry carries** — it is what TREK gates installs and activation on, and the only one that can express an exclusive upper bound. CI checks it matches the manifest at `commitSha`. | +| `trek` | The manifest's `trek` **range**, verbatim (`">=4.0.0 <5.0.0"`). **The only compatibility field a new entry carries** — it is what TREK gates installs and activation on, and the only one that can express an exclusive upper bound. CI checks it matches the manifest at `commitSha`. | | `minTrekVersion` / `maxTrekVersion` | **Deprecated — don't set them.** `trek` says everything `minTrekVersion` said (it was just the range's lower bound) and says it better; `maxTrekVersion` is *inclusive* and so cannot express `<4.0.0` at all. Still accepted on entries published before `trek` existed, so a TREK too old to read `trek` has something to fall back on — and if a floor *is* present, CI enforces that it agrees with the range. `entry` no longer emits either. | | `size` | Bytes, 1 … 52 428 800 (50 MB). **Required — a common omission when hand-writing.** | | `apiVersion` | Integer >= 1. | @@ -148,7 +148,9 @@ runs schema/format checks only.) | Gate | Fails when | Fix | |---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | JSON schema | Entry violates `plugin-entry.schema.json` (incl. unknown keys) | Regenerate with `trek-plugin entry` | -| id ↔ filename | `id` ≠ filename or not a valid slug | Rename file / fix id | +| id ↔ filename | `id` ≠ filename or not a valid slug — or a **reserved id** (`registry`, `install`, `rescan`; they collide with admin API routes, and TREK's install loader refuses them too) | Rename file / fix id | +| Version ordering | `versions[]` is not sorted **newest-first** by semver (`versions[] must be sorted newest-first (found <a> before <b>)`) — the workflow and the gates grade `versions[0]` as the newly published version, so an oldest-first entry would have the wrong commit reviewed | Reorder; `entry --merge` keeps the order for you | +| Permission allowlist | Any manifest permission not on TREK's known-permission list — and not a valid `http:outbound:<host>` — fails with the server's own message, `unknown permission(s): …` (the list is vendored from TREK, so a typo'd permission dies in CI instead of at install on every instance) | Fix the permission string (`validate` catches it offline first) | | Owner binding | Existing id repointed to a different owner (`OWNERS.json`: id → `{ boundOwner, repo }`, stamped on first merge) | Only the bound owner updates it; a genuine transfer needs a maintainer to apply the **`allow-owner-change`** label (see [Maintainer overrides](#maintainer-overrides)) | | Homoglyph / mixed-script | `name` mixes Latin `[A-Za-z]` **with** Cyrillic (U+0400–04FF) or Greek (U+0370–03FF, the full block — Latin+Greek look-alikes like Α/Ο/α **are** caught). Only fires on a *mix*; an all-Cyrillic name is not caught | Use plain ASCII | | Release tag | `gitTag` doesn't exist or doesn't resolve to `commitSha` | Push the tag; re-run `entry` | @@ -169,9 +171,6 @@ uses at install (`scripts/lib/verify-signature.mjs` is a port of the host's accepts is one the host accepts. Signing itself stays **optional**: an unsigned entry passes on its SHA-256 pin alone, exactly as before. -(Reserved ids `registry`, `install`, `rescan` are refused by **TREK's install -loader** — they collide with admin API route segments — not by the CI script. -Avoid them regardless.) ### Maintainer overrides @@ -288,8 +287,14 @@ Two things make this an easy call: - **Adopting it later is fine — but you can never stop.** **Unsigned → signed at any version breaks nobody**: nothing is pinned until a signed version installs, so a key added at v1.4.0 is a real, safe option. (Any doc claiming you must "sign from v1.0.0 or - never" is wrong.) Signed → unsigned is what's forbidden, forever — a one-way door (see - below). So the only question that actually binds you is *"can I keep a key safe?"* + never" is wrong.) One mechanical consequence the SDK handles for you: the registry + requires **every** version signed once a key appears in the entry (a mixed entry would + strand hosts that resolve an older version after pinning your key), so your first + signed update **retro-signs the older versions automatically** — each pinned artifact + is downloaded, verified byte-for-byte against its `sha256` (a mismatch refuses: that + asset no longer matches its pin), and signed with the same key. Signed → unsigned is + what's forbidden, forever — a one-way door (see below). So the only question that + actually binds you is *"can I keep a key safe?"* `authorPublicKey` is the base64 Ed25519 public key; each `signature` is base64 over the artifact bytes. The key is **pinned on first install (TOFU)**, and the signature is @@ -361,6 +366,7 @@ the plugin row, so the Installed list keeps showing *why* an install/update was | `SIGNATURE_INVALID` | The signature does not verify | **No** | | `TREK_VERSION_INCOMPATIBLE` | The plugin's `trek` range excludes the running TREK. Refuses **install** (every path: registry, pinned version, update, sideload, dev-link) and **activation** — so a plugin installed on 3.3 stops starting once the operator upgrades past its upper bound | **No** — the range is *your* statement that it does not work there | | `TREK_VERSION_UNKNOWN` | The installed plugin declares no `trek` range at all, so nothing can vouch for it. Refuses activation | **No** — publish a version that declares one | +| `API_VERSION_INCOMPATIBLE` | The manifest's `apiVersion` is newer than the plugin API this TREK supports (currently `1`). Refuses install and activation | **No** — the host genuinely lacks that API | | `NO_COMPATIBLE_UPDATE` | An update was requested but no published version is both newer *and* runnable on this TREK. The plugin keeps running on its current code | n/a — nothing was changed | Only `SIGNATURE_KEY_CHANGED` gets an override (`POST /api/admin/plugins/:id/retrust`), @@ -370,12 +376,20 @@ another. The other three mean the bytes are not what the author signed; there is override button at all** for them. An admin confirming a rotation sees both key fingerprints, to check the new one with you out of band. -## When `submit` / `publish` can't open the PR (do it by hand) +## When `submit` / `publish` can't open the PR -If the automated PR step fails for any reason (no `gh`, no auth, a network blip), the -**release itself is already done** — only the PR is missing, and re-running `publish` -would refuse to overwrite the released artifact. Don't re-release; just open the one-file -PR by hand: +**`publish` rolls a failed run back.** If step 4 (preflight) or step 5 (the PR) +fails, it deletes the release, remote tag, and local tag that run created — so the +default recovery is simply: fix the problem and **re-run `publish` with the same +tag**. Nothing that existed before the run is touched. Two exceptions leave you in +the old "release exists, PR missing" state: + +- you passed **`--keep-release`** (the error printed the manual cleanup commands), or +- the failure predates the rollback / you released by hand. + +In that state you can either clean up and re-run (`trek-plugin unrelease <tag> +--repo you/repo` deletes all three, refusing a version that is actually published) +— or keep the release and open the one-file PR by hand: ```bash # 0. `entry` hashes your LOCAL plugin.zip (never downloads) — make sure the diff --git a/skills/trek-plugin-dev/references/server-api.md b/skills/trek-plugin-dev/references/server-api.md index bc949b5..b11160e 100644 --- a/skills/trek-plugin-dev/references/server-api.md +++ b/skills/trek-plugin-dev/references/server-api.md @@ -37,7 +37,7 @@ module.exports = definePlugin({ }, ], - // Cron jobs — run via node-cron when jobs:run is granted (see below). + // Cron jobs — run on their cron schedule when jobs:run is granted (see below). jobs: [ { id: 'refresh', schedule: '*/15 * * * *', async handler(ctx) { /* … */ @@ -51,10 +51,9 @@ The routes and job ids on the **loaded definition are authoritative** (a route's array index is its internal id); the `routes` block in the manifest is never consumed. -> ⚠️ **`jobs[]` need the `jobs:run` grant.** The host-side runner -> (`plugin-jobs.ts` `scheduleJobs()`) wires each declared job's cron to node-cron -> and fires `invoke.job` on the tick — **only when `jobs:run` is granted** -> (without it, jobs never run; an invalid cron is skipped). Jobs run **userless** +> ⚠️ **`jobs[]` need the `jobs:run` grant.** The host schedules each declared +> job's cron expression and fires it on the tick — **only when `jobs:run` is +> granted** (without it, jobs never run; an invalid cron is skipped). Jobs run **userless** > (own-`db`/egress/brokers only; trip reads → `RESOURCE_FORBIDDEN`). For > future/recurring callbacks under your own control there is also the > **persistent `ctx.scheduler`** (`at`/`in`/`every`/`cancel`, same grant), which @@ -72,11 +71,11 @@ export interface PluginDefinition { onUnload?(ctx: PluginContext): Promise<void> | void; routes?: PluginRoute[]; - jobs?: PluginJob[]; // run via node-cron under jobs:run (see above) + jobs?: PluginJob[]; // cron-scheduled by the host under jobs:run (see above) events?: PluginEventSubscription[]; // WIRED reactive hook — needs events:subscribe scheduled?(input: { name: string; payload?: unknown }, ctx): Promise<void> | void; // ctx.scheduler callbacks (jobs:run) - deleteUserData?(userId: number, ctx): Promise<void> | void; // hook:user-data — GDPR erasure, userless - exportUserData?(userId: number, ctx): Promise<unknown>; // hook:user-data — GDPR export, userless + deleteUserData?(input: { userId: number }, ctx): Promise<void> | void; // hook:user-data — GDPR erasure, userless. NOTE: object arg, not a bare number + exportUserData?(input: { userId: number }, ctx): Promise<unknown>; // hook:user-data — GDPR export, userless exports?: Record<string, (args, ctx) => unknown>; // capabilities.provides targets — called by dependents subscriptions?: { plugin: string; event: string; handler(payload, ctx): void }[]; // consume a dependency's emits hooks?: { // all WIRED — each needs its hook:* grant @@ -86,6 +85,10 @@ export interface PluginDefinition { warningProvider?: WarningProvider; // getWarnings(tripId, ctx) → {level,message,dayId?,placeId?}[] tableContributor?: TableContributor; // hook:table-contributor — columns/actions on core table views mapMarkerProvider?: MapMarkerProvider; // hook:map-marker-provider + mapLayerProvider?: MapLayerProvider; // hook:map-layer-provider — vector overlays on the trip map + routeProvider?: RouteProvider; // hook:route-provider — routing profiles (capabilities.routeProfiles) + dayScheduleProvider?: DayScheduleProvider; // hook:day-schedule-provider — time rows in the day plan + dayTintProvider?: DayTintProvider; // hook:day-tint-provider — colour-coded day cards pdfSectionProvider?: PdfSectionProvider; // hook:pdf-section-provider atlasLayerProvider?: AtlasLayerProvider; // hook:atlas-layer-provider journalEntryProvider?: JournalEntryProvider; // hook:journal-entry-provider @@ -319,7 +322,7 @@ export interface PluginContext { > your range cannot install or start your plugin at all. > > Two consequences: -> - Set `"trek"` honestly (`">=3.4.0 <4.0.0"`). It is now load-bearing, not a hint: it is +> - Set `"trek"` honestly (the scaffold writes `">=4.0.0 <5.0.0"`). It is now load-bearing, not a hint: it is > what *guarantees* the namespaces you call exist on every host that can run you — and > what stops an old instance installing you and failing at runtime. > - **Still guard.** The gate is skipped on a host whose `APP_VERSION` is not a semver @@ -368,11 +371,16 @@ never holds a key/secret; the host brokers the call: (`notify:send`) — the recipient is **forced** to the acting user or a trip they belong to (admin scope refused); `title` emoji-stripped and ≤ 200, `body` ≤ 1000, `link` must be an in-app `/…` path. **Route-only** (needs the acting - user). + user). **Budgeted: 100 sends/day per plugin**, resetting at UTC midnight — + past it the call fails with `daily notification budget exhausted (resets at + UTC midnight)`. - **`ctx.ai.complete(prompt, system?)` → `{ text }`** and **`ctx.ai.extract(text, jsonSchema, prompt?)` → `{ results: object[] }`** (`ai:invoke`) — uses the **acting user's** configured provider; 20 000-char cap; the output is **data only** (treat it as untrusted text, never as instructions). + **Budgeted: 200 calls/day per plugin** (UTC-midnight reset; past it → + `daily AI budget exhausted (resets at UTC midnight)`) — design batch/summary + flows, not a call per entity. - **`ctx.oauth.getAccessToken()` → `string | null`** (`oauth:client`) — a short-lived token for a service the user connected via **Settings → Plugins → Connect**; `null` when userless or not connected. Route-only in practice. @@ -402,18 +410,40 @@ permission and called with the plugin `ctx`: `getWarnings(tripId, ctx): Promise<{ level: 'info'|'warning'|'error'; message: string; dayId?: number; placeId?: number }[]>`. TREK surfaces the returned warnings in the trip planner. -**Six more declarative contribution hooks** (each `hook:*`-gated, +**Ten more declarative contribution hooks** (each `hook:*`-gated, its own consuming controller, host-sanitized — emoji stripped from any text — and fail-safe on throw/timeout), so an `integration` can inject **native** UI with no iframe of its own: - **`tableContributor`** (`hook:table-contributor`) — columns/actions on core table views (reservations/places/day/costs/packing/files). -- **`mapMarkerProvider`** (`hook:map-marker-provider`) — markers on the trip map. -- **`pdfSectionProvider`** (`hook:pdf-section-provider`) — sections in the exported PDF. -- **`atlasLayerProvider`** (`hook:atlas-layer-provider`) — labelled atlas layers. -- **`journalEntryProvider`** (`hook:journal-entry-provider`) — rows in journal entries. -- **`tripCardProvider`** (`hook:trip-card-provider`) — badges/content on dashboard trip cards. +- **`mapMarkerProvider`** (`hook:map-marker-provider`) — markers on the trip map (≤ 200/plugin). +- **`mapLayerProvider`** (`hook:map-layer-provider`) — bounded **vector overlays** + (polylines/polygons/metric circles: routes, corridors, zones). Budgets per + plugin: ≤ 4 layers / ≤ 150 features / ≤ 8000 vertices; width 1–8, opacity + 0.05–1, radius ≤ 2000 km clamped; drawn beneath TREK's own routes. +- **`routeProvider`** (`hook:route-provider`) — routing profiles for the + planner's route toggle (declare them in `capabilities.routeProfiles`, ≤ 3). + **Targeted, not a fan-out**: TREK calls exactly the profile the user picked, + with a **20 s** timeout (room for an external solver via your egress). + `getRoute({tripId, dayId, profile, waypoints})` — 2–30 located stops in visit + order — returns `{coordinates ≤ 10000, distance, duration, legs, viaPoints? ≤ 40}`, + validated **whole** (`legs.length` must equal `waypoints−1`; a malformed result + is discarded and the planner falls back to straight lines). +- **`dayScheduleProvider`** (`hook:day-schedule-provider`) — time contributions + in the day plan: `{id, dayId, assignmentId?/reservationId?/position?, minutes?, + label, tone?}` rows render under their anchor, and `minutes` (1–1440) counts + into the day's route-footer total. ≤ 60 items/plugin, label ≤ 120. +- **`dayTintProvider`** (`hook:day-tint-provider`) — colour-codes whole day cards + in the Plan sidebar (and the mobile day chip): per-day `tone`/`color` + (`#rrggbb` only) shorthands or per-region `badge*`/`header*`/`activity*` + values; you pick the hue, the host clamps alpha/lightness per theme so a day + stays readable. One contribution per day, resolved whole (first entry wins, + first granted provider wins across plugins); bounded by the trip's day count. +- **`pdfSectionProvider`** (`hook:pdf-section-provider`) — sections in the exported PDF (≤ 5/plugin; ≤ 20 paragraphs × 2000 chars; table ≤ 8×50). +- **`atlasLayerProvider`** (`hook:atlas-layer-provider`) — labelled atlas layers (≤ 3/plugin, ≤ 300 countries each). +- **`journalEntryProvider`** (`hook:journal-entry-provider`) — rows in journal entries (≤ 12/plugin). +- **`tripCardProvider`** (`hook:trip-card-provider`) — badges on dashboard trip cards (≤ 4 per trip, ≤ 240 total per plugin). **`photoProvider` / `calendarSource` are wired too** (`plugin-photos.controller.ts` / `plugin-calendar.controller.ts` consume them; @@ -432,8 +462,8 @@ configured it (typically a self-hosted endpoint declared with `operatorEgress: true`). `create --template notification-channel` scaffolds one. There is also a **data-rights hook** `hook:user-data`: define -`deleteUserData(userId, ctx)` / `exportUserData(userId, ctx)` and the host calls -them **durably** on account erasure/export. They run **userless** — you get only +`deleteUserData({ userId }, ctx)` / `exportUserData({ userId }, ctx)` (note the +**object argument**) and the host calls them **durably** on account erasure/export. They run **userless** — you get only a `userId` and act on your **own `db:own`** data; the hook grants no core read. Implement it if your plugin stores personal data (GDPR erasure/portability). @@ -467,8 +497,12 @@ module.exports = definePlugin({ }); ``` -- **`on`** is a core event name (`place:created`, `place:updated`, `day:updated`, - `file:created`, `assignment:created`, `budget:updated`, …) or `'*'` for all. +- **`on`** is a core event name `<family>:<verb>` (`place:created`, `place:updated`, + `day:updated`, `file:created`, `assignment:created`, `budget:updated`, …) or `'*'` + for all. The SDK exports the authoritative catalog: **`EVENT_FAMILIES`** (the ten + families — `trip`, `place`, `day`, `reservation`, `accommodation`, `assignment`, + `budget`, `packing`, `dayNote`, `file`) and **`EVENT_SNAPSHOT_GRANT`** (family → + the `db:read:*` permission that unlocks its snapshot). - **Payload:** `{ event, tripId, entity?, entityId?, snapshot? }` — `entity` is the family (e.g. `'reservation'`), `entityId` which entity changed, and `snapshot` a whitelisted shallow copy of the changed fields, so you can act @@ -563,10 +597,12 @@ code**, e.g. `"PERMISSION_DENIED: …"` — catch and match on that. callback whose redirect is a relative in-app path (see Routes). - Version compatibility: the manifest's **`trek` range IS enforced** (TREK ≥ 3.4.0) at install and at activation, with no admin override — see - [manifest.md](manifest.md). `apiVersion` is **not**: the server accepts any - numeric value and never compares it to `PLUGIN_API_VERSION`, so the `trek` range - is the only thing that actually gates compatibility. The one gap is a host with a - non-semver `APP_VERSION` (Docker's default `dev`), where the check is skipped. + [manifest.md](manifest.md). **`apiVersion` is enforced too**: it must be a + positive integer, and a manifest declaring a plugin-API version newer than the + host supports is refused at install and won't activate + (`API_VERSION_INCOMPATIBLE`) — moot while v1 is the only API, load-bearing the + day v2 exists. The one gap in the `trek` gate is a host with a non-semver + `APP_VERSION` (Docker's default `dev`), where that check is skipped. ## Outbound HTTP @@ -588,7 +624,8 @@ See the egress trap in [manifest.md](manifest.md). | Crash policy | **5 crashes / 5 min → auto-disabled** (status `error`); else restart, backoff capped **30 s** | supervisor | | SIGTERM→SIGKILL grace | **3 s** | supervisor | | Artifact (install) | 25 MB/file, 50 MB total, 4000 entries | `install/safe-extract.ts` | -| **RPC rate limit** | Per-plugin token bucket at the `ctx` dispatch boundary: **burst 60, 20/s, 16 in-flight** (env `TREK_PLUGIN_RPC_BURST` / `_PER_SEC` / `_INFLIGHT`). A runaway plugin is throttled instead of freezing the single-threaded host. | `host/rate-limit.ts` | +| **RPC rate limit** | Per-plugin token bucket at the `ctx` dispatch boundary: **burst 60, 20/s, 16 in-flight** (env `TREK_PLUGIN_RPC_BURST` / `_PER_SEC` / `_INFLIGHT`). A runaway plugin is throttled (`HOST_ERROR: rate limit exceeded — slow down ctx.* calls`) instead of freezing the single-threaded host. | `host/rate-limit.ts` | +| **Daily broker budgets** | `ctx.ai.*` **200 calls/day**, `ctx.notify.send` **100/day** per plugin, resetting at **UTC midnight** (env `TREK_PLUGIN_AI_PER_DAY` / `TREK_PLUGIN_NOTIFY_PER_DAY`; `0` disables the broker outright). Exhausted → `daily AI/notification budget exhausted (resets at UTC midnight)`. | `host/daily-budget.ts` | ## What plugin code can NOT do diff --git a/skills/trek-plugin-dev/references/testing.md b/skills/trek-plugin-dev/references/testing.md index 9ddc793..bef1ee2 100644 --- a/skills/trek-plugin-dev/references/testing.md +++ b/skills/trek-plugin-dev/references/testing.md @@ -233,7 +233,9 @@ other assets byte-verbatim), `/api/<path>` (your routes), and **`/preview`**: a **themed host** that embeds `/ui` in a sandboxed opaque-origin iframe (exactly TREK's isolation) and speaks the full bridge — it posts `trek:context` with **light/dark + accent + appearance toggles**, proxies `trek:invoke` to `/api`, -and handles resize/notify/navigate. So you preview the themed UI **without any +and handles resize/notify/navigate, the `trek:session:*` storage (real quotas + +error codes), and `trek:geolocation` (refused with `forbidden` when +`geolocation:read` isn't granted, like the host). So you preview the themed UI **without any harness** — just open `/preview`. `/preview` still sets **no CSP** (like `/ui`), so it reproduces the sandbox + @@ -375,6 +377,10 @@ export interface MockHostOptions { // inter-plugin + per-user + broker fixtures: pluginExports?; declaredEmits?; userSettings?; tags?; journals?; journalEntries?; collections?; atlasVisited?; atlasBucketList?; vacayPlan?; categories?; weatherResult?; ratesResult?; aiText?; aiResults?; oauthAccessToken?; + /** Daily broker budgets, modelled like the host (defaults 200 / 100; 0 disables the + * broker — the FIRST call fails with the host's exact exhaustion message). Plain + * counters: the mock never rolls the UTC window — make a fresh host per test. */ + aiPerDay?: number; notifyPerDay?: number; /** Fixtures keyed by trip id; `members` gates access like the real host. */ trips?: Record<number, { members: number[]; data?: unknown; places?: unknown[]; reservations?: unknown[]; @@ -523,6 +529,12 @@ Notes: equal the acting user (else `RESOURCE_FORBIDDEN`), a `'trip'` target is membership-checked, `link` must be an in-app `/…` path (not `//…`, ≤ 512). Assert on the **`notifications`** array. +- **Daily budgets and `ctx.meta` quotas are enforced** like the host: `ai.*` + and `notify.send` fail with `daily AI/notification budget exhausted (resets at + UTC midnight)` past `aiPerDay`/`notifyPerDay` (defaults 200/100; `0` disables + the broker), and `meta.set` enforces key ≤ 256 chars, value ≤ 64 KB serialized, + ≤ 100 keys per entity — in the host's check order — so a test can prove your + plugin backs off instead of discovering the limits in production. - **member management + per-trip rights:** `addMember`/`removeMember` need `db:write:members` + the fixture's `can.member_manage`, verify the target exists, no-op for owner/existing member (`joined:false`), and refuse removing