Fix/plugin audit findings (#2031)

* fix(plugins): enforce manifest apiVersion at install and activation

* test(plugins): exercise the absent-apiVersion default

* feat(plugins): generate the core-event catalog into host-facts

* docs(plugins): explain the packing.listBags write-permission binding

* test(plugins): cover apiVersion upload refusal; tidy docblocks and generator header

* fix(plugin-sdk): validate settings options and oauth like the host

* fix(plugin-sdk): model the full manifest shape and stop dropping fields

* fix(plugin-sdk): drop non-string routeProfiles icons like the host

* docs(plugin-sdk): document host limits, oauth contract, and ignored asUserId

* feat(plugin-sdk): enforce daily budgets and meta quotas in the mock host

* feat(plugin-sdk): expose the generated core-event catalog

* feat(plugin-sdk): scaffold trek range >=3.4.0 <5.0.0 ahead of TREK 4.0

* feat(plugin-sdk): scaffold the oauth broker settings for oauth:client plugins

* chore(plugin-sdk): fix stale mirror docs and the colliding permissions export

* docs(plugin-sdk): add the rate-limit string; restore trek docblock; match host meta sizing

* feat(plugin-sdk): raise the scaffold trek floor to 4.0.0

* docs(wiki): bring the plugin pages in line with the current runtime

* docs: bring the workspace CLAUDE.md guides up to date and make them committable

* feat(plugin-sdk): roll back failed publishes, add unrelease, sync the fork, retro-sign updates

* docs(wiki): document the publish rollback, unrelease, fork sync, and retro-signing

* chore(plugin-sdk): 1.6.0
This commit is contained in:
jubnl
2026-08-24 23:48:36 +02:00
committed by GitHub
parent 4bac447ac8
commit d110b3bb6d
48 changed files with 1895 additions and 169 deletions
+12 -13
View File
@@ -4,11 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What TREK is
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with four workspaces:
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with three workspaces:
- **`shared/`** (`@trek/shared`) — Zod schemas that are the **single source of truth** for API contracts, consumed by both server and client. Also owns all i18n locale files. Must be built before server/client typecheck or run.
- **`nest-mcp/`** (`@trek/nest-mcp`) — decorator-driven MCP tool/resource/prompt registration for NestJS, consumed by the server. Deliberately **extraction-clean**: no imports from `@trek/server`/`@trek/shared`, no TREK semantics.
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server, sandboxed plugin runtime.
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server (decorator-driven registration layer in `server/src/nest-mcp/`, formerly the `@trek/nest-mcp` workspace), sandboxed plugin runtime.
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
**`plugin-sdk/`** (`trek-plugin-sdk`) also lives in the repo but is **not a root workspace** — it has its own lockfile, is published to npm independently, and must ship standalone. Run its commands from `plugin-sdk/`, not the root.
@@ -17,16 +16,16 @@ Each package has its own `CLAUDE.md` with internals; this file covers the monore
## Commands
Run from the repo root unless noted. Per the user's preference, **use `npm run ... --workspace=<pkg>`, not `npm -w <pkg>`**.
Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not `npm -w <pkg>`**.
```bash
npm run dev # build shared + nest-mcp, then watch shared + server + client concurrently
npm run build # build shared → nest-mcp → server → client (order matters)
npm run lint # lint all four workspaces
npm run format # prettier --write all four
npm run dev # build shared, then watch shared + server + client concurrently
npm run build # build shared → server → client (order matters)
npm run lint # lint all three workspaces
npm run format # prettier --write all three
```
Tests (the user runs client tests as `cd client && npm run test`):
Tests:
```bash
npm run test # all workspaces
@@ -89,12 +88,12 @@ Offline-first, with a layered data flow. A Page never owns state directly:
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema in `shared/src/<domain>/`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up.
- **i18n locale files live in `shared/src/i18n/<locale>/`**, one file per domain. `en/` is canonical. When you add/change a translation key, **add it to every locale** or `i18n:parity:strict` fails CI. The client resolves keys via `client/src/i18n/TranslationContext.tsx` (`t(key)`), which only reads top-level string keys.
## Project direction (from the 2026 full-repo audit)
## Project direction
A verified audit of the whole repo lives in `audit_report/TREK_audit_reports/` (`00_MASTER_SUMMARY.md` is the entrypoint, `01_STEERING_ROADMAP.md` the plan). Its verdict shapes how all new code should be written:
These principles come out of a verified 2026 full-repo audit and shape how all new code should be written:
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's feature components calling the raw API past the offline core, still ~190 files). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's the long tail of feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip.
- **DI over global mutable module state.** The `db` proxy, `ws` singleton, and event-sink globals are what force `require()`-to-dodge-cycles and load-bearing boot ordering. New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (DI providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents of the same machinery.
@@ -105,7 +104,7 @@ A verified audit of the whole repo lives in `audit_report/TREK_audit_reports/` (
- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes).
- **Discuss first**: outside contributions must be pitched in the `#github-pr` Discord channel before any code is written — undiscussed PRs are closed.
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and require a linked issue (`Closes #N`) for bug fixes or an approved feature-request discussion for features — no issue/discussion, no PR.
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). Per user preference, **do not add Co-Authored-By Claude attribution**.
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **Do not add Co-Authored-By or other tool-attribution trailers** to commit messages.
- One focused change per PR; no breaking changes; no unrelated reformatting/refactors. Tests required — the project holds **80%+ coverage** (the `src/nest/**` vitest coverage gate enforces ≥80%).
- When migrating/adding a route, **parity is law**: same URL, method, query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error strings (e.g. reproduce `{ error: 'Admin only' }` exactly rather than relying on a generic guard message). Note Nest defaults POST to 201; add `@HttpCode(200)` where the legacy contract returned 200. Declare static sub-routes (`/reorder`, `/in-app/all`) **before** `:id` param routes.
+77
View File
@@ -0,0 +1,77 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and `server/CLAUDE.md` for the API. This file covers client internals only.
## Commands (run from `client/`)
```bash
npm run dev # Vite dev server; proxies /api,/ws,/uploads,/oauth → http://localhost:3001
npm run build # prebuild generates PWA icons, then vite build
npm run typecheck # tsc --noEmit
npm run lint # eslint .
npm run lint:pages # enforce the Page pattern (scripts/check-page-pattern.mjs)
npm run test # vitest run (tests/** + src/**/*.test.{ts,tsx})
npm run test:unit # tests/unit
npm run test:integration # tests/integration + co-located src tests
npm run test:coverage # v8 coverage
npm run e2e # Playwright
npm run e2e:report # open last Playwright report
npm run shots # Playwright screenshots project (shots:promote to accept)
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1 on findings; not a CI gate)
```
Single test:
```bash
npx vitest run src/store/slices/budgetSlice.test.ts
npx vitest run -t "optimistically adds the place"
npx vitest watch src/pages/DashboardPage.test.tsx
```
The dev server needs the API on **:3001** — run the server (`npm run dev` from root or `server/`) alongside, or `npm run dev` at the repo root to start both.
## Page pattern (enforced — `lint:pages` will fail otherwise)
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `<page>Model.ts` holds pure (React-free) types/helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep rendered JSX byte-identical — it's a refactor of where logic lives.
## Data flow — offline-first
The layering is **store → repo → api | Dexie**, and writes go through a mutation queue:
- **`src/store/`** — Zustand. `tripStore.ts` is composed from **slices** in `store/slices/` (places, days, packing, todo, budget, reservations, files, assignments, dayNotes). Other stores: auth, settings, addon, permissions, inAppNotification, journey, vacay, systemNotice, collection, saveToCollection, plugin, backgroundTasks. `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
- **`src/repo/`** — per-entity repositories that mediate between the network and the offline cache. The pattern: if `navigator.onLine`, call the REST API and upsert into Dexie; if offline, read from Dexie. Always go through a repo for trip data, not the API directly.
- **`src/api/client.ts`** — single Axios instance; request/response types imported from `@trek/shared` Zod schemas. `api/websocket.ts` manages the `/ws` connection.
- **`src/db/offlineDb.ts`** — Dexie/IndexedDB schema: cached entities + the `QueuedMutation` table + `SyncMeta` (per-trip last-sync, prefetched-tiles bbox) + a `BlobCache` for offline file/photo access.
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (with a temporary **negative id** for creates), then on reconnect `flush()` replays REST with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the Dexie row. `tripSyncManager.ts` / `syncTriggers.ts` / `connectivity.ts` drive reconnection sync; `tilePrefetcher.ts` pre-downloads map tiles.
## Direction (from the 2026 audit)
The offline core is flagship work surrounded by a periphery that ignores it. New code must extend the core's discipline, not the periphery's shortcuts:
- **One data path, no layer skips**: component → feature hook → store/slice → `repo/` → `api/` | Dexie. **Never import `api/client` (or raw fetch/axios) from a component or modal** — especially never *write* from one. A long tail of existing files (~190 at last count) bypasses the layering and no lint ratchet holds the line yet; that's debt, not precedent. New API surface goes in a per-domain `api/<domain>.ts`, not the `client.ts` god module.
- **Offline-first is the product promise, not a trip-planner feature.** New domains should get a repo with both read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry), extending the existing pattern rather than hand-rolling a variant. If a domain is deliberately online-only, mark it so explicitly.
- **No God components or God hooks.** The page pattern is a floor, not a ceiling — a 900-line `use<Page>()` hook that satisfies the linter is still coupling. Decompose hooks by concern, split renders into memoizable sections, and don't pass 50-field prop bags with inline-arrow callbacks (it defeats `React.memo`).
- **Prefer React 19 idioms over hand-rolled machinery**: `useOptimistic` for optimistic updates, `useActionState`/`useFormStatus` for forms, `use()` + Suspense for read paths, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference implementation).
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`; silently keeping state the server rejected is a data-integrity bug.
- **WS remote-event handling must match local slice reducers field-for-field.** If a slice reducer preserves embedded fields on update (e.g. `place_time`/`end_time`), the corresponding `remoteEventHandler` branch must apply the identical merge semantics — divergence is silent, cross-user data loss.
- **Subscribe with selectors** (`useShallow` for multi-field reads); never `const s = useStore()` whole-store; no store getters that allocate per call. Use `getState()` for imperative access in effects.
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` directly in feature code.
- **Rendering security**: never interpolate user content into HTML strings (map tooltips/popups included — build via DOM + `textContent`); untrusted or cross-user markdown gets `rehype-sanitize`; never `rehype-raw` near those surfaces.
- **No `window` CustomEvent buses or global mutable `window` state** — use the store or a typed emitter. No `any` at boundaries: WS payloads and map renderer props get real types, validated where they enter.
- **Hygiene**: error boundaries around new shells/routes; every async `.then(setState)` needs a cancelled flag or `AbortController`; no `eslint-disable exhaustive-deps` as a routine tool (a missing memo dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops — batch; search for an existing utility/hook before writing a duplicate.
- **Theming**: use the semantic Tailwind tokens (`bg-surface*`, `text-content`, `border-edge`, `bg-accent`, status colors) — no raw palette classes, hex literals, or invented CSS vars; only `applyAppearance()` mutates `<html>` styling.
## Other big-picture pieces
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet (`MapView.tsx`) and Mapbox GL (`MapViewGL.tsx`), chosen at runtime by `MapViewAuto.tsx` based on the tile-source setting. GL-specific overlay/marker logic lives in the `*Mapbox.ts` siblings. Keep both renderers in sync when changing map features. **Offline maps**: only Leaflet supports full pre-download (raster tiles via `sync/tilePrefetcher.ts`, cached by the Workbox `map-tiles` rule). Mapbox GL is best-effort offline — its vector tiles are cached opportunistically as you browse online (the `mapbox-tiles` SW rule), not prefetched. Tile requests stay `no-cors` so self-hosted/custom tile providers without CORS headers keep working; `navigator.storage.persist()` (called at app init) keeps cached tiles from being evicted under storage pressure.
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled synchronously as the fallback; every other locale is a **dynamic `import('@trek/shared/i18n/<locale>')`** so Vite code-splits one chunk per locale and only the active one is fetched. `t(key)` resolves top-level keys from `@trek/shared/i18n`. Add new strings to the locale files in **`shared/`**, not here. RTL handled via `isRtlLanguage`.
- **Mobile shell** (`src/mobile/`): below 768px (`useIsPhone`, reactive via matchMedia) `App.tsx` wraps routes in `MobileShell`, and phone-specific screens under `mobile/screens/<domain>/` (trip, dashboard, atlas, vacay, settings, admin, …) take over; the desktop/tablet experience at ≥768px is untouched. UI changes to a domain that has an `M*` screen usually need both the desktop component and the mobile screen updated.
- **Plugins UI** (`src/components/Plugins/`): third-party plugin surfaces render in a sandboxed iframe — `PluginFrame.tsx` owns the postMessage bridge and hands the plugin the current theme's design tokens (live re-sync on theme/accent change). Contribution points: `PluginWidgets.tsx` (dashboard), `PluginDaySchedule.tsx` (itinerary rows), `Map/MapPluginLayers.tsx`/`MapPluginMarkers.tsx` (map). `store/pluginStore.ts` holds installed-plugin state; admin install/manage in `components/Admin/AdminPluginsPanel.tsx`. The server-side runtime is `server/src/nest/plugins/`; plugin authoring is covered by the `trek-plugin-dev` skill.
- **PWA** (`vite.config.js`): `VitePWA` with `registerType: 'autoUpdate'` and Workbox `runtimeCaching` for tiles/API/uploads. `prebuild` (`scripts/generate-icons.mjs`) generates app icons before build.
- **Native shell** (`client/src/platform/`): the Capacitor apps are not served from the same origin as the API, so `getServerOrigin()` is the single source of truth for where the server lives — `''` in the web build (relative URLs stay relative), the user's chosen server in native. Requests go through `apiFetch` / the `apiClient` interceptor, assets through `resolveAssetUrl` / `<ServerImage>`; ESLint blocks bare `fetch('/api…')` and `/uploads` literals so the seam cannot reopen. Native authenticates with `Authorization: Bearer` because the session cookie is `sameSite: 'lax'` and a webview can never send it cross-site. Saved servers are "profiles" (`serverProfiles.ts`, Capacitor Preferences) with tokens in device secure storage (`serverTokens.ts`) and a per-profile Dexie namespace. **The web build must stay byte-identical — especially Dexie names**; see `offlineDb.naming.test.ts`.
## Tests
vitest with `@vitejs/plugin-react`, jsdom (custom `tests/environment/jsdom-native-abort.ts`), `forks` pool. Tests live both in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie in tests. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`). Playwright e2e config separate (`npm run e2e`).
+59
View File
@@ -0,0 +1,59 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this package is
`trek-plugin-sdk` — the author-facing SDK and CLI for building TREK plugins. It lives inside the TREK monorepo but is **not one of the root npm workspaces**: it has its own `package-lock.json`, is versioned and published to npm independently, and must ship standalone (a published copy has no access to the rest of the repo). Run all commands from `plugin-sdk/`, not the repo root. The repo root's CLAUDE.md covers the main app; its conventions (target `dev` branch, conventional commits) still apply.
It ships three bins from one codebase: `trek-plugin-sdk` / `trek-plugin` (same entry, `src/cli/trek-plugin.ts`) and `create-trek-plugin` (`src/cli/create.ts`).
## Commands
```bash
npm install
npm run build # tsc ESM build + tsc CJS build + scripts/finish-cjs.mjs
npm run typecheck # tsc --noEmit
npm test # vitest run
npx vitest run test/sdk.test.ts # single test file
npx vitest run -t "name of the test" # by test-name pattern
node scripts/gen-lucide-icon-names.mjs # regenerate src/lucide-icon-names.ts (uses repo-root lucide-react)
```
Node >= 18. The package is `"type": "module"`; the dual build emits ESM to `dist/` and CJS to `dist/cjs/` (finish-cjs.mjs writes the `{"type":"commonjs"}` scope marker there — without it Node parses the CJS output as ESM).
## The load-bearing principle: mirrored host code + parity tests
The SDK's core promise is that local results equal host/registry results: `dev` enforces what a real TREK enforces, and `validate` passes iff the TREK-Plugins registry CI passes. A drift in the lenient direction is the worst bug in this package — a **false green** lets an author cut an effectively immutable GitHub release (the registry pins its sha256) that the registry then rejects.
Because the package ships standalone, it cannot import server code, so several files are deliberate **copies** of host sources, each guarded by a parity test that reads the original source directly. These tests skip outside the monorepo, but in this checkout they run — keep the pairs in sync when touching either side:
| SDK copy | Source of truth | Parity test |
|---|---|---|
| `src/permissions.ts` (`HOOK_PERMISSION`) | `server/src/nest/plugins/protocol/envelope.ts` via `server/scripts/gen-plugin-facts.ts` | `test/permissions-parity.test.ts` |
| `src/egress-policy.ts` (pure helpers, kept **byte-identical**) | `server/src/nest/plugins/runtime/egress-policy.ts` | `test/permissions-parity.test.ts` |
| `src/manifest.ts` (validation rules, `KNOWN_ADDONS`) | server plugin loader / `server/src/addons.ts` | — kept in sync by hand |
| `src/cli/checks/*` (registry gates) | TREK-Plugins registry repo (`scripts/validate-entry.mjs`, `check-readme.mjs`) | `test/checks-parity.test.ts` (runs the registry's real script; set `TREK_PLUGINS_REPO` to point at a checkout) |
| `src/zip.ts` (ZIP writer) | server `safe-extract.ts` reader | format is frozen against the reader |
`src/permissions.ts` also documents a subtlety worth preserving: ctx methods fail loudly at call time, but hooks/events/jobs are gated *before* the plugin is reached and fail **silently** in production — so the dev server and mock host deliberately make those failures loud (an ungranted `run(def).hook(...)` throws).
**Direction: single-source the contract.** The protocol core — permissions, RPC methods, hooks, the event catalog — is already single-sourced: `src/generated/host-facts.ts` is **machine-written** by `server/scripts/gen-plugin-facts.ts` from `server/src/nest/plugins/protocol/envelope.ts` (never hand-edit it; the server's `check:plugin-facts` CI gate catches drift). The remaining hand-kept copies (egress-policy, manifest rules, registry gates, the ZIP writer) are the residual risk, and the `skipIf(!inMonorepo)` escape hatch means the published SDK's own CI can green-light drift in those — so keep moving copies into the generated/parity-tested set, never away from it. Until then: changing an egress rule, manifest rule, or registry gate means touching **every** copy in one change; parity gates fail closed, never leniently; and version/compat checks fail **closed** on unknown host versions rather than assuming compatibility.
## Architecture
- **`src/index.ts`** — the plugin API surface: all types, `definePlugin`, `PLUGIN_API_VERSION` (bump on any breaking API change). Pure and dependency-free; it mirrors exactly what TREK's isolated plugin runtime injects. Runtime deps for the whole package are only `@clack/prompts`, `semver`, `update-notifier` — keep it that way (e.g. Playwright for `shot` is intentionally the author's devDependency, and the ZIP writer is in-tree instead of a dependency).
- **Two package exports**: `.` (the SDK) and `./testing` (`src/mock-host.ts`) — a mock `PluginContext` + driver enforcing the same permission model against configured fixtures.
- **`src/cli/`** — one file per command, dispatched by `trek-plugin.ts`; `menu.ts` is the no-command guided menu. Interactive TTY sessions get prompts; non-TTY (CI, pipes) stays flag-driven — machine output (`entry` JSON, `pack --json`, PR URLs) goes to stdout, notices to stderr.
- **`src/cli/checks/`** — one check registry, two depths: `runOffline` (synchronous on purpose, so `packPluginDir` can validate before zipping without going async) and `runAll` (adds the GitHub-dependent gates used by `preflight`/`publish`). Printing lives in `report.ts`: `status` renders the checklist and never exits non-zero; `validate` is the same checks with an exit code.
- **`src/ui/kit.ts`** — the design kit (`TREK_UI_CSS`, `TREK_THEME_JS`) as plain strings, inlined into a plugin's `client/index.html` where the `<!-- trek:ui -->` marker sits (`dev`/`pack` expand it; the opaque-origin iframe CSP forbids external link/script). Not a security boundary.
- **`src/lucide-icon-names.ts`** — generated snapshot (see command above); an unknown icon is a validate **warning**, never an error.
- **`test/helpers.ts`** — `makePublishable(dir)` turns a fresh scaffold into a registry-passing plugin (README with per-permission lines, ≥400 prose chars, screenshot); the scaffold deliberately fails publish gates, so tests choose "fresh" vs "publishable" explicitly.
- **`examples/`** — complete example plugins (`trip-doctor`, `koffi`).
## Behavioral contracts to not break
- `status` never exits non-zero; `validate` is the enforcing form.
- `publish` order is check → pack → release → preflight → submit, and a check failure must stop anything from being tagged/released (releases are immutable in practice).
- Signed → unsigned is refused forever; unsigned → signed is always allowed.
- Update notices (update-notifier) print to stderr only and are silent in CI/`NODE_ENV=test`/non-TTY, so JSON output stays pipeable.
+75 -2
View File
@@ -140,6 +140,54 @@ const { ctx, broadcasts } = createMockHost({
// degrades gracefully when a permission is missing.
```
## Runtime limits
The host enforces these limits on every ctx call and hook invocation. They are generous
for a legitimate plugin and only bite a runaway or abusive one — build against them so
`dev`/`mock-host` behavior matches production.
| Area | Limit |
|---|---|
| `ctx.ai` | 200 calls/day per plugin (UTC midnight rollover); past it throws `"daily AI budget exhausted (resets at UTC midnight)"` |
| `ctx.notify` | 100 calls/day per plugin (UTC midnight rollover); past it throws `"daily notification budget exhausted (resets at UTC midnight)"` |
| RPC (every `ctx.*` call) | burst 60, sustained 20/s, 16 in-flight per plugin; a throttled call is refused with `HOST_ERROR: rate limit exceeded — slow down ctx.* calls` |
| `ctx.db` (your own sqlite) | 256 MB per plugin |
| `ctx.meta` | 64 KB per value, 256 chars per key, 100 keys per (plugin, entity) |
| Plugin process | 300 MB RSS ceiling; auto-disabled after 5 crashes in 5 minutes |
| Event redelivery buffer | 200 events held per plugin, dropped unreplayed after 15 minutes |
Hook contribution caps (declarative content the host renders — a slow/failing call is
skipped, never fatal):
| Hook | Cap |
|---|---|
| `pdfSectionProvider` | ≤5 sections per provider |
| `atlasLayerProvider` | ≤3 layers per provider |
| `mapMarkerProvider` | ≤200 markers per provider |
| `warningProvider` | ≤20 warnings per provider, each message ≤300 chars |
| `placeDetailProvider` | ≤12 items per provider |
| `photoProvider` | ≤60 photos per page |
| `calendarSource` | ≤500 events per source per request |
| `tableContributor` | ≤20 columns / ≤10 actions per entity |
| `tripCardProvider` | ≤4 badges per trip, ≤240 total per provider |
## OAuth broker
`ctx.oauth.getAccessToken()` is host-brokered outbound OAuth: the plugin never becomes a
direct OAuth client. The host runs the whole flow (authorize → callback → token exchange
→ refresh) with PKCE and a 10-minute state TTL, and holds the refresh token + client
secret — a plugin only ever sees a short-lived access token for the ACTING USER, or
`null` when they haven't connected (or in a userless context).
Provider config is the plugin's admin-owned INSTANCE settings — declare `scope:'instance'`
manifest fields named `oauth_authorize_url`, `oauth_token_url`, `oauth_scopes` (optional),
`oauth_client_id`, `oauth_client_secret`, and the admin fills them in from Settings →
Plugins → Connect. Both endpoint URLs must be `https` and may not point at loopback,
`.local`/`.internal` names, or private/metadata addresses — the host refuses to start a
flow against them. The host exposes
`/api/plugin-oauth/:id/status|connect|callback|disconnect` to drive the connect UI;
plugin code itself only ever calls `getAccessToken()`.
## Capture the screenshot
The registry requires a screenshot that resolves to a real image, and the store card
@@ -180,7 +228,28 @@ Needs `git` and an authenticated `gh`. It prints the PR URL at the end.
**Updating** a listed plugin: bump `version` in the manifest, commit, and run
`publish` again with the new tag — it detects the existing entry and prepends the
new version, newest-first.
new version, newest-first. Your TREK-Plugins fork is fast-forwarded automatically
before the PR branch is cut, so a fork left behind doesn't break the submit (a
*diverged* fork gets a warning and the `gh repo sync --force` command to fix it).
### When a publish fails
A failure **after** the release is cut (a preflight or PR problem) rolls back
exactly what that run created — the GitHub release, the pushed tag, the local
tag — so the same tag is free to re-run against once you've fixed the problem.
Nothing that existed before the run is ever touched. Pass `--keep-release` to
keep the release instead; the error then prints the manual cleanup commands.
Two related conveniences:
- A leftover tag from an earlier failed run (it has no release and doesn't point
at `HEAD`) is recognised as debris and **moved to your current commit** instead
of silently releasing the stale one.
- `trek-plugin unrelease <vX.Y.Z> --repo you/repo` deletes a stranded release +
remote tag + local tag in one go. It checks the published registry index first
and **refuses to touch a version that is actually published** — those artifacts
are immutable (the registry pins their sha256), so the only way forward there is
a new version.
Prefer to drive the steps yourself? They still exist individually — `pack`,
`release` (pack → GitHub release → entry), `preflight`, `submit` (opens the PR),
@@ -199,7 +268,10 @@ It is a one-way door you may walk through **late**:
- Unsigned throughout is fine — the sha256 pin is the only guarantee, and TREK accepts it.
- **Unsigned → signed later breaks nobody.** Nothing is pinned until a signed version
installs, so adding a key at v1.4.0 is a real option, not a lost cause.
installs, so adding a key at v1.4.0 is a real option, not a lost cause. The registry
does require every version signed once your key appears in the entry — your 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).
- **Signed → unsigned is refused forever**, on every instance that already has the plugin.
`publish` refuses that at step 1, before anything is tagged or released.
@@ -242,6 +314,7 @@ bin if you install the package). `trek-plugin help <command>` — or
- `preflight [dir] --repo o/n --tag vX [--entry f] [--all]` — the registry checks that need the network: the tag resolves to the pinned commit, the released artifact downloads and hashes, the id is not bound to another owner, and an update does not drop or rotate a published signing key.
- `submit [dir] --repo o/n --tag vX [--registry o/n] [--draft]` — open the registry PR for you.
- `release [dir] --repo o/n --tag vX [--sign [key]] [--merge f]` — pack → GitHub release → entry, without opening the PR.
- `unrelease <vX.Y.Z> [dir] --repo o/n [--yes]` — delete a stranded release + remote tag + local tag. Refuses a version that is published in the registry (immutable); `--yes` consents when the registry index can't be checked.
## Update notice
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-plugin-sdk",
"version": "1.5.0",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-plugin-sdk",
"version": "1.5.0",
"version": "1.6.0",
"license": "MIT",
"dependencies": {
"@clack/prompts": "^1.7.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-plugin-sdk",
"version": "1.5.0",
"version": "1.6.0",
"description": "SDK for building TREK plugins: types, definePlugin, a mock host, manifest validation, and the create-trek-plugin / trek-plugin CLIs.",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -106,7 +106,7 @@ const manifestTrekRange: OfflineCheck = {
? fail(
`trek: "${trek}"`,
'This claims support for every TREK version, including ones that do not exist yet. It opts you out of the\n' +
'one mechanism that stops your plugin running on a host it was never tested against. Pin a range like ">=3.4.0 <4.0.0".',
'one mechanism that stops your plugin running on a host it was never tested against. Pin a range like ">=4.0.0 <5.0.0".',
)
: pass(trek);
},
+34 -5
View File
@@ -57,6 +57,23 @@ export interface ScaffoldOptions {
export const TEMPLATES = ['blank', 'notification-channel'] as const;
/**
* The credential fields the OAuth broker (server-side `oauth.getToken`) needs to run the
* authorization-code flow on the plugin's behalf. ALL `scope: 'instance'` — unlike the
* notification-channel template's `scope: 'user'` settings above, these are configured ONCE
* per install by an admin, not per recipient: the broker itself reads them at token-exchange
* time, there is no per-user client id/secret. Deliberately not user-scoped.
*/
const OAUTH_SETTINGS: Array<{
key: string; label: string; input_type: string; required: boolean; secret?: boolean; scope: 'instance';
}> = [
{ key: 'oauth_authorize_url', label: 'OAuth authorize URL', input_type: 'text', required: true, scope: 'instance' },
{ key: 'oauth_token_url', label: 'OAuth token URL', input_type: 'text', required: true, scope: 'instance' },
{ key: 'oauth_scopes', label: 'OAuth scopes (space-separated)', input_type: 'text', required: false, scope: 'instance' },
{ key: 'oauth_client_id', label: 'OAuth client id', input_type: 'text', required: true, scope: 'instance' },
{ key: 'oauth_client_secret', label: 'OAuth client secret', input_type: 'text', required: true, secret: true, scope: 'instance' },
];
/** Mirrors the server's widget slots (and the manifest validator's). */
export const WIDGET_SLOTS = ['sidebar', 'hero', 'place-detail', 'day-detail', 'reservation-detail'] as const;
export type WidgetSlot = (typeof WIDGET_SLOTS)[number];
@@ -121,11 +138,13 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
author: opts.author || 'Your Name',
description: opts.description || (isChannel ? `Deliver TREK notifications over ${displayName}.` : 'Describe what your plugin does.'),
type,
// 3.4.0, not 3.3.0. The `ctx` namespaces this scaffold is written against — ctx.meta,
// ctx.places, ctx.days, ctx.itinerary, ctx.costs, ctx.packing, ctx.files — only exist from
// 3.4.0. Claiming 3.3.0 lets the plugin install on a host where they are `undefined`, which
// fails at the first call with a TypeError instead of a clear "incompatible" at install.
trek: '>=3.4.0 <4.0.0',
// Floor 4.0.0: the host surface this scaffold is written against — the enforced
// apiVersion gate, the generated core-event catalog (EVENT_FAMILIES /
// EVENT_SNAPSHOT_GRANT), the OAuth-broker settings contract — is the TREK 4 surface.
// Claiming an older floor lets the plugin install on a host where parts of it are
// missing, which fails at the first call instead of a clear "incompatible" at install.
// Ceiling 5.0.0: bounded, per the trek-range rule; widen only against a tested host.
trek: '>=4.0.0 <5.0.0',
nativeModules: false,
permissions: perms,
// Dependency declarations (empty by default). `requiredAddons` lists addon ids
@@ -183,6 +202,16 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
},
];
}
// The broker (ctx.oauth.getToken) needs somewhere to read the authorize/token URLs and the
// app's client id/secret — an admin configures them once, instance-wide, and the broker
// reads them at token-exchange time. Merge with any settings the template above already
// wrote (a channel template never grants oauth:client today, so there is no real overlap,
// but keys are deduped anyway rather than trusting that stays true).
if (perms.includes('oauth:client')) {
const existing = (manifest.settings as Array<{ key: string }> | undefined) ?? [];
const seen = new Set(existing.map((s) => s.key));
manifest.settings = [...existing, ...OAUTH_SETTINGS.filter((s) => !seen.has(s.key))];
}
fs.writeFileSync(path.join(root, 'trek-plugin.json'), JSON.stringify(manifest, null, 2) + '\n');
fs.writeFileSync(path.join(root, 'server', 'index.js'), isChannel ? CHANNEL_JS : SERVER_JS(perms.includes('db:own')));
+2 -2
View File
@@ -56,7 +56,7 @@ interface Entry {
}
/**
* Lower bound of a `trek` range like ">=3.2.0 <4.0.0" -> "3.2.0" (matches the server).
* Lower bound of a `trek` range like ">=4.0.0 <5.0.0" -> "4.0.0" (matches the server).
*
* Read off the range with semver rather than by finding the first version-shaped substring:
* for "<4.0.0" the first such substring is 4.0.0, which is the range's *upper* bound and
@@ -91,7 +91,7 @@ export function buildEntry(opts: {
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(opts.repo)) throw new Error(`--repo must be "owner/name", got "${opts.repo}"`);
// minVersion() is null for a range nothing can satisfy (">=4.0.0 <3.0.0" parses fine), so
// this doubles as the satisfiability check — TREK would refuse to install such a plugin.
if (!minTrekFrom(manifest.trek)) throw new Error('manifest has no valid "trek" version range (e.g. "trek": ">=3.2.0 <4.0.0")');
if (!minTrekFrom(manifest.trek)) throw new Error('manifest has no valid "trek" version range (e.g. "trek": ">=4.0.0 <5.0.0")');
if (!fs.existsSync(opts.zipPath)) throw new Error(`artifact not found: ${opts.zipPath} — run \`trek-plugin pack\` first`);
const buf = fs.readFileSync(opts.zipPath);
+21 -2
View File
@@ -18,7 +18,7 @@ export const VERSION_LINE = (v: string): string => `trek-plugin-sdk ${v}`;
export const PATH_COMMANDS = ['create', 'dev', 'status', 'publish'] as const;
/** Real, supported, and not what a newcomer needs to see first. */
export const OTHER_COMMANDS = ['validate', 'pack', 'shot', 'keygen', 'sign', 'entry', 'preflight', 'submit', 'release'] as const;
export const OTHER_COMMANDS = ['validate', 'pack', 'shot', 'keygen', 'sign', 'entry', 'preflight', 'submit', 'release', 'unrelease'] as const;
export interface CommandHelp {
/** One line, for the top-level list. */
@@ -85,7 +85,7 @@ you like.`,
publish: {
summary: 'release + open the registry PR',
usage: 'trek-plugin publish [dir] --repo <owner/name> --tag <vX.Y.Z> [--sign]',
usage: 'trek-plugin publish [dir] --repo <owner/name> --tag <vX.Y.Z> [--sign] [--keep-release]',
body: `The whole release, in order:
1. check every registry gate that can be checked locally
@@ -98,6 +98,12 @@ Step 1 comes first for a reason: a GitHub release is effectively immutable, beca
pins its sha256. If a check fails, NOTHING is packed, tagged, pushed or released — so you can fix
it and re-run against the same version.
And when a LATER step fails, publish rolls back exactly what the run created — the release, the
pushed tag, the local tag — so the same tag is free to re-run against once you have fixed the
problem. Pass --keep-release to leave them in place instead (the error then prints the manual
cleanup). Anything that existed before the run is never touched. A leftover tag from an earlier
failed run is recognised (no release for it, not on HEAD) and moved to HEAD rather than reused.
In a terminal it OFFERS TO SIGN, and creates a key for you if you have none. A signature proves the
artifact came from you, not just that its bytes match what the registry saw. You can add signing
later — publishing unsigned now and signing at v1.4.0 breaks nobody — but you can never take it
@@ -217,6 +223,19 @@ hand, or to re-check a release you have already cut.
body: `Packs, cuts the GitHub release, and prints the registry entry — but does NOT open the registry PR.
For when you want to release now and submit later. \`publish\` is the whole thing.`,
},
unrelease: {
summary: 'delete a stranded tag + release (never a published one)',
usage: 'trek-plugin unrelease <vX.Y.Z> [dir] --repo <owner/name> [--registry owner/name] [--yes]',
body: `Undoes a release that never made it into the registry: deletes the GitHub release, the remote
tag, and the local tag — each reported, each skipped when already gone. For when a publish
failed halfway (though \`publish\` now rolls its own failures back), or you changed your mind
before the registry PR merged.
The one hard rule: a version that IS published in the registry is immutable — the registry pins
the artifact's sha256, so deleting its release breaks install/update for everyone who has it.
unrelease checks the published index first and refuses those outright, with no override. If the
registry cannot be reached to check, it refuses unless you pass --yes.`,
},
};
export function topLevelHelp(version: string): string {
+1
View File
@@ -38,6 +38,7 @@ export const ADVANCED_MENU: MenuItem[] = [
{ value: 'preflight', label: 'Preflight', hint: 'The registry checks that need the release to exist' },
{ value: 'submit', label: 'Submit', hint: 'Open the registry PR' },
{ value: 'release', label: 'Release', hint: 'Pack → GitHub release → print entry' },
{ value: 'unrelease', label: 'Unrelease', hint: 'Delete a stranded tag + release (never a published one)' },
{ value: 'back', label: '← Back', hint: '' },
];
+90 -28
View File
@@ -31,8 +31,12 @@ import { plainLog, type LogSink } from './ui.js';
function git(dir: string, args: string[], quiet = true): string {
return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: quiet ? 'pipe' : 'inherit' }).toString().trim();
}
function tagExists(dir: string, tag: string): boolean {
try { git(dir, ['rev-parse', `${tag}^{commit}`]); return true; } catch { return false; }
/** The commit a local tag points at, or null when the tag does not exist. */
function tagSha(dir: string, tag: string): string | null {
try { return git(dir, ['rev-parse', `${tag}^{commit}`]); } catch { return null; }
}
function remoteTagExists(dir: string, tag: string): boolean {
try { return git(dir, ['ls-remote', '--tags', 'origin', `refs/tags/${tag}`]) !== ''; } catch { return false; }
}
function releaseExists(repo: string, tag: string): boolean {
try { execFileSync('gh', ['release', 'view', tag, '--repo', repo], { stdio: 'pipe' }); return true; } catch { return false; }
@@ -45,6 +49,8 @@ export async function publishPlugin(opts: {
force?: boolean;
/** Skip the local gates. An escape hatch for a re-run, never a good idea on a first publish. */
skipChecks?: boolean;
/** On failure, leave the tag/release this run created in place instead of rolling them back. */
keepRelease?: boolean;
/**
* A signing state the CLI already looked up (it does, to know whether to offer signing). Passed
* in so a single publish does not hit the registry twice for the same answer.
@@ -93,14 +99,36 @@ export async function publishPlugin(opts: {
// 3. Tag (if needed) + push + GitHub release with the artifact attached
step(3, `Tagging ${opts.tag} + creating the GitHub release…`);
if (!tagExists(dir, opts.tag)) git(dir, ['tag', opts.tag]);
try { git(dir, ['push', 'origin', opts.tag]); } catch {
const head = git(dir, ['rev-parse', 'HEAD']);
const existingTagSha = tagSha(dir, opts.tag);
const relExisted = releaseExists(opts.repo, opts.tag);
let createdLocalTag = false;
let movedTag = false;
if (existingTagSha === null) {
git(dir, ['tag', opts.tag]);
createdLocalTag = true;
} else if (existingTagSha !== head) {
// A tag that doesn't point at HEAD and has no release is debris from a failed run — reusing
// it silently would release the UNFIXED commit. Move it. One that HAS a release is immutable.
if (relExisted && !opts.force) {
throw new Error(
`tag ${opts.tag} points at ${existingTagSha.slice(0, 7)}, not HEAD (${head.slice(0, 7)}) — and its release already exists on ${opts.repo}.\n` +
`A released artifact is immutable (the registry pins its sha256). Cut a new version,\n` +
`or pass --force if this release was never merged into the registry.`,
);
}
log(` ! tag ${opts.tag} pointed at ${existingTagSha.slice(0, 7)} (debris from an earlier run) — moving it to HEAD`);
git(dir, ['tag', '-f', opts.tag]);
movedTag = true;
}
const remoteTagExisted = remoteTagExists(dir, opts.tag);
try { git(dir, ['push', ...(movedTag ? ['--force'] : []), 'origin', opts.tag]); } catch {
throw new Error(`could not push tag ${opts.tag} — is "origin" your plugin's GitHub repo and are you authenticated? (git push origin ${opts.tag})`);
}
// A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the bytes of a
// release that is already in the registry breaks the checksum for everyone who has that version
// — they can no longer install or update it. Refuse by default.
if (releaseExists(opts.repo, opts.tag)) {
if (relExisted) {
if (!opts.force) {
throw new Error(
`release ${opts.tag} already exists on ${opts.repo}.\n` +
@@ -115,31 +143,65 @@ export async function publishPlugin(opts: {
}
log(` ✓ release ${opts.tag} on ${opts.repo}`);
// 4. Build the entry, then run the gates that need the tag and the release to exist
const entry = buildEntry({ dir, repo: opts.repo, tag: opts.tag, zipPath: packed.artifact, signKeyPath: opts.signKeyPath, now: opts.now });
if (opts.skipPreflight) {
log('[4/5] Preflight skipped (--no-preflight).');
} else {
step(4, 'Preflight — the gates that need the release to exist…');
const rep = await preflight(entry, { dir, registry: opts.registry });
for (const f of rep.failures) log(' ✗ ' + f);
if (!rep.ok) {
// What THIS run created — the exact scope of a rollback. Anything that existed before the run
// (a merged release, a pre-pushed tag) is deliberately out of bounds.
const created = { release: !relExisted, remoteTag: !remoteTagExisted, localTag: createdLocalTag || movedTag };
const manualCleanup =
` gh release delete ${opts.tag} --repo ${opts.repo} --yes\n` +
` git push origin :refs/tags/${opts.tag}\n` +
` git tag -d ${opts.tag}`;
const rollback = () => {
if (created.release) {
try { execFileSync('gh', ['release', 'delete', opts.tag, '--repo', opts.repo, '--yes'], { stdio: 'pipe' }); log(` ↩ deleted release ${opts.tag}`); }
catch { log(` ! could not delete release ${opts.tag} — run: gh release delete ${opts.tag} --repo ${opts.repo} --yes`); }
}
if (created.remoteTag) {
try { git(dir, ['push', 'origin', `:refs/tags/${opts.tag}`]); log(` ↩ deleted remote tag ${opts.tag}`); }
catch { log(` ! could not delete remote tag ${opts.tag} — run: git push origin :refs/tags/${opts.tag}`); }
}
if (created.localTag) {
try { git(dir, ['tag', '-d', opts.tag]); log(` ↩ deleted local tag ${opts.tag}`); }
catch { log(` ! could not delete local tag ${opts.tag} — run: git tag -d ${opts.tag}`); }
}
};
try {
// 4. Build the entry, then run the gates that need the tag and the release to exist
const entry = buildEntry({ dir, repo: opts.repo, tag: opts.tag, zipPath: packed.artifact, signKeyPath: opts.signKeyPath, now: opts.now });
if (opts.skipPreflight) {
log('[4/5] Preflight skipped (--no-preflight).');
} else {
step(4, 'Preflight — the gates that need the release to exist…');
const rep = await preflight(entry, { dir, registry: opts.registry });
for (const f of rep.failures) log(' ✗ ' + f);
if (!rep.ok) {
throw new Error(
`preflight found ${rep.failures.length} problem(s) — nothing was submitted.\n` +
`Did you push your code to ${opts.repo} before publishing?`,
);
}
log(` ✓ all ${rep.passed.length} checks passed`);
}
// 5. Open the registry PR
step(5, 'Opening the registry PR…');
const { prUrl } = await submitEntry(entry, { registry: opts.registry, draft: opts.draft, signKeyPath: opts.signKeyPath });
log(' ✓ done');
// Keep the artifact. It is the exact bytes the release and the entry's sha256 pin were computed
// from — a re-pack on another machine or SDK version can differ (CRLF, walk order), so anyone
// re-running `entry`/`sign` afterwards must hash THIS file, not a rebuild.
log(` artifact kept at ${zip}`);
return { prUrl };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (opts.keepRelease) {
throw new Error(
`preflight found ${rep.failures.length} problem(s) — nothing was submitted.\n` +
`The release is cut, so fix these, commit, and re-run against a NEW tag (or --force if this one was never merged).\n` +
`Did you push your code to ${opts.repo} before publishing?`,
`${msg}\n\nThe tag and release were left in place (--keep-release). To clean up by hand:\n${manualCleanup}`,
);
}
log(` ✓ all ${rep.passed.length} checks passed`);
rollback();
throw new Error(
`${msg}\n\nEverything this run created was rolled back — the tag ${opts.tag} is free again. Fix the problem and re-run.`,
);
}
// 5. Open the registry PR
step(5, 'Opening the registry PR…');
const { prUrl } = submitEntry(entry, { registry: opts.registry, draft: opts.draft });
log(' ✓ done');
// Keep the artifact. It is the exact bytes the release and the entry's sha256 pin were computed
// from — a re-pack on another machine or SDK version can differ (CRLF, walk order), so anyone
// re-running `entry`/`sign` afterwards must hash THIS file, not a rebuild.
log(` artifact kept at ${zip}`);
return { prUrl };
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Retro-signing: the missing half of "you can start signing late".
*
* Server-side, adding a key at v1.4.0 breaks nobody — TREK pins the key only when a signed
* version installs. But the REGISTRY refuses a mixed entry (key present, older versions
* unsigned), and for good reason: once an instance pins your key, it refuses the unsigned
* older versions, and "install latest compatible" can legitimately resolve an older host to
* one of them. So every version must be signed the moment a key appears — which used to mean
* downloading each old artifact and signing it by hand.
*
* This does that tedious part: for each unsigned version it downloads the pinned artifact,
* verifies the bytes still hash to the entry's pinned sha256 (a mismatch means the release
* asset was changed — a far bigger problem than signing), and signs those exact bytes.
*/
import { createHash } from 'node:crypto';
import { loadPrivateKey, publicKeyBase64, signArtifact } from './sign.js';
export interface RetroSignableEntry {
id?: string;
authorPublicKey?: string;
versions: Array<{ version: string; downloadUrl: string; sha256: string; signature?: string }>;
}
/** True when the entry carries a key but not a signature on every version. */
export function needsRetroSign(entry: RetroSignableEntry): boolean {
return !!entry.authorPublicKey && entry.versions.some((v) => !v.signature);
}
/**
* Sign every unsigned version in place with the key at `keyPath`. Returns the versions it
* signed. Throws — without half-signing the entry — when the key doesn't match the entry's
* `authorPublicKey`, or when any artifact can't be fetched or no longer matches its pin.
*/
export async function retroSignVersions(
entry: RetroSignableEntry,
keyPath: string,
log: (line: string) => void = () => {},
): Promise<string[]> {
if (!needsRetroSign(entry)) return [];
const key = loadPrivateKey(keyPath);
if (publicKeyBase64(key) !== entry.authorPublicKey) {
throw new Error(
'this signing key differs from the entry\'s authorPublicKey — retro-signing older versions with it would produce signatures TREK rejects. Use the original key.',
);
}
const unsigned = entry.versions.filter((v) => !v.signature);
const signatures = new Map<string, string>();
const failures: string[] = [];
for (const v of unsigned) {
try {
const r = await fetch(v.downloadUrl, { headers: { 'User-Agent': 'trek-plugin' } });
if (!r.ok) { failures.push(`v${v.version}: HTTP ${r.status} fetching ${v.downloadUrl}`); continue; }
const bytes = Buffer.from(await r.arrayBuffer());
const hash = createHash('sha256').update(bytes).digest('hex');
if (hash !== v.sha256) {
failures.push(`v${v.version}: the release asset no longer hashes to its pinned sha256 (got ${hash.slice(0, 12)}…, pinned ${v.sha256.slice(0, 12)}…) — the artifact was changed after publishing, which is a bigger problem than signing`);
continue;
}
signatures.set(v.version, signArtifact(bytes, key));
} catch (e) {
failures.push(`v${v.version}: ${e instanceof Error ? e.message : String(e)}`);
}
}
if (failures.length) {
throw new Error(
`could not retro-sign ${failures.length} older version(s) — the registry requires every version signed once a key is present:\n` +
failures.map((f) => ` ✗ ${f}`).join('\n') +
'\n\nNothing was signed. Fix the artifacts above (or remove those versions from the entry) and re-run.',
);
}
// All-or-nothing: only mutate the entry once every artifact verified and signed.
for (const v of unsigned) {
v.signature = signatures.get(v.version)!;
log(` ✓ retro-signed v${v.version}`);
}
return unsigned.map((v) => v.version);
}
+27 -2
View File
@@ -11,12 +11,13 @@ import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { readJsonFile } from './json.js';
import { needsRetroSign, retroSignVersions } from './retro-sign.js';
const DEFAULT_REGISTRY = 'liketrek/TREK-Plugins';
interface EntryLike {
id: string; name?: string; authorPublicKey?: string;
versions: { version: string }[];
versions: { version: string; downloadUrl: string; sha256: string; signature?: string }[];
}
function git(cwd: string, ...a: string[]): string {
@@ -42,7 +43,7 @@ function mergeOnto(existing: EntryLike, fresh: EntryLike): EntryLike {
return merged;
}
export function submitEntry(entry: EntryLike, opts: { registry?: string; branch?: string; draft?: boolean; keep?: boolean } = {}): { prUrl: string } {
export async function submitEntry(entry: EntryLike, opts: { registry?: string; branch?: string; draft?: boolean; keep?: boolean; signKeyPath?: string } = {}): Promise<{ prUrl: string }> {
const registry = opts.registry || DEFAULT_REGISTRY;
const name = registry.split('/')[1];
const login = gh('api', 'user', '--jq', '.login');
@@ -51,6 +52,17 @@ export function submitEntry(entry: EntryLike, opts: { registry?: string; branch?
// Ensure the fork exists (idempotent — prints "already exists" if it does).
try { gh('repo', 'fork', registry, '--clone=false'); } catch { /* already forked */ }
// Fast-forward the fork from the registry. The PR branch is based on upstream/main either
// way, but a fork left far behind has broken pushes and PRs in the wild — and costs nothing
// to keep current. Best-effort: a diverged fork can't be fast-forwarded, and that alone
// must not sink the submit.
try { gh('repo', 'sync', `${login}/${name}`, '--source', registry); } catch {
console.error(
`! could not fast-forward your fork ${login}/${name} (diverged?) — continuing, the PR branch is based on the registry's main.\n` +
` If the push or PR fails, reset the fork with: gh repo sync ${login}/${name} --force`,
);
}
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-submit-'));
try {
// Clone the fork (pushable via gh auth), then base a branch on the CURRENT upstream main.
@@ -77,6 +89,19 @@ export function submitEntry(entry: EntryLike, opts: { registry?: string; branch?
toWrite = mergeOnto(existing, entry);
action = 'update';
}
// First signed update onto an unsigned history: the registry refuses a mixed entry (key
// present, older versions unsigned) — see retro-sign.ts for why. Sign the older versions
// with the same key, or say exactly what is missing.
if (needsRetroSign(toWrite)) {
if (!opts.signKeyPath) {
throw new Error(
`this update adds your signing key, but ${toWrite.versions.filter((v) => !v.signature).length} older version(s) are unsigned — ` +
'the registry requires every version signed once a key is present. Re-run with --sign so they can be signed for you.',
);
}
console.error(' older versions are unsigned — signing them with your key so the registry accepts the update');
await retroSignVersions(toWrite, opts.signKeyPath, (line) => console.error(line));
}
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, JSON.stringify(toWrite, null, 2) + '\n');
+48 -3
View File
@@ -24,6 +24,8 @@ import { runShot } from './shot.js';
import { preflight } from './preflight.js';
import { submitEntry } from './submit.js';
import { publishPlugin } from './publish.js';
import { unrelease } from './unrelease.js';
import { needsRetroSign, retroSignVersions } from './retro-sign.js';
import { loadContext } from './checks/context.js';
import { runOffline } from './checks/index.js';
import { renderPlain } from './checks/report.js';
@@ -81,7 +83,8 @@ const COMMAND_FLAGS: Record<string, readonly string[]> = {
preflight: ['repo', 'tag', 'entry', 'zip', 'commit', 'sign', 'key', 'all', 'registry'],
submit: ['repo', 'tag', 'zip', 'commit', 'sign', 'key', 'registry', 'branch', 'draft', 'keep'],
release: ['repo', 'tag', 'out', 'notes', 'commit', 'merge', 'sign', 'key'],
publish: ['repo', 'tag', 'sign', 'key', 'registry', 'draft', 'notes', 'no-preflight', 'no-checks', 'force'],
publish: ['repo', 'tag', 'sign', 'key', 'registry', 'draft', 'notes', 'no-preflight', 'no-checks', 'force', 'keep-release'],
unrelease: ['repo', 'tag', 'registry', 'yes'],
};
function assertKnownFlags(command: string, f: Flags): void {
@@ -107,6 +110,26 @@ function signKey(f: Flags): string | undefined {
return f.sign === 'true' ? (f.key || defaultKeyPath()) : f.sign;
}
/**
* First signed update onto an unsigned history (`--merge` + `--sign`): the registry requires
* every version signed once a key is present, so sign the older versions too — or say exactly
* what is missing. See retro-sign.ts.
*/
async function maybeRetroSign(
entry: { authorPublicKey?: string; versions: Array<{ version: string; downloadUrl: string; sha256: string; signature?: string }> },
keyPath: string | undefined,
): Promise<void> {
if (!needsRetroSign(entry)) return;
if (!keyPath) {
fail(
`this entry carries your signing key, but ${entry.versions.filter((v) => !v.signature).length} older version(s) are unsigned — ` +
'the registry requires every version signed once a key is present. Re-run with --sign so they can be signed for you.',
);
}
console.error('older versions are unsigned — signing them with your key so the registry accepts the update');
await retroSignVersions(entry, keyPath!, (line) => console.error(line));
}
/**
* Resolve the repo + tag every publishing command needs. Interactive: prompt for
* whatever is missing. Non-interactive: reproduce the command's exact error so
@@ -322,6 +345,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
commit: f.commit, asset: f.asset, mergePath: f.merge,
signKeyPath: signKey(f), now: new Date().toISOString(),
});
await maybeRetroSign(entry, signKey(f));
const json = JSON.stringify(entry, null, 2) + '\n';
if (f.out) {
fs.writeFileSync(f.out, json);
@@ -384,6 +408,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
dir, repo, tag,
signKeyPath, signing, registry: f.registry, draft: !!f.draft,
notes: f.notes, skipPreflight: !!f['no-preflight'], skipChecks: !!f['no-checks'], force: !!f.force,
keepRelease: !!f['keep-release'],
now: new Date().toISOString(),
log: tui ? clackLogSink : undefined,
});
@@ -400,12 +425,12 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
const ok = await promptConfirm({ message: 'Open the registry PR now?', initialValue: true });
if (!ok) { outro('Cancelled — no PR opened.'); return; }
const s = spinner(); s.start('Opening the registry PR');
const { prUrl } = submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep });
const { prUrl } = await submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep, signKeyPath: signKey(f) });
s.stop('Registry PR opened');
console.log(prUrl);
} else {
console.error(`Opening a registry PR for ${entry.id} ${entry.versions[0].version}…`);
const { prUrl } = submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep });
const { prUrl } = await submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep, signKeyPath: signKey(f) });
console.log(prUrl);
}
} else if (command === 'release') {
@@ -421,6 +446,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
execFileSync('gh', ['release', 'create', tag, packed.artifact, '--repo', repo, '--title', tag, '--notes', f.notes || `Release ${tag}`], { stdio: 'pipe' });
s.stop(`Released ${tag} on ${repo}`);
const entry = buildEntry({ dir, repo, tag, zipPath: packed.artifact, commit: f.commit, mergePath: f.merge, signKeyPath: signKey(f), now: new Date().toISOString() });
await maybeRetroSign(entry, signKey(f));
logInfo(`Registry entry (add as registry/plugins/${entry.id}.json, or run \`npx trek-plugin-sdk submit\`):`);
process.stdout.write(JSON.stringify(entry, null, 2) + '\n');
} else {
@@ -429,9 +455,28 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
console.error(`Creating GitHub release ${tag} on ${repo}…`);
execFileSync('gh', ['release', 'create', tag, packed.artifact, '--repo', repo, '--title', tag, '--notes', f.notes || `Release ${tag}`], { stdio: 'inherit' });
const entry = buildEntry({ dir, repo, tag, zipPath: packed.artifact, commit: f.commit, mergePath: f.merge, signKeyPath: signKey(f), now: new Date().toISOString() });
await maybeRetroSign(entry, signKey(f));
console.error('\nRegistry entry (add as registry/plugins/' + entry.id + '.json in a TREK-Plugins PR, or run `npx trek-plugin-sdk submit`):\n');
process.stdout.write(JSON.stringify(entry, null, 2) + '\n');
}
} else if (command === 'unrelease') {
if (positional[0]) f.tag = positional[0];
const { repo, tag } = await ensureRepoTag(f, 'unrelease needs a tag and --repo <owner/name>: trek-plugin unrelease vX.Y.Z --repo <owner/name>');
const dir = positional[1] || '.';
let yes = !!f.yes;
if (tui && !yes) {
const ok = await promptConfirm({
message: `Delete the GitHub release, remote tag and local tag ${tag} on ${repo}?`,
initialValue: false,
});
if (!ok) { outro('Cancelled — nothing was deleted.'); return; }
yes = true; // the human just consented — that covers the registry-unreachable guard too
}
const { deleted } = await unrelease({ dir, tag, repo, registry: f.registry, yes, log: tui ? clackLogSink : undefined });
const summary = deleted.length
? `Deleted: ${deleted.join(', ')} — ${tag} is free to publish again.`
: `Nothing to delete — no release or tags found for ${tag}.`;
if (tui) logSuccess(summary); else console.error(summary);
} else {
console.error(`unknown command: ${command}\n${USAGE}`);
process.exit(2);
+1 -1
View File
@@ -268,7 +268,7 @@ export const PERMISSION_CATALOG: { value: string; label: string; hint: string; f
);
/** Every grantable permission id. Derived — never hand-maintained. */
export const KNOWN_PERMISSIONS: string[] = PERMISSION_CATALOG.map((p) => p.value);
export const PICKER_PERMISSIONS: string[] = PERMISSION_CATALOG.map((p) => p.value);
// ── reporter seam (for commands that log their own progress, e.g. publish) ────
+110
View File
@@ -0,0 +1,110 @@
/**
* trek-plugin unrelease <tag> — undo a release that never made it into the registry.
*
* A failed publish (or a change of heart before the PR merges) leaves up to three things
* behind: the GitHub release, the remote tag, and the local tag. Cleaning them up by hand
* is three commands in the right order; this is that, with one hard safety rail:
*
* A version that IS published in the registry is immutable — the registry pins the
* artifact's sha256, so deleting or re-cutting its release breaks install/update for
* everyone who already has it. unrelease refuses those outright, with no override.
*/
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { readJsonFile } from './json.js';
import { DEFAULT_REGISTRY } from './checks/network.js';
import { plainLog, type LogSink } from './ui.js';
function git(dir: string, args: string[]): string {
return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: 'pipe' }).toString().trim();
}
interface PublishedIndex { plugins?: Array<{ id?: string; versions?: Array<{ version?: string }> }> }
/**
* The published registry index, or null when it cannot be read (offline, mirror down).
* Null is the dangerous answer — the caller must then have explicit consent (--yes).
*/
async function publishedIndex(registry: string): Promise<PublishedIndex | null> {
try {
const r = await fetch(`https://raw.githubusercontent.com/${registry}/main/dist/index.json`, {
headers: { 'User-Agent': 'trek-plugin' },
});
if (!r.ok) return null;
return (await r.json()) as PublishedIndex;
} catch {
return null;
}
}
export async function unrelease(opts: {
dir: string; tag: string; repo: string; registry?: string;
/** Consent to delete when the registry index cannot be verified. */
yes?: boolean;
log?: LogSink;
}): Promise<{ deleted: string[] }> {
const log = opts.log ?? plainLog;
const dir = path.resolve(opts.dir);
const { tag, repo } = opts;
const registry = opts.registry || DEFAULT_REGISTRY;
const manifest = readJsonFile<{ id?: unknown }>(path.join(dir, 'trek-plugin.json'));
const id = String(manifest.id ?? '');
const version = tag.replace(/^v/, '');
const index = await publishedIndex(registry);
if (index === null) {
if (!opts.yes) {
throw new Error(
`could not read the registry index (${registry}) to verify ${id} ${version} is not published.\n` +
`Deleting a PUBLISHED version's release breaks its sha256 pin for everyone who installed it.\n` +
`If you are sure this version never merged into the registry, re-run with --yes.`,
);
}
log(` ! registry unreachable — proceeding on --yes`);
} else {
const mine = index.plugins?.find((p) => p.id === id);
if (mine?.versions?.some((v) => v.version === version)) {
throw new Error(
`${id} ${version} is PUBLISHED in the registry (${registry}) — its release is immutable.\n` +
`The registry pins this artifact's sha256; deleting the release would break install/update\n` +
`for everyone who has it. Ship a new version instead. (There is deliberately no override.)`,
);
}
}
// Delete in blast-radius order: release, remote tag, local tag. Each is best-effort and
// reported, so a partial previous cleanup doesn't stop the rest.
const deleted: string[] = [];
let hasRelease = false;
try { execFileSync('gh', ['release', 'view', tag, '--repo', repo], { stdio: 'pipe' }); hasRelease = true; } catch { /* absent */ }
if (hasRelease) {
execFileSync('gh', ['release', 'delete', tag, '--repo', repo, '--yes'], { stdio: 'pipe' });
deleted.push('release');
log(` ✓ deleted release ${tag} on ${repo}`);
} else {
log(` - no release ${tag} on ${repo}`);
}
let hasRemoteTag = false;
try { hasRemoteTag = git(dir, ['ls-remote', '--tags', 'origin', `refs/tags/${tag}`]) !== ''; } catch { /* no origin — nothing to delete */ }
if (hasRemoteTag) {
git(dir, ['push', 'origin', `:refs/tags/${tag}`]);
deleted.push('remote tag');
log(` ✓ deleted remote tag ${tag}`);
} else {
log(` - no tag ${tag} on origin`);
}
let hasLocalTag = false;
try { git(dir, ['rev-parse', `${tag}^{commit}`]); hasLocalTag = true; } catch { /* absent */ }
if (hasLocalTag) {
git(dir, ['tag', '-d', tag]);
deleted.push('local tag');
log(` ✓ deleted local tag ${tag}`);
} else {
log(` - no local tag ${tag}`);
}
return { deleted };
}
+34 -1
View File
@@ -1,5 +1,5 @@
// GENERATED — do not edit by hand.
// Source: server/src/nest/plugins/protocol/envelope.ts
// Source: server/src/nest/plugins/protocol/envelope.ts + server/src/plugin-event-sink.ts
// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts
export const HOOK_PERMISSION: Readonly<Record<string, string>> = {
@@ -316,3 +316,36 @@ export const USER_DATA_PERMISSION = 'hook:user-data';
export const EVENTS_PERMISSION = 'events:subscribe';
export const JOBS_PERMISSION = 'jobs:run';
export const HTTP_OUTBOUND_PREFIX = 'http:outbound:';
/**
* Core-event catalog. Delivery names are the WebSocket broadcast names,
* `<family>:<verb>` (e.g. `place:created`). A subscribed plugin receives
* `{event, tripId, entity?, entityId?, snapshot?}`; `snapshot` is delivered only
* when the plugin also holds EVENT_SNAPSHOT_GRANT[family]. Delete/reorder/bulk
* events carry no snapshot.
*/
export const EVENT_FAMILIES: readonly string[] = [
'accommodation',
'assignment',
'budget',
'day',
'dayNote',
'file',
'packing',
'place',
'reservation',
'trip',
];
export const EVENT_SNAPSHOT_GRANT: Readonly<Record<string, string>> = {
place: 'db:read:trips',
day: 'db:read:trips',
reservation: 'db:read:trips',
accommodation: 'db:read:trips',
assignment: 'db:read:trips',
trip: 'db:read:trips',
budget: 'db:read:costs',
packing: 'db:read:packing',
dayNote: 'db:read:daynotes',
file: 'db:read:files',
};
+72 -19
View File
@@ -23,6 +23,11 @@ export interface BudgetItem { id: number; trip_id?: number; name?: string; total
export interface Assignment { id: number; day_id?: number; place_id?: number; notes?: string | null; [k: string]: unknown }
export interface User { id: number; username?: string; display_name?: string | null; avatar?: string | null; [k: string]: unknown }
/** Every ctx.* call is rate-limited per plugin at the host RPC dispatch boundary:
* burst 60, sustained 20/s, 16 in-flight. A throttled call is refused (retryable)
* rather than executed, with:
* HOST_ERROR: rate limit exceeded — slow down ctx.* calls
* A legitimate plugin never hits the generous burst. See README § Runtime limits. */
export interface PluginContext {
readonly id: string;
readonly config: Readonly<Record<string, unknown>>;
@@ -32,6 +37,9 @@ export interface PluginContext {
settings: {
get(key: string): Promise<unknown>;
};
/** Your OWN sqlite database (`db:own`) — a separate file the plugin never gets a
* path or connection to directly. Quota: 256 MB per plugin (writes past it fail);
* result sets are capped at 100,000 rows. See `tx()` below for the atomic-batch cap. */
db: {
query<T = unknown>(sql: string, ...args: unknown[]): Promise<T[]>;
exec(sql: string, ...args: unknown[]): Promise<{ changes: number }>;
@@ -42,10 +50,15 @@ export interface PluginContext {
tx(ops: Array<{ sql: string; args?: unknown[] }>): Promise<{ results: Array<{ changes?: number; rows?: unknown[] }> }>;
};
trips: {
getById(tripId: number, asUserId?: number): Promise<Trip | null>;
getPlaces(tripId: number, asUserId?: number): Promise<Place[]>;
// `asUserId` is accepted for source compatibility but IGNORED by the host —
// trip reads are always membership-checked against the authenticated user of
// the current invocation (the request's `req.user`), which the plugin cannot
// override. Only reachable from a route handler (a user context); a job has
// no user and its trip reads are refused.
getById(tripId: number, /** @deprecated ignored by the host */ asUserId?: number): Promise<Trip | null>;
getPlaces(tripId: number, /** @deprecated ignored by the host */ asUserId?: number): Promise<Place[]>;
/** Hydrated like the REST list: each row carries `endpoints`, `day_positions` + the day/place joins. */
getReservations(tripId: number, asUserId?: number): Promise<Reservation[]>;
getReservations(tripId: number, /** @deprecated ignored by the host */ asUserId?: number): Promise<Reservation[]>;
/** The trip's days with their `assignments` + `notes_items` (the planner GET's shape). Needs `db:read:trips`. */
getDays(tripId: number): Promise<Day[]>;
/** The trip's lodging blocks (day_accommodations) with joined place fields. Needs `db:read:trips`. */
@@ -97,7 +110,9 @@ export interface PluginContext {
update(tripId: number, itemId: number, input: Record<string, unknown>): Promise<PackingItem>;
/** Delete a packing item. Needs `db:write:packing` + packing_edit. */
delete(tripId: number, itemId: number): Promise<{ deleted: boolean }>;
/** List/create/update/delete packing bags + set members (no privacy). Needs `db:write:packing` + packing_edit. */
// List/create/update/delete packing bags + set members (no privacy). Needs
// `db:write:packing` + packing_edit.
/** Needs 'db:write:packing' (intentional — bags are the write-side structure; packing.list is the read surface). */
listBags(tripId: number): Promise<unknown[]>;
createBag(tripId: number, input: { name: string; color?: string }): Promise<unknown>;
updateBag(tripId: number, bagId: number, input: Record<string, unknown>): Promise<unknown>;
@@ -133,13 +148,17 @@ export interface PluginContext {
};
/** Host-mediated notification. The plugin supplies only target + plain text; the host
* owns delivery + preferences. Recipient is FORCED to the acting user (scope 'user',
* targetId = the acting user) or a trip they belong to (scope 'trip'). Needs `notify:send`. */
* targetId = the acting user) or a trip they belong to (scope 'trip'). Needs `notify:send`.
* Daily budget: 100/day per plugin (UTC midnight rollover); past it `send` throws
* "daily notification budget exhausted (resets at UTC midnight)". See README § Runtime limits. */
notify: {
send(input: { title: string; body: string; link?: string; scope: 'user' | 'trip'; targetId: number }): Promise<{ sent: boolean }>;
};
/** Host-mediated LLM using the admin/user-configured provider — the plugin never holds a
* key. `complete` returns { text }; `extract` returns { results } for your JSON schema.
* Output is DATA: to persist it, push it through the gated write methods yourself. Needs `ai:invoke`. */
* Output is DATA: to persist it, push it through the gated write methods yourself. Needs `ai:invoke`.
* Daily budget: 200/day per plugin (UTC midnight rollover); past it these throw
* "daily AI budget exhausted (resets at UTC midnight)". See README § Runtime limits. */
ai: {
complete(prompt: string, system?: string): Promise<{ text: string }>;
extract(text: string, jsonSchema: object, prompt?: string): Promise<{ results: Record<string, unknown>[] }>;
@@ -147,7 +166,17 @@ export interface PluginContext {
/** Host-brokered outbound OAuth: a short-lived access token for the ACTING USER of a
* third-party service the host connected on their behalf (Settings → Plugins → Connect).
* Returns null when the user hasn't connected or in a userless context. The host holds
* the refresh token + client secret — you never see them. Needs `oauth:client`. */
* the refresh token + client secret — you never see them. Needs `oauth:client`.
*
* The host runs the whole flow (authorize -> callback -> token exchange -> refresh)
* with PKCE + a 10-minute state TTL. Provider config is the plugin's admin-owned
* INSTANCE settings — declare `scope:'instance'` manifest fields named
* `oauth_authorize_url`, `oauth_token_url`, `oauth_scopes` (optional),
* `oauth_client_id`, `oauth_client_secret` and the admin fills them in. Both
* endpoint URLs must be https and may not point at loopback/`.local`/`.internal`/
* private/metadata addresses. The host exposes
* `/api/plugin-oauth/:id/status|connect|callback|disconnect` for the connect UI —
* plugin code only ever calls `getAccessToken()`. See README § OAuth broker. */
oauth: {
getAccessToken(): Promise<string | null>;
};
@@ -282,7 +311,9 @@ export interface PluginContext {
};
// Your OWN namespaced key/value store on a trip/place/day (#1429) — enrich core
// entities without forking the schema. Needs `db:meta`; the entity must belong to
// a trip the current user can access. Values are JSON-serialisable.
// a trip the current user can access. Values are JSON-serialisable. Quotas (per
// plugin + entity): 64 KB serialized JSON per value, 256 chars per key, 100 keys.
// See README § Runtime limits.
meta: {
get(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number, key: string): Promise<unknown>;
set(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number, key: string, value: unknown): Promise<unknown>;
@@ -361,7 +392,8 @@ export interface Photo {
export interface PhotoProvider {
/** Search your photo backend. `ctx` is the last arg so you can reach it via
* ctx.settings/oauth/http. Needs `hook:photo-provider`. Surfaced in the photo picker
* and at `GET /api/plugin-photos/search`; thumbnail/full URLs must be http/https. */
* and at `GET /api/plugin-photos/search`; thumbnail/full URLs must be http/https.
* The host caps results at 60 photos per page. */
search(query: string, opts: { page: number; limit: number }, ctx: PluginContext): Promise<{ photos: Photo[]; total: number; hasMore: boolean }>;
getById(id: string, ctx: PluginContext): Promise<Photo | null>;
}
@@ -421,18 +453,21 @@ export interface CalendarSource {
// start/end are ISO strings — the host->plugin boundary is JSON, so a Date would
// arrive as a string anyway (kept in lockstep with the runtime SDK copy). `ctx` is the
// last arg. Aggregated for the signed-in user at `GET /api/plugin-calendar`.
// The host caps results at 500 events per source per request.
getEvents(userId: number, start: string, end: string, ctx: PluginContext): Promise<CalendarEvent[]>;
}
/** One row of extra place info TREK renders natively (reviews/ratings/links/…). */
export interface PlaceDetailItem { label: string; value?: string; url?: string; }
export interface PlaceDetailProvider {
/** Extra info for a place; core calls this for a `place-detail` panel. Needs `hook:place-detail-provider`. */
/** Extra info for a place; core calls this for a `place-detail` panel. Needs `hook:place-detail-provider`.
* The host caps results at 12 items per provider. */
getDetails(placeId: number, ctx: PluginContext): Promise<PlaceDetailItem[]>;
}
/** A validation/warning a plugin raises on a trip; TREK surfaces it in the planner. */
export interface TripWarning { level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number; }
export interface WarningProvider {
/** Problems/warnings for a trip (e.g. overpacked day, place closed). Needs `hook:trip-warning-provider`. */
/** Problems/warnings for a trip (e.g. overpacked day, place closed). Needs `hook:trip-warning-provider`.
* The host caps results at 20 warnings per provider, each message truncated to 300 chars. */
getWarnings(tripId: number, ctx: PluginContext): Promise<TripWarning[]>;
}
@@ -466,7 +501,8 @@ export type TableContribution = TableColumnContribution | TableActionContributio
export interface TableContributor {
/** `view` is one of 'reservations' | 'places' | 'day' | 'costs' | 'packing' | 'files'.
* Runs with the current user bound, on a short timeout; a slow/failing call is
* skipped, never fatal. Needs `hook:table-contributor`. */
* skipped, never fatal. Needs `hook:table-contributor`. The host caps results at
* 20 columns and 10 actions per entity. */
getContributions(view: string, tripId: number, ctx: PluginContext): Promise<TableContribution[]>;
}
@@ -485,7 +521,7 @@ export interface MapMarkerContribution {
export interface MapMarkerProvider {
/** Return markers to overlay on a trip's map. Runs with the current user bound,
* on a short timeout; the host caps the marker count and skips a failing call.
* Needs `hook:map-marker-provider`. */
* Needs `hook:map-marker-provider`. The host caps results at 200 markers per provider. */
getMarkers(tripId: number, ctx: PluginContext): Promise<MapMarkerContribution[]>;
}
@@ -652,7 +688,8 @@ export interface PdfSection {
export interface PdfSectionProvider {
/** Return sections to append to a trip's PDF export. Runs with the current user
* bound, on a short timeout; the host caps counts/lengths and skips a failing
* call. Needs `hook:pdf-section-provider`. */
* call. Needs `hook:pdf-section-provider`. The host caps results at 5 sections
* per provider (see `PdfSection` above for the per-section paragraph/table caps). */
getSections(tripId: number, ctx: PluginContext): Promise<PdfSection[]>;
}
@@ -668,7 +705,8 @@ export interface AtlasLayerProvider {
/** Return tint layers for the ACTING USER's Atlas map. User-scoped — the host binds
* the current user; the hook takes no target parameter. Runs on a short timeout;
* the host caps the layer/country counts and skips a failing call.
* Needs `hook:atlas-layer-provider`. */
* Needs `hook:atlas-layer-provider`. The host caps results at 3 layers per provider
* (see `AtlasLayer` above for the per-layer country cap). */
getLayers(ctx: PluginContext): Promise<AtlasLayer[]>;
}
@@ -687,7 +725,8 @@ export interface TripCardProvider {
/** Return badges for the dashboard trip cards currently on screen. `tripIds` are
* exactly those cards (each already access-checked for the acting user). Runs with
* the current user bound, on a short timeout; the host caps the badge count and
* skips a failing call. Needs `hook:trip-card-provider`. */
* skips a failing call. Needs `hook:trip-card-provider`. The host caps results at
* 4 badges per trip, 240 total per provider. */
getCards(tripIds: number[], ctx: PluginContext): Promise<TripCardContribution[]>;
}
@@ -703,11 +742,12 @@ export interface JournalEntryProvider {
/** A core-event subscription (#1429 eco). Handlers run with NO user (like a job).
* Needs `events:subscribe`. */
export interface PluginEventSubscription {
/** A core event name (e.g. `place:created`, `day:updated`, `file:created`) or `*` for all. */
/** A core event name `<family>:<verb>` (e.g. `place:created`, `day:updated`, `file:created`)
* where `<family>` is a value in EVENT_FAMILIES, or `*` for all. */
on: string;
// `entity` = the event family (e.g. 'reservation'); `entityId` = WHICH entity changed,
// when known. `snapshot` = a whitelisted field view of the changed entity, delivered
// only when the plugin also holds the family's db:read:* grant — never user ids,
// only when the plugin also holds EVENT_SNAPSHOT_GRANT[family] — never user ids,
// private packing items or secrets; delete/reorder/bulk events carry none. Still no
// acting user: a trip read from the handler is refused.
handler(payload: { event: string; tripId: number; entity?: string; entityId?: number; snapshot?: Record<string, unknown> }, ctx: PluginContext): Promise<void> | void;
@@ -778,7 +818,16 @@ export function definePlugin(def: PluginDefinition): PluginDefinition {
return def;
}
export { validateManifest, CHANNEL_EVENTS, type PluginManifest, type ValidationResult } from './manifest.js';
export {
validateManifest,
CHANNEL_EVENTS,
type PluginManifest,
type NormalizedManifest,
type ValidationResult,
type ManifestSettingField,
type ManifestAction,
type ManifestCapabilities,
} from './manifest.js';
export { createMockHost, type MockHostOptions } from './mock-host.js';
// The permissions TREK enforces OUTSIDE ctx — hooks, events, jobs, egress. An entry
// point you implement without its grant is never called in production, silently; these
@@ -787,6 +836,10 @@ export {
PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION,
grantGaps, grantedHosts, type GrantGap, type PluginEntryPoints,
} from './permissions.js';
// The core-event catalog: families and the snapshot-delivery permission each family requires.
export {
EVENT_FAMILIES, EVENT_SNAPSHOT_GRANT, KNOWN_PERMISSIONS,
} from './generated/host-facts.js';
/** Scope for host-managed, per-user session state in a sandboxed plugin UI. */
export type PluginSessionStorageScope = 'plugin' | 'trip';
+121 -23
View File
@@ -10,24 +10,69 @@ export interface PluginDependency {
id: string;
version: string;
}
export interface ManifestSettingField {
key: string;
label?: string;
input_type?: string;
placeholder?: string;
hint?: string;
required?: boolean;
secret?: boolean;
scope?: 'instance' | 'user';
options?: Array<string | number | { value: string | number; label?: string }>;
oauth?: { initPath?: string; callbackPath?: string };
}
export interface ManifestAction {
key: string;
label?: string;
hint?: string;
danger?: boolean;
}
export interface ManifestCapabilities {
settingsUi?: boolean;
widget?: { title?: string; defaultSize?: string; slot?: 'sidebar' | 'hero' | 'place-detail' | 'day-detail' | 'reservation-detail' };
tripPage?: { replaces?: string[]; position?: number };
notificationChannel?: { title?: string; events?: string[] };
routeProfiles?: Array<{ id: string; label: string; icon?: string }>;
provides?: string[];
emits?: string[];
}
export interface PluginManifest {
id: string;
name: string;
version: string;
apiVersion: number;
/** The semver RANGE of TREK versions this plugin supports (">=3.2.0 <4.0.0"). */
/** The semver RANGE of TREK versions this plugin supports (">=4.0.0 <5.0.0"). */
trek: string;
type: 'integration' | 'page' | 'widget' | 'trip-page';
apiVersion?: number;
permissions?: string[];
egress?: string[];
nativeModules?: boolean;
operatorEgress?: boolean;
requiredAddons?: string[];
pluginDependencies?: PluginDependency[];
capabilities?: ManifestCapabilities;
settings?: ManifestSettingField[];
actions?: ManifestAction[];
icon?: string;
author?: string;
description?: string;
homepage?: string;
tags?: string[];
license?: string;
}
export interface NormalizedManifest extends PluginManifest {
apiVersion: number;
permissions: string[];
egress: string[];
nativeModules?: boolean;
nativeModules: false;
requiredAddons: string[];
pluginDependencies: PluginDependency[];
}
export interface ValidationResult {
ok: boolean;
errors: string[];
manifest?: PluginManifest;
manifest?: NormalizedManifest;
}
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
@@ -123,8 +168,8 @@ export function validateManifest(raw: unknown): ValidationResult {
if (!isSatisfiableRange(m.trek)) {
errors.push(
typeof m.trek === 'string' && m.trek
? `"trek" is not a satisfiable semver range: "${m.trek}" (e.g. ">=3.2.0 <4.0.0")`
: 'missing "trek" — declare the TREK versions this plugin supports, e.g. ">=3.2.0 <4.0.0"',
? `"trek" is not a satisfiable semver range: "${m.trek}" (e.g. ">=4.0.0 <5.0.0")`
: 'missing "trek" — declare the TREK versions this plugin supports, e.g. ">=4.0.0 <5.0.0"',
);
}
if (m.apiVersion !== undefined && typeof m.apiVersion !== 'number') errors.push('apiVersion must be a number');
@@ -209,6 +254,10 @@ export function validateManifest(raw: unknown): ValidationResult {
// Mirrors the server's routeProfiles parsing: the planner's route picker shows these,
// so they must be well-formed and only exist alongside the routeProvider grant.
const routeProfiles = capabilities?.routeProfiles;
// Normalized routeProfiles entries (icon dropped when non-string, like the server's
// optStr()) — populated only when routeProfiles is present and shaped as an array;
// used to build the output below in place of the raw, possibly-dirty input array.
let normalizedRouteProfiles: Array<{ id: string; label: string; icon?: string }> | undefined;
if (routeProfiles !== undefined) {
if (!permissions.includes('hook:route-provider')) {
errors.push('capabilities.routeProfiles requires the "hook:route-provider" permission');
@@ -217,14 +266,21 @@ export function validateManifest(raw: unknown): ValidationResult {
else {
if (routeProfiles.length > 3) errors.push('capabilities.routeProfiles: at most 3 profiles');
const seen = new Set<string>();
normalizedRouteProfiles = [];
for (const v of routeProfiles) {
const p = (v && typeof v === 'object' ? v : {}) as { id?: unknown; label?: unknown };
const p = (v && typeof v === 'object' ? v : {}) as { id?: unknown; label?: unknown; icon?: unknown };
const id = typeof p.id === 'string' ? p.id : '';
if (!/^[a-z][a-z0-9-]{0,23}$/.test(id)) errors.push('capabilities.routeProfiles: id must be lowercase [a-z][a-z0-9-], max 24 chars');
else if (seen.has(id)) errors.push(`capabilities.routeProfiles: duplicate id "${id}"`);
else seen.add(id);
const label = typeof p.label === 'string' ? p.label.trim() : '';
if (!label || label.length > 40) errors.push('capabilities.routeProfiles: label is required (max 40 chars)');
// icon is optional; a non-string icon is silently dropped, not rejected — mirrors
// the server's optStr() (server/src/nest/plugins/install/manifest.ts:519-521),
// which returns undefined for a non-string value rather than throwing. Length is
// never rejected either — the server slices to 40 chars at install time.
const icon = typeof p.icon === 'string' ? p.icon : undefined;
normalizedRouteProfiles.push(icon !== undefined ? { id, label, icon } : { id, label });
}
}
}
@@ -243,6 +299,33 @@ export function validateManifest(raw: unknown): ValidationResult {
if (s.scope !== undefined && s.scope !== 'user' && s.scope !== 'instance') {
errors.push(`settings["${key}"].scope must be "user" or "instance"`);
}
if (s.options !== undefined) {
if (!Array.isArray(s.options)) {
errors.push('settings option list must be an array');
} else {
for (const o of s.options) {
if (typeof o === 'string' || typeof o === 'number') continue;
if (o && typeof o === 'object') {
const value = (o as { value?: unknown }).value;
if (value === undefined || value === null || String(value) === '') {
errors.push('settings option must have a non-empty "value"');
}
} else {
errors.push(`invalid settings option ${JSON.stringify(o)} (expected a string or { value, label })`);
}
}
}
}
if (s.oauth !== undefined) {
if (!s.oauth || typeof s.oauth !== 'object' || Array.isArray(s.oauth)) {
errors.push('settings oauth must be an object');
} else {
for (const k of ['initPath', 'callbackPath'] as const) {
const v = (s.oauth as Record<string, unknown>)[k];
if (v !== undefined && typeof v !== 'string') errors.push(`settings oauth.${k} must be a string`);
}
}
}
}
}
// Settings-page action buttons ("Test connection"). Keys share the settings-key rules.
@@ -269,23 +352,38 @@ export function validateManifest(raw: unknown): ValidationResult {
const pluginDependencies = validatePluginDependencies(m.pluginDependencies, typeof m.id === 'string' ? m.id : '', errors);
if (errors.length) return { ok: false, errors };
return {
ok: true,
errors: [],
manifest: {
id: m.id as string,
name: m.name as string,
version: m.version as string,
apiVersion: typeof m.apiVersion === 'number' ? m.apiVersion : 1,
trek: m.trek as string,
type: m.type as PluginManifest['type'],
permissions,
egress,
nativeModules: false,
requiredAddons,
pluginDependencies,
},
const manifest: NormalizedManifest = {
id: m.id as string,
name: m.name as string,
version: m.version as string,
apiVersion: typeof m.apiVersion === 'number' ? m.apiVersion : 1,
trek: m.trek as string,
type: m.type as PluginManifest['type'],
permissions,
egress,
nativeModules: false,
requiredAddons,
pluginDependencies,
};
// Carried through verbatim when present — absent stays absent (no undefined keys) — except
// routeProfiles, which is rebuilt from normalizedRouteProfiles so a dropped (non-string)
// icon doesn't leak the raw input value back out.
if (m.capabilities !== undefined) {
const rawCapabilities = m.capabilities as ManifestCapabilities;
manifest.capabilities = normalizedRouteProfiles !== undefined
? { ...rawCapabilities, routeProfiles: normalizedRouteProfiles }
: rawCapabilities;
}
if (m.settings !== undefined) manifest.settings = m.settings as ManifestSettingField[];
if (m.actions !== undefined) manifest.actions = m.actions as ManifestAction[];
if (m.operatorEgress !== undefined) manifest.operatorEgress = m.operatorEgress as boolean;
if (m.icon !== undefined) manifest.icon = m.icon as string;
if (m.author !== undefined) manifest.author = m.author as string;
if (m.description !== undefined) manifest.description = m.description as string;
if (m.homepage !== undefined) manifest.homepage = m.homepage as string;
if (m.tags !== undefined) manifest.tags = m.tags as string[];
if (m.license !== undefined) manifest.license = m.license as string;
return { ok: true, errors: [], manifest };
}
// Export/event names exposed to other plugins (dots allowed for event names).
+48 -1
View File
@@ -107,6 +107,18 @@ export interface MockHostOptions {
aiResults?: Record<string, unknown>[];
/** The acting user's connected-service token for ctx.oauth.getAccessToken (default null). */
oauthAccessToken?: string | null;
/** Daily cap on ctx.ai.complete/ctx.ai.extract calls, mirroring the host's
* per-plugin daily-budget broker (daily-budget.ts). Default 200; `0` disables
* the broker entirely, so the FIRST call fails with the exhaustion string.
* Unlike the real host, this is a plain in-memory counter for the life of this
* mock host: it never rolls over at UTC midnight — there is no timer here, and
* no wall-clock window. A test that wants to see the budget "reset" should
* create a fresh mock host rather than wait. */
aiPerDay?: number;
/** Same idea as `aiPerDay`, for ctx.notify.send. Default 100; `0` disables the
* broker (first call fails with the exhaustion string). See `aiPerDay` for the
* no-rollover caveat — this counter never resets on its own either. */
notifyPerDay?: number;
}
/** Drives a plugin's OWN entry points against the mock ctx — the missing half of a
@@ -234,6 +246,13 @@ function settingsBlob(src: Record<string, unknown> | undefined, what: string): R
const PLACE_STR_LIMITS: Record<string, number> = { name: 200, description: 2000, address: 500, notes: 2000 };
const TRIP_STR_LIMITS: Record<string, number> = { title: 200, description: 2000 };
// ctx.meta quotas, mirrored from meta.rpc.ts's disk-DoS guard: value size (characters
// of the serialized JSON, matching the host — UTF-16 code units, not UTF-8 bytes),
// key length, and keys per (plugin, entity).
const META_VALUE_MAX = 64 * 1024;
const META_KEY_MAX = 256;
const META_KEYS_MAX = 100;
export function createMockHost(opts: MockHostOptions = {}): MockHost {
const grants = new Set(opts.grants ?? []);
const calls: MockHost['calls'] = [];
@@ -372,6 +391,14 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
const metaStore: Record<string, unknown> = {};
const metaKey = (et: string, eid: number, key: string) => `${et}:${eid}:${key}`;
// Per-host daily budgets for ai.*/notify.send, mirroring daily-budget.ts. Plain
// counters, no timers: this mock never rolls the window over at UTC midnight (see
// the MockHostOptions docblocks) — a test wanting a fresh budget creates a new host.
const aiPerDay = opts.aiPerDay ?? 200;
const notifyPerDay = opts.notifyPerDay ?? 100;
let aiUsed = 0;
let notifyUsed = 0;
const buildCtx = (actingUserId: number | undefined): PluginContext => {
const requireActingUser = (): number => {
if (actingUserId === undefined) throw new Error('RESOURCE_FORBIDDEN: this call requires an authenticated user context');
@@ -816,6 +843,8 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
if (!input.link.startsWith('/') || input.link.startsWith('//')) throw new Error('link must be an in-app path starting with /');
link = input.link.slice(0, 512);
}
if (notifyUsed >= notifyPerDay) throw new Error('daily notification budget exhausted (resets at UTC midnight)');
notifyUsed += 1;
notifications.push({ title, body, ...(link ? { link } : {}), scope, targetId: input.targetId });
return { sent: true };
},
@@ -823,10 +852,14 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
ai: {
async complete() {
need('ai:invoke', 'ai.complete');
if (aiUsed >= aiPerDay) throw new Error('daily AI budget exhausted (resets at UTC midnight)');
aiUsed += 1;
return { text: opts.aiText ?? '' };
},
async extract() {
need('ai:invoke', 'ai.extract');
if (aiUsed >= aiPerDay) throw new Error('daily AI budget exhausted (resets at UTC midnight)');
aiUsed += 1;
return { results: opts.aiResults ?? [] };
},
},
@@ -1351,7 +1384,21 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
async set(entityType, entityId, key, value) {
need('db:meta', 'meta.set');
metaGate(entityType, entityId);
metaStore[metaKey(entityType, entityId, key)] = value ?? null;
// Quota order mirrors meta.rpc.ts: key length -> value size -> key count.
if (key.length > META_KEY_MAX) throw new Error(`metadata key too long (>${META_KEY_MAX} chars)`);
const json = JSON.stringify(value ?? null);
// Matches the host exactly: json.length (UTF-16 code units of the serialized
// JSON), not Buffer.byteLength (UTF-8 bytes) — see meta.rpc.ts:71.
if (json.length > META_VALUE_MAX) {
throw new Error(`metadata value too large (>${META_VALUE_MAX} bytes)`);
}
const fullKey = metaKey(entityType, entityId, key);
if (!(fullKey in metaStore)) {
const prefix = `${entityType}:${entityId}:`;
const count = Object.keys(metaStore).filter((k) => k.startsWith(prefix)).length;
if (count >= META_KEYS_MAX) throw new Error(`too many metadata keys on this ${entityType} (max ${META_KEYS_MAX})`);
}
metaStore[fullKey] = value ?? null;
return { key, value: value ?? null };
},
async list(entityType, entityId) {
+36 -3
View File
@@ -4,7 +4,7 @@ import os from 'node:os';
import path from 'node:path';
import { validateManifest } from '../src/index.js';
import { scaffold } from '../src/cli/create.js';
import { PERMISSION_CATALOG, PERMISSION_FAMILIES, KNOWN_PERMISSIONS, isInteractive, missingArgs } from '../src/cli/ui.js';
import { PERMISSION_CATALOG, PERMISSION_FAMILIES, PICKER_PERMISSIONS, isInteractive, missingArgs } from '../src/cli/ui.js';
// The authoritative set — what the HOST accepts at activation. The picker must match it.
import { KNOWN_PERMISSIONS as MANIFEST_PERMISSIONS } from '../src/manifest.js';
import { resolveMenuChoice, PRIMARY_MENU, ADVANCED_MENU } from '../src/cli/menu.js';
@@ -47,7 +47,7 @@ describe('permission catalog', () => {
// list the host actually honours — so a new TREK permission fails here until `create`
// can offer it.
it('offers every permission the host accepts — no more, no less', () => {
expect([...KNOWN_PERMISSIONS].sort()).toEqual([...MANIFEST_PERMISSIONS].sort());
expect([...PICKER_PERMISSIONS].sort()).toEqual([...MANIFEST_PERMISSIONS].sort());
});
it('describes every permission, and files it in exactly one family', () => {
@@ -57,7 +57,7 @@ describe('permission catalog', () => {
.toHaveLength(1);
}
// No duplicates across families — a permission listed twice would render twice.
expect(new Set(KNOWN_PERMISSIONS).size).toBe(KNOWN_PERMISSIONS.length);
expect(new Set(PICKER_PERMISSIONS).size).toBe(PICKER_PERMISSIONS.length);
});
it('every family is non-empty and described (an empty one is a dead prompt entry)', () => {
@@ -245,3 +245,36 @@ describe('capabilities.notificationChannel validation', () => {
expect(r.errors!.join(' ')).toMatch(/requires the "hook:notification-channel" permission/);
});
});
describe('scaffold oauth:client settings', () => {
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'oauth-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
const OAUTH_KEYS = ['oauth_authorize_url', 'oauth_token_url', 'oauth_scopes', 'oauth_client_id', 'oauth_client_secret'];
it('scaffolds the five instance-scoped broker settings when oauth:client is granted', () => {
scaffold('oauth-plug', 'integration', tmp, { permissions: ['db:own', 'oauth:client'] });
const m = JSON.parse(fs.readFileSync(path.join(tmp, 'oauth-plug', 'trek-plugin.json'), 'utf8'));
const settings = m.settings as Array<{ key: string; secret?: boolean; scope?: string }>;
for (const key of OAUTH_KEYS) {
expect(settings.some((s) => s.key === key), `missing setting "${key}"`).toBe(true);
}
const secret = settings.find((s) => s.key === 'oauth_client_secret')!;
expect(secret.secret).toBe(true);
for (const key of OAUTH_KEYS) {
const s = settings.find((x) => x.key === key)!;
expect(s.scope, `${key}.scope`).toBe('instance');
}
expect(validateManifest(m).ok).toBe(true);
});
it('scaffolds none of the broker settings without oauth:client', () => {
scaffold('plain-oauth-plug', 'integration', tmp, { permissions: ['db:own'] });
const m = JSON.parse(fs.readFileSync(path.join(tmp, 'plain-oauth-plug', 'trek-plugin.json'), 'utf8'));
const settings = (m.settings ?? []) as Array<{ key: string }>;
for (const key of OAUTH_KEYS) {
expect(settings.some((s) => s.key === key), `unexpected setting "${key}"`).toBe(false);
}
});
});
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { validateManifest } from '../src/manifest.js';
describe('validateManifest normalized output', () => {
it('normalized manifest keeps every recognized field', () => {
const input = {
id: 'my-plugin', name: 'My Plugin', version: '1.0.0', type: 'widget', trek: '>=4.0.0 <5.0.0',
permissions: ['db:own'], operatorEgress: false, icon: 'Bell', author: 'A', description: 'Does things.',
homepage: 'https://example.com', tags: ['fun'], license: 'MIT',
capabilities: { widget: { title: 'W', slot: 'hero' }, provides: ['getState'] },
settings: [{ key: 'mode', scope: 'user', options: ['a', 'b'] }],
actions: [{ key: 'ping', label: 'Ping' }],
};
const r = validateManifest(input);
expect(r.ok).toBe(true);
const m = r.manifest!;
expect(m.capabilities?.widget?.slot).toBe('hero');
expect(m.settings?.[0].options).toEqual(['a', 'b']);
expect(m.actions?.[0]).toEqual({ key: 'ping', label: 'Ping' });
expect(m.icon).toBe('Bell');
expect(m.operatorEgress).toBe(false);
expect(m.nativeModules).toBe(false); // still forced
expect(m.apiVersion).toBe(1); // still defaulted
expect(m.requiredAddons).toEqual([]);
});
it('accepts routeProfiles icon', () => {
const r = validateManifest({
id: 'rt-plug', name: 'R', version: '1.0.0', type: 'integration', trek: '>=4.0.0 <5.0.0',
permissions: ['hook:route-provider'], capabilities: { routeProfiles: [{ id: 'hike', label: 'Hiking', icon: 'Mountain' }] },
});
expect(r.ok).toBe(true);
expect(r.manifest!.capabilities?.routeProfiles?.[0].icon).toBe('Mountain');
});
it('drops a non-string routeProfiles icon instead of rejecting the manifest', () => {
const r = validateManifest({
id: 'rt-plug', name: 'R', version: '1.0.0', type: 'integration', trek: '>=4.0.0 <5.0.0',
permissions: ['hook:route-provider'], capabilities: { routeProfiles: [{ id: 'hike', label: 'Hiking', icon: 42 }] },
});
expect(r.ok).toBe(true);
expect(r.manifest!.capabilities?.routeProfiles?.[0].icon).toBeUndefined();
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { validateManifest } from '../src/manifest.js';
const base = () => ({ id: 'my-plugin', name: 'My Plugin', version: '1.0.0', type: 'integration', trek: '>=4.0.0 <5.0.0' });
const withSetting = (extra: object) => ({ ...base(), settings: [{ key: 'mode', ...extra }] });
describe('validateManifest settings[].options / settings[].oauth', () => {
it('accepts string/number option lists', () => {
expect(validateManifest(withSetting({ options: ['a', 'b'] })).ok).toBe(true);
expect(validateManifest(withSetting({ options: [1, 2] })).ok).toBe(true);
});
it('accepts {value,label} options incl. value 0', () => {
expect(validateManifest(withSetting({ options: [{ value: 0, label: 'Zero' }] })).ok).toBe(true);
});
it('rejects a non-array options', () => {
const r = validateManifest(withSetting({ options: 'a,b' }));
expect(r.ok).toBe(false);
expect(r.errors).toContain('settings option list must be an array');
});
it('rejects an option with empty value', () => {
const r = validateManifest(withSetting({ options: [{ value: '', label: 'x' }] }));
expect(r.errors).toContain('settings option must have a non-empty "value"');
});
it('rejects a garbage option entry', () => {
const r = validateManifest(withSetting({ options: [true] }));
expect(r.errors).toContain('invalid settings option true (expected a string or { value, label })');
});
it('rejects a non-object oauth', () => {
expect(validateManifest(withSetting({ oauth: 'x' })).errors).toContain('settings oauth must be an object');
});
it('rejects non-string oauth paths, accepts valid oauth', () => {
expect(validateManifest(withSetting({ oauth: { initPath: 3 } })).errors).toContain('settings oauth.initPath must be a string');
expect(validateManifest(withSetting({ oauth: { initPath: '/oauth/init' } })).ok).toBe(true);
});
});
+134
View File
@@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import { createMockHost } from '../src/index.js';
/**
* Mock-host fidelity for the two host-mediated daily budgets (ai.complete/extract and
* notify.send, daily-budget.ts) and the ctx.meta quotas (meta.rpc.ts). Error strings
* and check order are copied verbatim from the server so a plugin author sees the
* exact same failure locally that production would give them.
*/
describe('daily budgets', () => {
it('exhausts the AI budget', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1, aiPerDay: 2 });
await host.ctx.ai.complete('hi');
await host.ctx.ai.complete('hi');
await expect(host.ctx.ai.complete('hi')).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('ai.extract shares the same per-host AI budget as ai.complete', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1, aiPerDay: 1 });
await host.ctx.ai.complete('hi');
await expect(host.ctx.ai.extract('text', {})).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('aiPerDay: 0 disables the broker — the first call fails', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1, aiPerDay: 0 });
await expect(host.ctx.ai.complete('hi')).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('defaults to 200/day for ai, far above what a normal test suite calls', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1 });
for (let i = 0; i < 200; i++) await host.ctx.ai.complete('hi');
await expect(host.ctx.ai.complete('hi')).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('exhausts the notify budget', async () => {
const host = createMockHost({
grants: ['notify:send'],
actingUserId: 1,
trips: { 1: { members: [1] } },
notifyPerDay: 1,
});
await host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 });
await expect(host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 })).rejects.toThrow(
'daily notification budget exhausted (resets at UTC midnight)',
);
});
it('notifyPerDay: 0 disables the broker — the first call fails', async () => {
const host = createMockHost({
grants: ['notify:send'],
actingUserId: 1,
trips: { 1: { members: [1] } },
notifyPerDay: 0,
});
await expect(host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 })).rejects.toThrow(
'daily notification budget exhausted (resets at UTC midnight)',
);
});
it('defaults to 100/day for notify, far above what a normal test suite calls', async () => {
const host = createMockHost({ grants: ['notify:send'], actingUserId: 1, trips: { 1: { members: [1] } } });
for (let i = 0; i < 100; i++) await host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 });
await expect(host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 })).rejects.toThrow(
'daily notification budget exhausted (resets at UTC midnight)',
);
});
});
describe('ctx.meta quotas', () => {
const fixture = () =>
createMockHost({ grants: ['db:meta'], actingUserId: 1, trips: { 1: { members: [1] } } });
it('meta value quota: 65536 bytes passes, 65537 rejects', async () => {
const { ctx } = fixture();
// Size the payload via Buffer.byteLength(JSON.stringify(v)), same as the server's
// notion of "serialized JSON bytes" — a bare string serializes with 2 quote bytes,
// so budget those into the repeat count.
const okValue = 'x'.repeat(65536 - 2);
expect(Buffer.byteLength(JSON.stringify(okValue), 'utf8')).toBe(65536);
await expect(ctx.meta.set('trip', 1, 'k', okValue)).resolves.toEqual({ key: 'k', value: okValue });
const tooBig = 'x'.repeat(65537 - 2);
expect(Buffer.byteLength(JSON.stringify(tooBig), 'utf8')).toBe(65537);
await expect(ctx.meta.set('trip', 1, 'k2', tooBig)).rejects.toThrow('metadata value too large (>65536 bytes)');
});
it('meta key length: 256 chars passes, 257 rejects', async () => {
const { ctx } = fixture();
const okKey = 'k'.repeat(256);
await expect(ctx.meta.set('trip', 1, okKey, 'v')).resolves.toEqual({ key: okKey, value: 'v' });
const tooLongKey = 'k'.repeat(257);
await expect(ctx.meta.set('trip', 1, tooLongKey, 'v')).rejects.toThrow('metadata key too long (>256 chars)');
});
it('meta key count: 100 keys per (plugin, entity) passes, the 101st rejects', async () => {
const { ctx } = fixture();
for (let i = 0; i < 100; i++) {
await ctx.meta.set('trip', 1, `key-${i}`, i);
}
await expect(ctx.meta.set('trip', 1, 'key-100', 100)).rejects.toThrow(
'too many metadata keys on this trip (max 100)',
);
});
it('overwriting an existing key never counts against the 100-key cap', async () => {
const { ctx } = fixture();
for (let i = 0; i < 100; i++) {
await ctx.meta.set('trip', 1, `key-${i}`, i);
}
// The entity already has 100 keys; updating one of them must still succeed.
await expect(ctx.meta.set('trip', 1, 'key-0', 'updated')).resolves.toEqual({ key: 'key-0', value: 'updated' });
});
it('checks in server order: key length before value size before key count', async () => {
const { ctx } = fixture();
// A key that is simultaneously too long AND paired with an oversized value must
// fail on the key-length check first.
const tooLongKey = 'k'.repeat(257);
const tooBigValue = 'x'.repeat(70000);
await expect(ctx.meta.set('trip', 1, tooLongKey, tooBigValue)).rejects.toThrow('metadata key too long (>256 chars)');
// Fill the entity to the cap, then a value that is ALSO too large must fail on
// the value-size check (raised before the key-count check ever runs).
const full = fixture();
for (let i = 0; i < 100; i++) {
await full.ctx.meta.set('trip', 1, `key-${i}`, i);
}
await expect(full.ctx.meta.set('trip', 1, 'new-key', tooBigValue)).rejects.toThrow(
'metadata value too large (>65536 bytes)',
);
});
});
+11 -1
View File
@@ -15,7 +15,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { HOOK_PERMISSION } from '../src/permissions.js';
import { PLUGIN_SESSION_MAX_KEYS, PLUGIN_SESSION_MAX_KEY_LENGTH, PLUGIN_SESSION_MAX_VALUE_BYTES } from '../src/index.js';
import { PLUGIN_SESSION_MAX_KEYS, PLUGIN_SESSION_MAX_KEY_LENGTH, PLUGIN_SESSION_MAX_VALUE_BYTES, EVENT_FAMILIES, EVENT_SNAPSHOT_GRANT, KNOWN_PERMISSIONS } from '../src/index.js';
const here = path.dirname(fileURLToPath(import.meta.url));
const serverPlugins = path.resolve(here, '../../server/src/nest/plugins');
@@ -86,3 +86,13 @@ describe.skipIf(!fs.existsSync(hostFrame))('parity with the host frame', () => {
}
});
});
describe.skipIf(!inMonorepo)('core event catalog', () => {
it('event snapshot grants are known read permissions on known families', () => {
expect(EVENT_FAMILIES.length).toBeGreaterThan(0);
for (const [family, perm] of Object.entries(EVENT_SNAPSHOT_GRANT)) {
expect(EVENT_FAMILIES).toContain(family);
expect(KNOWN_PERMISSIONS).toContain(perm);
}
});
});
+128 -2
View File
@@ -6,6 +6,11 @@ import path from 'node:path';
// publish shells out to git + gh; drive those from here so we can assert what it WOULD run.
const calls: Array<{ bin: string; args: string[] }> = [];
let releaseExistsOnRemote = false;
/** The commit the local tag points at; null = no local tag. `git tag` mutates it like git would. */
let localTagSha: string | null = null;
let remoteTagOnOrigin = false;
let failPrCreate = false;
const HEAD_SHA = 'headfeedheadfeedheadfeedheadfeedheadfeed';
vi.mock('node:child_process', () => ({
execFileSync: (bin: string, args: string[]) => {
@@ -14,8 +19,24 @@ vi.mock('node:child_process', () => ({
if (!releaseExistsOnRemote) throw new Error('release not found');
return Buffer.from('');
}
// `git rev-parse <tag>^{commit}` — pretend the tag does not exist yet.
if (bin === 'git' && args.includes('rev-parse')) throw new Error('unknown revision');
if (bin === 'gh' && args[0] === 'pr' && args[1] === 'create' && failPrCreate) throw new Error('pr create exploded');
if (bin === 'gh' && args[0] === 'api') return Buffer.from('someone');
if (bin === 'git' && args.includes('ls-remote')) {
return Buffer.from(remoteTagOnOrigin ? `${HEAD_SHA}\trefs/tags/v1.0.0` : '');
}
if (bin === 'git' && args.includes('tag')) {
// Mirror git's behaviour so later rev-parses see what the run did.
const i = args.indexOf('tag');
if (args[i + 1] === '-d') localTagSha = null;
else localTagSha = HEAD_SHA; // `git tag <t>` and `git tag -f <t>` both land on HEAD
return Buffer.from('');
}
if (bin === 'git' && args.includes('rev-parse')) {
const target = args[args.indexOf('rev-parse') + 1];
if (target === 'HEAD') return Buffer.from(HEAD_SHA);
if (localTagSha === null) throw new Error('unknown revision');
return Buffer.from(localTagSha);
}
return Buffer.from('');
},
}));
@@ -24,6 +45,12 @@ const { publishPlugin } = await import('../src/cli/publish.js');
const { scaffold } = await import('../src/cli/create.js');
const { makePublishable } = await import('./helpers.js');
beforeEach(() => {
localTagSha = null;
remoteTagOnOrigin = false;
failPrCreate = false;
});
describe('publish — a released artifact is immutable', () => {
let tmp: string;
let dir: string;
@@ -139,6 +166,105 @@ describe('publish — the local gates run before anything is cut', () => {
* cut at step 3 — so the author learned it with their tag already burned, for a fact that was
* knowable before a single byte was packed. The check belongs in step 1, and this pins it there.
*/
/**
* The cleanup half of the immutability story. Step 3 creates a local tag, pushes it, and cuts
* the release; when step 4/5 then failed, all three were stranded and the author had to delete
* them by hand before re-running. A failed publish now rolls back exactly what IT created —
* and only that: anything that existed before the run (a merged release, a pre-pushed tag) is
* never touched.
*/
describe('publish — a failed run rolls back what it created', () => {
let tmp: string;
let dir: string;
beforeEach(() => {
calls.length = 0;
releaseExistsOnRemote = false;
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-rollback-'));
scaffold('rollback-plug', 'integration', tmp);
dir = path.join(tmp, 'rollback-plug');
makePublishable(dir);
});
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
const run = (opts: { force?: boolean; keepRelease?: boolean } = {}) => publishPlugin({
dir, repo: 'someone/trek-plugin-rollback-plug', tag: 'v1.0.0',
now: '2026-01-01T00:00:00Z', skipPreflight: true, log: () => {}, ...opts,
});
it('deletes the release, remote tag, and local tag this run created', async () => {
failPrCreate = true;
await expect(run()).rejects.toThrow(/rolled back/i);
expect(calls.some((c) => c.bin === 'gh' && c.args[0] === 'release' && c.args[1] === 'delete' && c.args.includes('v1.0.0'))).toBe(true);
expect(calls.some((c) => c.bin === 'git' && c.args.includes(':refs/tags/v1.0.0'))).toBe(true);
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag') && c.args.includes('-d'))).toBe(true);
});
it('--keep-release leaves everything in place and prints the manual cleanup', async () => {
failPrCreate = true;
await expect(run({ keepRelease: true })).rejects.toThrow(/gh release delete/);
expect(calls.some((c) => c.bin === 'gh' && c.args[1] === 'delete')).toBe(false);
expect(calls.some((c) => c.args.includes(':refs/tags/v1.0.0'))).toBe(false);
});
it('never deletes a release or remote tag that existed BEFORE the run', async () => {
releaseExistsOnRemote = true;
remoteTagOnOrigin = true;
failPrCreate = true;
await run({ force: true }).catch(() => {});
expect(calls.some((c) => c.bin === 'gh' && c.args[1] === 'delete')).toBe(false);
expect(calls.some((c) => c.args.includes(':refs/tags/v1.0.0'))).toBe(false);
// the local tag WAS created by this run, so that one goes
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag') && c.args.includes('-d'))).toBe(true);
});
});
/**
* The stale-tag trap: a failed publish leaves a tag; the author commits a fix and re-runs; the
* old flow silently reused the stale tag and released the UNFIXED code. A tag that does not
* point at HEAD and has no release is debris — move it. One that HAS a release is immutable.
*/
describe('publish — a stale tag from a failed run is moved, not reused', () => {
let tmp: string;
let dir: string;
beforeEach(() => {
calls.length = 0;
releaseExistsOnRemote = false;
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-stale-'));
scaffold('stale-plug', 'integration', tmp);
dir = path.join(tmp, 'stale-plug');
makePublishable(dir);
});
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
const run = () => publishPlugin({
dir, repo: 'someone/trek-plugin-stale-plug', tag: 'v1.0.0',
now: '2026-01-01T00:00:00Z', skipPreflight: true, log: () => {},
});
it('re-points a debris tag (no release) at HEAD and force-pushes it', async () => {
localTagSha = 'stalestalestalestalestalestalestalestale';
await run().catch(() => {});
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag') && c.args.includes('-f'))).toBe(true);
const push = calls.find((c) => c.bin === 'git' && c.args.includes('push') && c.args.includes('v1.0.0'));
expect(push?.args).toContain('--force');
});
it('refuses a mismatched tag whose release already exists', async () => {
localTagSha = 'stalestalestalestalestalestalestalestale';
releaseExistsOnRemote = true;
await expect(run()).rejects.toThrow(/points at/i);
expect(calls.some((c) => c.bin === 'git' && c.args.includes('-f'))).toBe(false);
});
it('reuses a tag already on HEAD without touching it', async () => {
localTagSha = HEAD_SHA;
await run().catch(() => {});
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag') && !c.args.includes('-d'))).toBe(false);
});
});
describe('publish — a signed plugin cannot quietly go unsigned', () => {
let tmp: string;
let dir: string;
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { generateKeypair, loadPrivateKey, publicKeyBase64, verifyArtifact } from '../src/cli/sign.js';
import { retroSignVersions } from '../src/cli/retro-sign.js';
const V1_BYTES = Buffer.from('the exact bytes of the v1.0.0 release asset');
const sha = (b: Buffer) => createHash('sha256').update(b).digest('hex');
describe('retro-sign — first signed update signs the older versions too', () => {
let tmp: string;
let keyPath: string;
let publicKey: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'retro-sign-'));
keyPath = path.join(tmp, 'signing.key');
publicKey = generateKeypair(keyPath).publicKey;
vi.stubGlobal('fetch', async (url: string) => {
if (String(url).includes('v1.0.0')) return { ok: true, arrayBuffer: async () => V1_BYTES } as unknown as Response;
return { ok: false, status: 404 } as Response;
});
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
vi.unstubAllGlobals();
});
const entry = (v1: Partial<{ sha256: string; signature: string }> = {}) => ({
id: 'retro-plug',
authorPublicKey: publicKey,
versions: [
{ version: '1.1.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.1.0/plugin.zip', sha256: 'deadbeef', signature: 'already-signed' },
{ version: '1.0.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.0.0/plugin.zip', sha256: sha(V1_BYTES), ...v1 },
],
});
it('downloads, verifies, and signs each unsigned older version', async () => {
const e = entry();
const signed = await retroSignVersions(e, keyPath, () => {});
expect(signed).toEqual(['1.0.0']);
const sig = e.versions[1].signature!;
expect(verifyArtifact(V1_BYTES, sig, publicKey)).toBe(true);
expect(e.versions[0].signature).toBe('already-signed'); // untouched
});
it('REFUSES when the downloaded bytes no longer hash to the pinned sha256', async () => {
const e = entry({ sha256: 'not-the-real-hash' });
await expect(retroSignVersions(e, keyPath, () => {})).rejects.toThrow(/1\.0\.0.*sha256/is);
expect(e.versions[1].signature).toBeUndefined();
});
it('REFUSES a key that does not match the entry authorPublicKey', async () => {
const otherKey = path.join(tmp, 'other.key');
generateKeypair(otherKey);
await expect(retroSignVersions(entry(), otherKey, () => {})).rejects.toThrow(/key/i);
});
it('is a no-op for an unsigned entry or one with nothing to sign', async () => {
const unsignedEntry = { id: 'x', versions: [{ version: '1.0.0', downloadUrl: 'u', sha256: 'h' }] };
expect(await retroSignVersions(unsignedEntry, keyPath, () => {})).toEqual([]);
const fullySigned = entry({ signature: 'sig' });
expect(await retroSignVersions(fullySigned, keyPath, () => {})).toEqual([]);
});
it('names every version whose artifact could not be fetched', async () => {
const e = entry();
e.versions[1].downloadUrl = 'https://github.com/x/y/releases/download/v9.9.9/plugin.zip'; // 404s
await expect(retroSignVersions(e, keyPath, () => {})).rejects.toThrow(/1\.0\.0/);
});
it('key sanity: publicKeyBase64 round-trips through loadPrivateKey', () => {
expect(publicKeyBase64(loadPrivateKey(keyPath))).toBe(publicKey);
});
});
+110
View File
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createHash } from 'node:crypto';
const calls: Array<{ bin: string; args: string[] }> = [];
let failSync = false;
/** Seeded into the fake fork clone as the plugin's existing registry entry (update case). */
let existingEntry: unknown = null;
let lastCloneDir = '';
// submit's git()/gh() run execFileSync with encoding:'utf8', which returns STRINGS — the
// mock must too, or `.trim()` explodes on a Buffer.
vi.mock('node:child_process', () => ({
execFileSync: (bin: string, args: string[]) => {
calls.push({ bin, args });
if (bin === 'gh' && args[0] === 'api') return 'someone';
if (bin === 'gh' && args[0] === 'repo' && args[1] === 'sync' && failSync) throw new Error('fork has diverged');
if (bin === 'gh' && args[0] === 'repo' && args[1] === 'clone') {
lastCloneDir = args[3];
if (existingEntry !== null) {
const p = path.join(lastCloneDir, 'registry', 'plugins', `${(existingEntry as { id: string }).id}.json`);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(existingEntry, null, 2));
}
return '';
}
if (bin === 'gh' && args[0] === 'pr' && args[1] === 'create') return 'https://github.com/liketrek/TREK-Plugins/pull/999';
return '';
},
}));
const { submitEntry } = await import('../src/cli/submit.js');
const { generateKeypair } = await import('../src/cli/sign.js');
const { verifyArtifact } = await import('../src/cli/sign.js');
const V1_BYTES = Buffer.from('old release asset bytes');
const sha = (b: Buffer) => createHash('sha256').update(b).digest('hex');
describe('submit — fork sync and retro-signing', () => {
let tmp: string;
let keyPath: string;
let publicKey: string;
beforeEach(() => {
calls.length = 0;
failSync = false;
existingEntry = null;
lastCloneDir = '';
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'submit-'));
keyPath = path.join(tmp, 'signing.key');
publicKey = generateKeypair(keyPath).publicKey;
vi.stubGlobal('fetch', async (url: string) => {
if (String(url).includes('v1.0.0')) return { ok: true, arrayBuffer: async () => V1_BYTES } as unknown as Response;
return { ok: false, status: 404 } as Response;
});
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
vi.unstubAllGlobals();
});
const freshEntry = (signed = false) => ({
id: 'sync-plug',
name: 'Sync Plug',
...(signed ? { authorPublicKey: publicKey } : {}),
versions: [{
version: '1.1.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.1.0/plugin.zip',
sha256: 'deadbeef', ...(signed ? { signature: 'fresh-sig' } : {}),
}],
});
it('fast-forwards the fork from the registry before branching', async () => {
await submitEntry(freshEntry(), {});
const sync = calls.find((c) => c.bin === 'gh' && c.args[0] === 'repo' && c.args[1] === 'sync');
expect(sync?.args).toContain('someone/TREK-Plugins');
expect(sync?.args).toContain('--source');
expect(sync?.args).toContain('liketrek/TREK-Plugins');
});
it('a diverged fork warns but does not stop the submit', async () => {
failSync = true;
const { prUrl } = await submitEntry(freshEntry(), {});
expect(prUrl).toContain('/pull/999');
});
it('retro-signs unsigned older versions on the first signed update', async () => {
existingEntry = {
id: 'sync-plug', name: 'Sync Plug',
versions: [{ version: '1.0.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.0.0/plugin.zip', sha256: sha(V1_BYTES) }],
};
await submitEntry(freshEntry(true), { signKeyPath: keyPath, keep: true });
const written = JSON.parse(fs.readFileSync(path.join(lastCloneDir, 'registry', 'plugins', 'sync-plug.json'), 'utf8')) as {
versions: Array<{ version: string; signature?: string }>;
};
const v1 = written.versions.find((v) => v.version === '1.0.0')!;
expect(v1.signature).toBeDefined();
expect(verifyArtifact(V1_BYTES, v1.signature!, publicKey)).toBe(true);
fs.rmSync(lastCloneDir, { recursive: true, force: true });
});
it('refuses a first signed update when no key is available to retro-sign the older versions', async () => {
existingEntry = {
id: 'sync-plug', name: 'Sync Plug',
versions: [{ version: '1.0.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.0.0/plugin.zip', sha256: sha(V1_BYTES) }],
};
await expect(submitEntry(freshEntry(true), {})).rejects.toThrow(/--sign/);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// unrelease shells out to git + gh; drive those from here so we can assert what it deletes.
const calls: Array<{ bin: string; args: string[] }> = [];
let releaseExistsOnRemote = false;
let remoteTagOnOrigin = false;
let localTagExists = false;
vi.mock('node:child_process', () => ({
execFileSync: (bin: string, args: string[]) => {
calls.push({ bin, args });
if (bin === 'gh' && args[0] === 'release' && args[1] === 'view') {
if (!releaseExistsOnRemote) throw new Error('release not found');
return Buffer.from('');
}
if (bin === 'git' && args.includes('ls-remote')) {
return Buffer.from(remoteTagOnOrigin ? 'abc\trefs/tags/v1.0.0' : '');
}
if (bin === 'git' && args.includes('rev-parse')) {
if (!localTagExists) throw new Error('unknown revision');
return Buffer.from('abc');
}
return Buffer.from('');
},
}));
const { unrelease } = await import('../src/cli/unrelease.js');
/** The published registry index the immutability guard consults. */
let indexBody: unknown = { plugins: [] };
let indexFails = false;
describe('unrelease — delete a stranded tag + release safely', () => {
let tmp: string;
beforeEach(() => {
calls.length = 0;
releaseExistsOnRemote = false;
remoteTagOnOrigin = false;
localTagExists = false;
indexBody = { plugins: [] };
indexFails = false;
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unrelease-'));
fs.writeFileSync(path.join(tmp, 'trek-plugin.json'), JSON.stringify({ id: 'doomed-plug', version: '1.0.0' }));
vi.stubGlobal('fetch', async () => {
if (indexFails) throw new Error('offline');
return { ok: true, json: async () => indexBody } as Response;
});
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
vi.unstubAllGlobals();
});
const run = (yes?: boolean) => unrelease({
dir: tmp, tag: 'v1.0.0', repo: 'someone/trek-plugin-doomed-plug', yes, log: () => {},
});
it('REFUSES when that version is published in the registry — those artifacts are immutable', async () => {
indexBody = { plugins: [{ id: 'doomed-plug', versions: [{ version: '1.0.0' }] }] };
releaseExistsOnRemote = true;
await expect(run(true)).rejects.toThrow(/published in the registry/i);
expect(calls.some((c) => c.bin === 'gh' && c.args[1] === 'delete')).toBe(false);
});
it('deletes the release, remote tag, and local tag when they exist', async () => {
releaseExistsOnRemote = true;
remoteTagOnOrigin = true;
localTagExists = true;
const { deleted } = await run();
expect(deleted).toEqual(['release', 'remote tag', 'local tag']);
expect(calls.some((c) => c.bin === 'gh' && c.args[0] === 'release' && c.args[1] === 'delete' && c.args.includes('--yes'))).toBe(true);
expect(calls.some((c) => c.bin === 'git' && c.args.includes(':refs/tags/v1.0.0'))).toBe(true);
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag') && c.args.includes('-d'))).toBe(true);
});
it('reports what was already absent without failing', async () => {
const { deleted } = await run();
expect(deleted).toEqual([]);
});
it('when the registry cannot be checked, refuses without --yes and proceeds with it', async () => {
indexFails = true;
localTagExists = true;
await expect(run()).rejects.toThrow(/--yes/);
const { deleted } = await run(true);
expect(deleted).toEqual(['local tag']);
});
it('a different published version of the same plugin does not block this tag', async () => {
indexBody = { plugins: [{ id: 'doomed-plug', versions: [{ version: '0.9.0' }] }] };
localTagExists = true;
const { deleted } = await run();
expect(deleted).toEqual(['local tag']);
});
});
+6 -2
View File
@@ -10,6 +10,7 @@ Scope: the **`@trek/server`** workspace. See the repo-root `CLAUDE.md` for the m
npm run dev # scripts/dev.mjs: tsc -w then node --watch dist/index.js (server on :3001)
npm run build # scripts/build.mjs — see "emit-anyway" note below
npm run typecheck # tsc --noEmit (the real type gate; build does NOT fail on type errors)
npm run typecheck:tests # tsc over tests/ — CI runs this too; vitest green does NOT mean typed mocks compile
npm run lint # eslint --fix on src/apps/libs/test
npm run lint:check # eslint, no fix
npm run test # vitest run (all of tests/**)
@@ -18,6 +19,8 @@ npm run test:integration # tests/integration
npm run test:ws # tests/websocket
npm run test:e2e # tests/e2e (boots Nest modules against a temp in-memory SQLite)
npm run test:coverage # istanbul coverage; per-domain ratchet over src/nest/** (floor ≥80%)
npm run gen:plugin-facts # regenerate the plugin-protocol tables into plugin-sdk/ + shared/
npm run check:plugin-facts # CI gate — fails if the generated copies drifted from envelope.ts
```
Single test:
@@ -41,10 +44,11 @@ Nest owns everything: the strangler migration **completed 2026-08-10**, and the
- **`src/db/`** — `better-sqlite3` (WAL, FK on). `database.ts` boots and runs `schema.ts` → `migrations.ts` → `seeds.ts`. `NODE_ENV=test` ⇒ isolated `:memory:` DB per vitest worker; `TREK_DB_FILE` ⇒ explicit file DB (Playwright harness).
- **`src/websocket.ts`** — JWT-authed `ws` server on `/ws`, room-per-trip, heartbeat, per-connection rate limiting. Trip mutations forward `X-Socket-Id` so the originating client doesn't echo its own change.
- **`src/mcp/`** — process-wide MCP state (scopes, sessionManager, tuning knobs, rate-limit map, `nest-mcp-policy.ts` boot gate). The HTTP transport is `src/nest/mcp-transport/` (a `@Public` controller + service); the tools/resources/prompts live in decorator-driven `<domain>.mcp.ts` files under `src/nest/` — `src/mcp/tools.ts` is registry-attach-only. Every tool invocation writes an `mcp.tool_call` audit row (user, OAuth client, tool name, ip) via the nest-mcp `onInvoke` seam.
- **`src/nest-mcp/`** — the decorator/registry layer itself (`@McpController`/`@Tool`/`@Resource`/`@Prompt`, `McpRegistry`, `McpModule`, result helpers) that the `<domain>.mcp.ts` files build on. Formerly the standalone `@trek/nest-mcp` workspace, folded in 2026-08-22; see its `README.md` for the API and invariants (self-contained apart from a type-only `../mcp/scopes` import; no scope semantics of its own). Tests: `tests/unit/nest-mcp/`.
- **`src/nest/scheduling/`** — `CronRegistrarService`, the one way TREK schedules a cron (`@nestjs/schedule` + the `cron` package). Every job is a `*.job.ts` provider in its owning domain, registered from `onApplicationBootstrap`; the registrar refuses to schedule under `NODE_ENV=test` (the shared `buildApp()` harness must never tick — `tests/integration/scheduler-gate.test.ts` pins it) and stops everything on `nestApp.close()`. Never use a `@Cron` decorator — decorator jobs bypass the gate.
- **`src/nest/plugins/`** — sandboxed plugin runtime in three layers: `supervisor/` + `runtime/plugin-host-entry.ts` + `protocol/envelope.ts` (forked child per plugin, IPC envelopes, rate/RSS policing — **intentionally not Nest**, it's the security boundary); `host/rpc-host.ts` (enforcement: a method is registered at spawn only if the plugin holds the unlocking permission — registration = authorization); `host/plugin-host-deps.factory.ts` (dependency wiring — an `@Injectable()` factory injecting the domain services; the RPC surface is decorator-driven since 2026-08-09, with `PluginsModule` split into four and per-domain coverage gates) + `host/plugin-host-state.ts` (process-wide data-DB/budget maps, module-level on purpose). Install-time gates in `install/` (manifest, safe-extract, Ed25519 signature verify, egress). ⚠️ `runtime/egress-policy.ts` and `HOOK_PERMISSION` in `supervisor/plugin-supervisor.ts` are mirrored (egress-policy **byte-identical**) into `plugin-sdk/` and guarded by parity tests there — keep both sides in sync when touching either.
- **`src/nest/plugins/`** — sandboxed plugin runtime in three layers: `supervisor/` + `runtime/plugin-host-entry.ts` + `protocol/envelope.ts` (forked child per plugin, IPC envelopes, rate/RSS policing — **intentionally not Nest**, it's the security boundary); `host/rpc-host.ts` (enforcement: a method is registered at spawn only if the plugin holds the unlocking permission — registration = authorization); `host/plugin-host-deps.factory.ts` (dependency wiring — an `@Injectable()` factory injecting the domain services; the RPC surface is decorator-driven since 2026-08-09, with `PluginsModule` split into four and per-domain coverage gates) + `host/plugin-host-state.ts` (process-wide data-DB/budget maps, module-level on purpose). Install-time gates in `install/` (manifest, safe-extract, Ed25519 signature verify, egress). ⚠️ The protocol tables — permissions, RPC methods, hooks, and the core-event catalog — live in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts` for the event families) and are **generated** into `plugin-sdk/src/generated/host-facts.ts` and `shared/src/plugin-permissions.ts` by `npm run gen:plugin-facts`; `check:plugin-facts` is the CI gate, so run it after touching any of them and commit the regenerated files. `runtime/egress-policy.ts` is still a hand-kept **byte-identical** mirror into `plugin-sdk/`, guarded by parity tests there — keep both sides in sync when touching either.
## Direction (from the 2026 audit — `audit_report/TREK_audit_reports/`)
## Direction (post-migration guardrails, from the 2026 audit)
**Nest is the destination — and as of 2026-08-10 the code is there.** These rules now guard against regression rather than describe a migration in flight:
+19 -1
View File
@@ -21,12 +21,13 @@ import {
HOOK_PERMISSION, KNOWN_METHODS, KNOWN_PERMISSIONS, METHOD_PERMISSION,
EVENTS_PERMISSION, JOBS_PERMISSION, USER_DATA_PERMISSION, HTTP_OUTBOUND_PREFIX,
} from '../src/nest/plugins/protocol/envelope';
import { SNAPSHOT_GRANT, ENTITY_ID_KEYS } from '../src/plugin-event-sink';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const HEADER = [
'// GENERATED — do not edit by hand.',
'// Source: server/src/nest/plugins/protocol/envelope.ts',
'// Source: server/src/nest/plugins/protocol/envelope.ts + server/src/plugin-event-sink.ts',
'// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts',
'',
].join('\n');
@@ -43,6 +44,8 @@ const pairs = (o: Readonly<Record<string, string>>) =>
* source-breaking type change for any plugin author doing a dynamic lookup. Strictness
* belongs on the host side, where the data source lives.
*/
const EVENT_FAMILIES = [...new Set([...Object.keys(ENTITY_ID_KEYS), ...Object.keys(SNAPSHOT_GRANT)])].sort();
const SDK_FACTS = `${HEADER}
export const HOOK_PERMISSION: Readonly<Record<string, string>> = {
${pairs(HOOK_PERMISSION)}
@@ -64,6 +67,21 @@ export const USER_DATA_PERMISSION = '${USER_DATA_PERMISSION}';
export const EVENTS_PERMISSION = '${EVENTS_PERMISSION}';
export const JOBS_PERMISSION = '${JOBS_PERMISSION}';
export const HTTP_OUTBOUND_PREFIX = '${HTTP_OUTBOUND_PREFIX}';
/**
* Core-event catalog. Delivery names are the WebSocket broadcast names,
* \`<family>:<verb>\` (e.g. \`place:created\`). A subscribed plugin receives
* \`{event, tripId, entity?, entityId?, snapshot?}\`; \`snapshot\` is delivered only
* when the plugin also holds EVENT_SNAPSHOT_GRANT[family]. Delete/reorder/bulk
* events carry no snapshot.
*/
export const EVENT_FAMILIES: readonly string[] = [
${list(EVENT_FAMILIES)}
];
export const EVENT_SNAPSHOT_GRANT: Readonly<Record<string, string>> = {
${pairs(SNAPSHOT_GRANT)}
};
`;
const SHARED_FACTS = `${HEADER}
+12 -2
View File
@@ -1,5 +1,5 @@
import semver from 'semver';
import { isKnownPermission } from '../protocol/envelope';
import { isKnownPermission, PLUGIN_API_VERSION } from '../protocol/envelope';
import { isValidTrekRange, minTrekOf } from './host-compat';
import type { NotifEventType } from '../../notifications/notification-events';
@@ -220,11 +220,21 @@ export function parseManifest(raw: unknown, opts?: { requireTrek?: boolean }): P
: 'missing "trek" version range — declare the TREK versions this plugin supports, e.g. ">=3.2.0 <4.0.0"',
);
}
const rawApi = m.apiVersion;
if (rawApi !== undefined && (typeof rawApi !== 'number' || !Number.isInteger(rawApi) || rawApi < 1)) {
throw new ManifestError('apiVersion must be a positive integer');
}
const apiVersion = (rawApi as number | undefined) ?? 1;
if (opts?.requireTrek && apiVersion > PLUGIN_API_VERSION) {
throw new ManifestError(`plugin requires plugin-API v${apiVersion}; this TREK supports v${PLUGIN_API_VERSION}`);
}
return {
id,
name: str(m.name, 'name'),
version,
apiVersion: typeof m.apiVersion === 'number' ? m.apiVersion : 1,
apiVersion,
author: optStr(m.author),
description: optStr(m.description),
homepage: optStr(m.homepage),
@@ -33,7 +33,7 @@ import type { PluginDependency } from './install/manifest';
import type { VersionMismatch, PluginDepRow } from './dependencies';
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, enableOrder, findDependentsTransitive, DependencyCycleError } from './dependencies';
import { HTTP_OUTBOUND_PREFIX as HTTP_OUTBOUND } from './protocol/envelope';
import { HTTP_OUTBOUND_PREFIX as HTTP_OUTBOUND, PLUGIN_API_VERSION } from './protocol/envelope';
// Mirrors HOST_RE in install/manifest.ts: an exact hostname or a `*.`-prefixed wildcard
// with a real multi-label suffix. Rejects a bare `*`, a whole-TLD wildcard, a scheme and
@@ -85,7 +85,9 @@ export type PluginDependencyCode =
/** The plugin's declared TREK range doesn't admit the running host. */
| 'TREK_VERSION_INCOMPATIBLE'
/** The plugin never declared a range, so we can't know that it does. */
| 'TREK_VERSION_UNKNOWN';
| 'TREK_VERSION_UNKNOWN'
/** The plugin's manifest apiVersion is newer than this TREK's plugin-API surface. */
| 'API_VERSION_INCOMPATIBLE';
/**
* Thrown when a plugin can't activate because a required addon is disabled, a declared
@@ -478,11 +480,12 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
/**
* Read-only activation gate for one plugin — throws (without mutating) if it may
* not activate. Checks run most- to least-severe: TREK-version compatibility →
* permission re-consent → required addon disabled → missing/mismatched plugin dependency.
* plugin-API version compatibility → permission re-consent → required addon disabled →
* missing/mismatched plugin dependency.
*/
private assertActivatable(id: string, installed: Map<string, PluginDepRow>, consentWiden: boolean): void {
const row = this.db.prepare('SELECT permissions, granted_permissions, dependencies, trek_range FROM plugins WHERE id = ?').get(id) as
| { permissions: string; granted_permissions: string; dependencies: string | null; trek_range: string | null }
const row = this.db.prepare('SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?').get(id) as
| { permissions: string; granted_permissions: string; dependencies: string | null; trek_range: string | null; api_version: number | null }
| undefined;
if (!row) throw new Error(`plugin ${id} not found`);
@@ -505,6 +508,16 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
{ trekRange: row.trek_range, hostVersion: hostVersion() },
);
}
// Same reasoning as the TREK-version gate above: a plugin whose manifest apiVersion
// outpaces this TREK's plugin-API surface can never run correctly, so it must be
// refused before any consent dialog is offered.
const apiVersion = row.api_version ?? 1;
if (apiVersion > PLUGIN_API_VERSION) {
throw new PluginDependencyError(
`plugin requires plugin-API v${apiVersion}; this TREK supports v${PLUGIN_API_VERSION}`,
'API_VERSION_INCOMPATIBLE',
);
}
const declared = parseArray(row.permissions).filter(isKnownPermission);
const granted = parseArray(row.granted_permissions);
@@ -7,6 +7,14 @@
* or the websocket server. Keep it dependency-free.
*/
/**
* The plugin-API surface version (#plugins, M4) — bumped on any breaking change to the
* ctx methods / manifest shape a plugin author can rely on. Mirrored into every
* manifest as `apiVersion`; the install and activation gates refuse a plugin declaring
* a version newer than this one.
*/
export const PLUGIN_API_VERSION = 1;
export type PluginErrCode =
| 'PERMISSION_DENIED' // a real method the plugin was not granted
| 'UNKNOWN_METHOD' // not a method the host exposes at all
@@ -202,6 +210,10 @@ export const METHOD_PERMISSION = {
'packing.create': 'db:write:packing',
'packing.update': 'db:write:packing',
'packing.delete': 'db:write:packing',
// Intentional: bags are the write-side organizational structure of packing —
// a read-only consumer uses packing.list; only bag-managing (write) plugins
// need the bag tree. Moving this to db:read:packing would strip access from
// every installed write-only plugin and force consent re-prompts.
'packing.listBags': 'db:write:packing',
'packing.createBag': 'db:write:packing',
'packing.updateBag': 'db:write:packing',
@@ -9,7 +9,7 @@
*/
/** Mirrors the published package's constant — bumped on any breaking API change. */
export const PLUGIN_API_VERSION = 1 as const;
export { PLUGIN_API_VERSION } from '../protocol/envelope';
export interface PluginContext {
readonly id: string;
@@ -93,7 +93,12 @@ export interface PluginContext {
update(tripId: number, itemId: number, input: Record<string, unknown>): Promise<unknown>;
/** Delete a packing item. Needs 'db:write:packing' + 'packing_edit'. */
delete(tripId: number, itemId: number): Promise<{ deleted: boolean }>;
/** List/create/update/delete packing bags + set members (no privacy). Needs 'db:write:packing' + 'packing_edit'. */
/**
* List/create/update/delete packing bags + set members (no privacy). Bag methods need
* 'db:write:packing' + the acting user's 'packing_edit' trip permission — except this
* one: listBags is a read, but the envelope still gates it on the write scope
* (intentional — bags are the write-side structure; packing.list is the read surface).
*/
listBags(tripId: number): Promise<unknown[]>;
createBag(tripId: number, input: { name: string; color?: string }): Promise<unknown>;
updateBag(tripId: number, bagId: number, input: Record<string, unknown>): Promise<unknown>;
+1 -1
View File
@@ -37,7 +37,7 @@ export function emitPluginEvent(tripId: number, event: string, meta?: PluginEven
// from leaking: e.g. `budget:member-paid-updated` carries { itemId, userId } and we
// deliberately only read itemId, never userId. Families not listed (settlement/bag
// sub-entities, reorders, bulk position updates) yield no entityId, which is correct.
const ENTITY_ID_KEYS: Readonly<Record<string, readonly string[]>> = {
export const ENTITY_ID_KEYS: Readonly<Record<string, readonly string[]>> = {
place: ['placeId', 'place.id'],
day: ['dayId', 'day.id'],
reservation: ['reservationId', 'reservation.id'],
@@ -12,7 +12,7 @@ const { testDb } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec(`CREATE TABLE plugins (
id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, version TEXT, permissions TEXT DEFAULT '[]', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '',
id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, version TEXT, api_version INTEGER DEFAULT 1, permissions TEXT DEFAULT '[]', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '',
config TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', capabilities TEXT DEFAULT '{}', last_error TEXT, updated_at TEXT,
-- Activation refuses a plugin that declares no TREK range, so the fixtures default to a
-- satisfied one: these tests are about permissions/dependencies, and every row would
@@ -722,3 +722,39 @@ describe('TREK host-version gating', () => {
cleanup('both-wrong');
});
});
/**
* The activation gate also refuses a plugin whose manifest `apiVersion` is newer than
* what this TREK's plugin-API surface supports (`PLUGIN_API_VERSION`) — mirrors the
* TREK-version gate above, just for the plugin-API surface instead of the host version.
*/
describe('plugin-API version gating', () => {
const seedApi = (id: string, apiVersion: number) => {
fs.mkdirSync(path.join(codeRoot, id, 'server'), { recursive: true });
fs.writeFileSync(path.join(codeRoot, id, 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
testDb
.prepare(
"INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range, api_version) VALUES (?, 'inactive', 0, '[]', '[]', '{}', '>=3.0.0', ?)",
)
.run(id, apiVersion);
};
const cleanup = (...ids: string[]) => { for (const id of ids) testDb.prepare('DELETE FROM plugins WHERE id = ?').run(id); };
it('refuses to activate a plugin whose apiVersion this TREK does not support', async () => {
seedApi('future-api', 2);
const err = await createPluginRuntime(new DatabaseService(dbConn)).activate('future-api').catch((e) => e);
expect(err).toBeInstanceOf(PluginDependencyError);
expect(err).toMatchObject({ code: 'API_VERSION_INCOMPATIBLE' });
expect(err.message).toBe('plugin requires plugin-API v2; this TREK supports v1');
cleanup('future-api');
});
it('activates normally when apiVersion is within what this TREK supports', async () => {
seedApi('current-api', 1);
const rt = createPluginRuntime(new DatabaseService(dbConn));
await rt.activate('current-api');
expect(rt.isActive('current-api')).toBe(true);
await rt.deactivate('current-api');
cleanup('current-api');
});
});
@@ -824,6 +824,14 @@ describe('TREK-version gating on install', () => {
const bytes = makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', trek: undefined });
expect(() => svc.stageUpload(bytes)).toThrow(/missing "trek"/);
});
it('refuses an uploaded archive declaring apiVersion 2 (this TREK only speaks plugin-API v1), and writes nothing to the plugin code dir', () => {
// trek range is satisfiable on its own — parseManifest's apiVersion check must
// fire (and stop the upload) before assertHostCompatible is ever reached.
const bytes = makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', apiVersion: 2 });
expect(() => svc.stageUpload(bytes)).toThrow('plugin requires plugin-API v2; this TREK supports v1');
expect(fs.existsSync(path.join(codeRoot, 'my-upload'))).toBe(false);
});
});
it('installWithDependencies installs the version it RESOLVED, not versions[0]', async () => {
@@ -0,0 +1,15 @@
import { it, expect } from 'vitest';
import { SNAPSHOT_GRANT, ENTITY_ID_KEYS } from '../../../src/plugin-event-sink';
import { KNOWN_PERMISSIONS } from '../../../src/nest/plugins/protocol/envelope';
it('every snapshot grant is a known db:read permission', () => {
for (const p of Object.values(SNAPSHOT_GRANT)) {
expect(KNOWN_PERMISSIONS).toContain(p);
expect(p).toMatch(/^db:read:/);
}
});
it('every snapshot family has an id-key path', () => {
for (const family of Object.keys(SNAPSHOT_GRANT)) {
expect(Object.keys(ENTITY_ID_KEYS)).toContain(family);
}
});
@@ -6,6 +6,7 @@ import { describe, it, expect } from 'vitest';
import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest';
const base = { id: 'flight-tracker', name: 'Flight', version: '1.2.0', type: 'widget', apiVersion: 1 };
const withApi = (apiVersion: unknown) => ({ ...base, apiVersion, trek: '>=3.2.0 <4.0.0' });
describe('parseManifest', () => {
it('parses a valid manifest with defaults', () => {
@@ -99,6 +100,23 @@ describe('parseManifest', () => {
});
});
describe('apiVersion', () => {
it.each([[0], [-3], [1.5], ['1']])('rejects non-positive-integer %p', (v) => {
expect(() => parseManifest(withApi(v))).toThrow('apiVersion must be a positive integer');
});
it('defaults to 1 when absent', () => {
const { apiVersion: _omitted, ...noApi } = base;
expect(parseManifest(noApi).apiVersion).toBe(1);
});
it('tolerates a future apiVersion under discovery (no requireTrek)', () => {
expect(parseManifest(withApi(2)).apiVersion).toBe(2);
});
it('refuses a future apiVersion on install paths (requireTrek)', () => {
expect(() => parseManifest(withApi(2), { requireTrek: true }))
.toThrow('plugin requires plugin-API v2; this TREK supports v1');
});
});
describe('parseManifest capabilities', () => {
it('parses a hero widget slot and defaults to sidebar', () => {
const hero = parseManifest({ ...base, capabilities: { widget: { slot: 'hero', title: 'T' } } });
+61
View File
@@ -0,0 +1,61 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Scope: the **`@trek/shared`** workspace. See the repo-root `CLAUDE.md` for the monorepo picture, and `server/CLAUDE.md` / `client/CLAUDE.md` for the consumers. This package is the **single source of truth** for API contracts (Zod) and all i18n strings; both server and client import from it.
## Commands (run from `shared/`)
```bash
npm run build # tsdown → dist/ (CJS + ESM + .d.ts). REQUIRED before server/client typecheck or run
npm run build:watch # tsdown --watch --no-clean (the root `npm run dev` runs this)
npm run typecheck # tsc --noEmit
npm run lint # eslint --fix src/**/*.ts
npm run test # vitest run
npm run i18n:parity # audit locale drift, exits 0 (prints a per-locale report)
npm run i18n:parity:strict # CI gate — exits 1 on any drift
```
Single test:
```bash
npx vitest run src/sanitize/sanitize.spec.ts
npx vitest run src/i18n/i18n-parity.spec.ts
npx vitest run -t "rejects extra keys"
```
**Always rebuild after changing a schema or locale** — the consumers import the compiled `dist/`, not `src/`. If a server/client typecheck fails to see a new export, you probably forgot `npm run build` here.
## Build & exports
`tsdown.config.ts` emits **three kinds of entry**: the root barrel (`src/index.ts`), the i18n metadata barrel (`src/i18n/index.ts`), and **one entry per locale** (`src/i18n/*/index.ts`) so each locale becomes its own lazy-loadable chunk (the client dynamic-imports them). `zod` is never bundled (`deps.neverBundle`). Both CJS and ESM are emitted with `.d.ts`. The `exports` map in `package.json` exposes `@trek/shared` (contracts), `@trek/shared/i18n` (metadata/types), and `@trek/shared/i18n/<locale>` (a single locale).
## Contracts (`src/<domain>/<domain>.schema.ts`)
One folder per domain; each exports Zod schemas plus their inferred types (`export type X = z.infer<typeof xSchema>`). The root barrel `src/index.ts` re-exports every domain. Domain-agnostic primitives live in `src/common/` (`primitives.schema.ts`, `pagination.schema.ts`).
**`src/plugin-permissions.ts` is machine-generated** (by `server/scripts/gen-plugin-facts.ts` from the server's plugin protocol) — never hand-edit it; regenerate with `npm run gen:plugin-facts` from `server/`, where `check:plugin-facts` is the CI drift gate.
A route is considered "migrated" only once its contract lives here and **both** sides import the inferred types. Key convention seen across schemas (e.g. `weather/weather.schema.ts`): **schemas mirror the exact legacy wire behavior** — strings stay strings if the legacy route never coerced them, optional fields reflect partial response subsets, and **bespoke 4xx error strings are reproduced in the server controller, not derived from the schema**, so the error body stays byte-identical. Don't "tidy up" a schema to be stricter than the contract it documents.
## Direction (from the 2026 audit)
The parity rule above governs **existing** routes; it is not a license to mint new debt. For anything new:
- **The contract describes the wire, not the storage engine.** New boolean fields are `z.boolean()` — convert SQLite 0/1 at the service boundary, never add a new `z.union([z.boolean(), z.number()])`. New id fields use `idSchema`/`idParamSchema`, never `number | string` unions. A field's request and response types must agree (a write→read round trip must not change a type).
- **Use the shared primitives** in `src/common/` (`idSchema`, `nonEmptyString`, pagination, …) instead of re-declaring bare `z.number()`/`z.string()` per domain. Prefer narrowing an existing open object over adding a new `z.record(...)`/passthrough body — "any object" gives zero drift protection.
- **Add a schema, add a spec** — especially for lenient parsers fed by untrusted or LLM input.
- **This package stays lean and isomorphic**: zero imports from client/server, no `node:` APIs in `src/`, and effectively no new runtime dependencies (long-term the contract layer approaches `zod`-only; i18n is a separable concern with its own release cadence — don't couple new contract code to it).
- **Never hand-copy a locale list.** `SUPPORTED_LANGUAGES` in `languages.ts` is the one registry — derive everything (barrels, loaders, spec maps) from it; adding a locale should be one edit, not four.
- **`shared` is the monorepo's strict-TypeScript template** (`strict` + `noUncheckedIndexedAccess`). Hold that line: no new `any`, no new suppressions.
## i18n (`src/i18n/`)
- **`languages.ts`** — `SUPPORTED_LANGUAGES` (the `{ value, label, locale }` tuples) is the canonical language registry and the source of `SupportedLanguageCode`. Adding a language starts here.
- **`<locale>/`** — one folder per language, with one file per UI domain (`common.ts`, `admin.ts`, `budget.ts`, …) plus an `index.ts` barrel. Every file exports a flat map of **dot-namespaced string keys** typed as `TranslationStrings` (e.g. `'common.save': 'Save'`). The runtime `t(key)` only resolves these top-level keys.
- **`en/` is canonical.** Every other locale must have the **identical file set and identical top-level keys** — this is enforced by `i18n:parity:strict` (and `i18n-parity.spec.ts`) and is a CI gate (`chore: enforce i18n parity`). When you add or rename a key, update **every** locale, or parity fails.
- `types.ts` defines `TranslationStrings`; `index.ts` re-exports types + language metadata (not the strings themselves — those load per-locale).
## Sanitization (`src/sanitize/sanitize.ts`)
`isomorphic-dompurify` (same code in browser and Node, tree-shakes so the client bundle doesn't pull jsdom). Minimal inline-only allow-list today; it guards the few surfaces that interpolate user strings into markup (currently the Journey banner) and is the designated home if rich-text/Markdown ships later.
+1 -1
View File
@@ -1,5 +1,5 @@
// GENERATED — do not edit by hand.
// Source: server/src/nest/plugins/protocol/envelope.ts
// Source: server/src/nest/plugins/protocol/envelope.ts + server/src/plugin-event-sink.ts
// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts
/** Widened on purpose so client callers can .includes() with a plain string. */
+6 -3
View File
@@ -128,11 +128,14 @@ with core events.
**Needs:** `events:subscribe`
Handlers run with no user and get the event name + tripId only (never the payload).
Handlers run with no user and get `{ event, tripId, entity?, entityId?, snapshot? }` —
the `snapshot` (a whitelisted field view of the changed entity) is delivered only when
your plugin also holds that family's `db:read:*` grant; deletes/bulk/reorder events
carry none.
```js
events: [
{ on: 'file:created', async handler({ tripId }, ctx) {
{ on: 'file:created', async handler({ tripId, entityId }, ctx) {
await notifySlack(`New file on trip ${tripId}`) // needs http:outbound
} },
]
@@ -599,7 +602,7 @@ module.exports = {
## Write across every subsystem
Beyond places/days/itinerary, TREK 3.3.0 opens create/update/delete on almost every trip subsystem. Each is membership-checked against the acting user and needs its `db:write:*` scope **plus** the app's edit permission — declare only what you use.
Beyond places/days/itinerary, TREK opens create/update/delete on almost every trip subsystem. Each is membership-checked against the acting user and needs its `db:write:*` scope **plus** the app's edit permission — declare only what you use.
```js
// Bookings & lodging — needs 'reservation_edit' / 'day_edit'
+39 -21
View File
@@ -50,9 +50,11 @@ my-plugin/
.gitattributes # LF everywhere, so the packed artifact's sha256 is reproducible
```
The scaffold declares `"trek": ">=3.4.0 <4.0.0"` — the `ctx` namespaces it is written
against only exist from 3.4.0, and claiming an older floor would let the plugin install
on a host where they are `undefined`.
The scaffold declares `"trek": ">=4.0.0 <5.0.0"` — the host surface it is written
against is the TREK 4 surface, and claiming an older floor would let the plugin
install on a host where parts of it are missing. Picking `oauth:client` in the wizard
also scaffolds the five instance-scope settings the OAuth broker reads (see
[Host-brokered OAuth](#host-brokered-oauth-ctxoauth)).
What you get **runs and packs immediately**, but it does **not** pass `validate` yet:
the README is still a template and there is no screenshot. That is deliberate — those
@@ -137,11 +139,9 @@ permissions still requires explicit re-consent).
## The plugin types
- **integration** — background logic (jobs, routes) with no UI of its own. Most
provider hooks are **live** (placeDetailProvider, warningProvider,
tableContributor, mapMarkerProvider, mapLayerProvider, pdfSectionProvider,
atlasLayerProvider, journalEntryProvider); only `photoProvider` / `calendarSource` are declared in the
SDK but **not yet wired into the host** — see [Provider hooks](#provider-hooks).
- **integration** — background logic (jobs, routes) with no UI of its own. Every
provider hook is **live** — from placeDetailProvider and warningProvider through
photoProvider (Memories) and calendarSource — see [Provider hooks](#provider-hooks).
- **page** — adds a nav entry that opens a full-page sandboxed iframe.
- **widget** — adds a card to the dashboard (`sidebar` slot), a hero-bar overlay
(`hero` slot), a panel inside the trip planner's **place-detail** view
@@ -251,7 +251,7 @@ declaration for readers — the manifest parser does not consume it.
| `ctx.places` | `create(tripId, fields)` / `update(tripId, placeId, fields)` / `delete(tripId, placeId)` | `db:write:places` |
| `ctx.days` | `create(tripId, {date?, notes?})` / `update(tripId, dayId, {notes?, title?})` / `delete(tripId, dayId)` | `db:write:days` |
| `ctx.itinerary` | `assign(tripId, dayId, placeId, notes?)` / `unassign(tripId, assignmentId)` — place↔day | `db:write:itinerary` |
| `ctx.meta` | `get` / `set` / `list` / `delete` your **own** namespaced data on a `trip`/`place`/`day` (enrich core entities without forking the schema) | `db:meta` |
| `ctx.meta` | `get` / `set` / `list` / `delete` your **own** namespaced data on a `trip`/`place`/`day`/`reservation`/`accommodation` (enrich core entities without forking the schema) | `db:meta` |
| `ctx.packing` | `list(tripId)` — a trip's packing items (membership-checked, respects private-item visibility) | `db:read:packing` |
| `ctx.files` | `list(tripId)` — a trip's files, trash excluded (membership-checked) | `db:read:files` |
| `ctx.files.getContent` | `getContent(tripId, fileId)` → `{ name, mimetype, size, content_base64 }` — a file's bytes as base64 (10MB cap; trashed files refused) | `db:read:files:content` |
@@ -681,7 +681,7 @@ simply ignore the flag, so declaring it never breaks an install.
A plugin can act as an OAuth *client* of a third-party service **without ever handling the secrets**. The **host** runs the entire flow — authorize → callback → token exchange → refresh — with PKCE (S256) and a `state` check, and holds the tokens. The plugin only triggers "connect" and reads a **short-lived access token** at runtime.
**Setup.** Declare the provider as `scope: "instance"` settings the admin fills in: `oauth_authorize_url`, `oauth_token_url`, `oauth_scopes` (optional), plus the two `secret: true` fields `oauth_client_id` and `oauth_client_secret`. (A settings field may also carry an `oauth: { initPath, callbackPath }` block.)
**Setup.** Declare the provider as `scope: "instance"` settings the admin fills in: `oauth_authorize_url`, `oauth_token_url`, `oauth_scopes` (optional), plus `oauth_client_id` and the `secret: true` `oauth_client_secret`. (A settings field may also carry an `oauth: { initPath, callbackPath }` block.) `trek-plugin create` scaffolds all five for you when you pick the `oauth:client` permission.
**Connecting.** A user connects under **Settings → Plugins → Connect**, which sends them to the provider's authorize page. The callback returns to `…/api/plugin-oauth/<id>/callback`; the host verifies the `state` (single-use, 10-minute TTL, bound to that user — CSRF defence), exchanges the code and stores the tokens **per user, encrypted at rest**.
@@ -750,7 +750,7 @@ module.exports = definePlugin({
| `pdfSectionProvider.getSections(tripId, ctx)` → `PdfSection[]` | `hook:pdf-section-provider` | **live** — text-only sections appended to the trip PDF export. Each is `{title, paragraphs?, table?}`; the host escapes and lays everything out itself (no markup ever reaches the document), caps counts (≤5 sections/plugin, ≤20 paragraphs, ≤8 headers, ≤50 rows) + lengths (title 120, paragraph 2000, header 60, cell 200) and clips rows to the header width. Also `GET /api/pdf-sections/:tripId` |
| `atlasLayerProvider.getLayers(ctx)` → `AtlasLayer[]` | `hook:atlas-layer-provider` | **live** — country tint layers drawn over the Atlas world map (wishlists, advisories, …). **User-scoped**: the host binds the acting user, the hook takes no target parameter. Each layer is `{id, name?, countries: [{code, tone?, label?}]}`; codes must be ISO-3166 alpha-2 (uppercase-coerced), tone is enum-whitelisted, counts capped (≤3 layers/plugin, ≤300 countries/layer). Declarative only — plugin JS never runs on the map canvas. Also `GET /api/atlas-layers` |
| `journalEntryProvider.getRows(entryId, ctx)` → `{ label, value?, url? }[]` | `hook:journal-entry-provider` | **live** — extra rows rendered under a journal entry card (needs the Journey addon; the entry's journey is access-checked like the journal detail routes). Same hardening as place details plus server-side normalization: ≤12 rows/plugin, label ≤60, value ≤200, url http/https/mailto-only. Also `GET /api/journal-entry-rows/:entryId` |
| `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=…` |
| `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 (≤4 badges per trip, ≤240 total per 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) |
@@ -962,8 +962,10 @@ Delivery is fire-and-forget on a short timeout, so a slow subscriber never block
core write. Because there's no user, trip reads (`ctx.trips.*`) are refused inside a
handler — beyond the snapshot, use the plugin's own `ctx.db`, `ctx.ws.*`, or an
outbound call. A plugin's own `plugin:*` broadcasts are never delivered back, so
handlers can't loop. Common events: `place:*`, `day:*`, `assignment:*`, `budget:*`,
`file:*`, `accommodation:*`.
handlers can't loop. Event names follow `<family>:<verb>` (`place:created`,
`day:updated`, `file:*`, `assignment:*`, `budget:*`, `accommodation:*`, …); the SDK
exports the authoritative family list and the snapshot-grant mapping as
`EVENT_FAMILIES` and `EVENT_SNAPSHOT_GRANT`.
## Dependencies
@@ -1105,6 +1107,13 @@ integration test for real SQL. To test inter-plugin calls, pass
`pluginExports: { koffi: { convert: (args) => … } }` and assert on `mock.emitted`
for anything your plugin publishes via `ctx.events.emit`.
The mock also enforces the host's operational limits, so a test can prove your
plugin backs off correctly: the **daily AI and notification budgets** (defaults
200 / 100 per day; configure with `aiPerDay` / `notifyPerDay` — `0` disables the
broker entirely, the exhausted call fails with the host's exact error) and the
**`ctx.meta` quotas** (≤256-char keys, ≤64 KB serialized values, ≤100 keys per
entity).
### Driving your plugin's handlers
`createMockHost` also gives you `run(def)` — the other half of a test. Where the
@@ -1182,9 +1191,9 @@ what your manifest declares.
| `id` | string, **required** | lowercase slug, `^[a-z][a-z0-9-]{2,39}$` (3–40 chars). Must match the directory name. |
| `name` | string, **required** | display name; also the page nav label. |
| `version` | string, **required** | semver (`1.2.3`, optional pre-release). |
| `apiVersion` | number | plugin API version (currently `1`; `PLUGIN_API_VERSION`). Defaults to `1`. |
| `apiVersion` | number | plugin API version (currently `1`; `PLUGIN_API_VERSION`). Defaults to `1`; must be a positive integer, and a version newer than the host supports is refused at install and won't activate (`API_VERSION_INCOMPATIBLE`). |
| `type` | string, **required** | `integration` \| `page` \| `widget` \| `trip-page`. |
| `trek` | string, **required** | the TREK versions this plugin supports, as a semver **range**: `">=3.4.0 <4.0.0"` (what the scaffold writes). Must be *satisfiable* (`">=4.0.0 <3.0.0"` parses but nothing can satisfy it — rejected). **Enforced at install and at activation** (see below); it is copied verbatim onto the registry entry, which is the entry's only compatibility field. Keep it **bounded** — an unbounded range claims support for TREK versions that do not exist yet. |
| `trek` | string, **required** | the TREK versions this plugin supports, as a semver **range**: `">=4.0.0 <5.0.0"` (what the scaffold writes). Must be *satisfiable* (`">=4.0.0 <3.0.0"` parses but nothing can satisfy it — rejected). **Enforced at install and at activation** (see below); it is copied verbatim onto the registry entry, which is the entry's only compatibility field. Keep it **bounded** — an unbounded range claims support for TREK versions that do not exist yet. |
| `author` | string | shown in the store. |
| `description` | string | one-line summary for the store. |
| `icon` | string | lucide-react icon name (default `Blocks`); used for the page nav entry. |
@@ -1232,9 +1241,10 @@ build arg defaults to the literal `dev` — has nothing to compare a range again
the check is skipped and an unversioned build installs anything. Plugins should still
guard optional `ctx.*` namespaces.
**Permissions** — the commonly-used core subset below; the **full list of ~50**
**Permissions** — the commonly-used core subset below; the **full list of 63**
(all read/write scopes, the notify/ai/oauth brokers, every provider hook) lives in
**[[Plugin Permissions|Plugin-Permissions]]**. Unknown values are rejected at activation.
**[[Plugin Permissions|Plugin-Permissions]]**. Unknown values are rejected at install
(and by `trek-plugin validate` and registry CI, which check against the same list).
| Permission | Grants |
|---|---|
@@ -1270,7 +1280,8 @@ guard optional `ctx.*` namespaces.
| `hook:journal-entry-provider` | `hooks.journalEntryProvider` — extra rows on a journal entry card |
| `hook:trip-card-provider` | `hooks.tripCardProvider` — small badges on the dashboard trip cards (`getCards(tripIds, ctx)` → `{ tripId, label, value?, icon?, tone?, url? }[]`; host bounds every field + access-checks each tripId) |
| `hook:user-data` | `deleteUserData` / `exportUserData` handlers — honour GDPR erasure (durable, retried) and data-export for a deleted/requesting user (userless; own db only) |
| `hook:photo-provider` / `hook:calendar-source` | reserved (see [Provider hooks](#provider-hooks)) |
| `hook:photo-provider` | `hooks.photoProvider` — a photo source for Memories, aggregated at `GET /api/plugin-photos/search` (see [Provider hooks](#provider-hooks)) |
| `hook:calendar-source` | `hooks.calendarSource` — calendar events for the signed-in user, aggregated at `GET /api/plugin-calendar` (see [Provider hooks](#provider-hooks)) |
> There is **no `ws:broadcast:*`** — use `ws:broadcast:trip` and/or
> `ws:broadcast:user` explicitly.
@@ -1286,7 +1297,7 @@ guard optional `ctx.*` namespaces.
| `required` | boolean. |
| `secret` | boolean — encrypted at rest, decrypted only into `ctx.config`. |
| `placeholder`, `hint` | form hints. |
| `options` | `[{ value, label }]` for select inputs. |
| `options` | `[{ value, label }]` for select inputs; bare strings/numbers are accepted and coerced, but an option needs a non-empty `value`. |
| `oauth` | `{ initPath, callbackPath }` for OAuth flows. |
**Page nav:** the host builds a page plugin's nav entry from the top-level `name`
@@ -1362,12 +1373,19 @@ trek-plugin release [dir] --repo owner/name --tag vX.Y.Z [--sign] [--merge entry
# Fork the registry, write registry/plugins/<id>.json, open the PR. Needs `gh`.
trek-plugin submit [dir] --repo owner/name --tag vX.Y.Z [--registry o/n] [--draft]
# Delete a stranded release + remote tag + local tag in one go. Checks the published
# registry index first and REFUSES a version that is actually published (immutable);
# --yes consents when the index can't be reached.
trek-plugin unrelease vX.Y.Z [dir] --repo owner/name [--yes]
```
`publish` chains those last steps in the order that matters: **check → pack → release →
preflight → submit**. The local gates run *before* anything is tagged or released,
because a GitHub release is effectively immutable — the registry pins its sha256. See
[[Publishing a Plugin|Plugin-Publishing]].
because a GitHub release is effectively immutable — the registry pins its sha256 —
and a failure *after* the release is cut rolls back everything that run created (the
release, both tags), so the same tag is free to re-run against (`--keep-release`
opts out). See [[Publishing a Plugin|Plugin-Publishing]].
## Registry & publishing
+8 -7
View File
@@ -45,14 +45,14 @@ ungranted capability is physically unreachable**, not just disallowed. See
| `db:write:collections` | Create/edit collections, save places, copy to a trip via `ctx.collections` | The service enforces the acting user's **per-collection role** (owner/admin/editor) itself. Needs the Collections addon. |
| `db:write:files` | Attach files + manage links via `ctx.files` (`create`, `createLink`, `update`, `softDelete`) | Trip access **+** the app's `file_upload`/`file_edit`/`file_delete` rights respectively. Content arrives as base64 (10MB cap); the extension is validated against the central blocklist before touching disk; link targets must live on the same trip. |
| `db:write:collab` | Post notes, polls and chat messages via `ctx.collab` | Trip access **+** `collab_edit`; needs the Collab addon. Broadcasts the same `collab:*` events as the app. |
| `db:write:members` | Add a user to a trip via `ctx.trips.addMember` | **Grants trip access** — deliberately its own permission behind the app's `member_manage` right (default: trip owner only), never bundled with a lower-risk write. The acting user is recorded as the inviter. |
| `db:write:members` | Add/remove a user on a trip via `ctx.trips.addMember` / `removeMember` | **Grants (and revokes) trip access** — deliberately its own permission behind the app's `member_manage` right (default: trip owner only), never bundled with a lower-risk write. The acting user is recorded as the inviter; the owner can never be removed. |
| `db:write:todos` | Create/edit/delete a trip's to-dos via `ctx.todos` | Trip access **+** `packing_edit` (the app gates to-dos with the same right). |
| `db:meta` | Store the plugin's **own** private key/value data on a trip/place/day/**reservation**/**accommodation** via `ctx.meta` | Namespaced per plugin (a plugin only sees its own rows). Reads need trip **access**; **writes** additionally need the entity's edit permission (`trip_edit`/`place_edit`/`day_edit`; reservations use `reservation_edit`, accommodations use `day_edit`). The natural home for an external-id mapping (AirTrail/calendar/booking-import sync) without forking the schema. Quotas: ≤256-char key, ≤64 KB value, ≤100 keys per entity. Purged on uninstall-with-delete-data. |
| `ws:broadcast:trip` | Push a real-time event to a trip room via `ctx.ws.broadcastToTrip` | Event types are force-namespaced `plugin:<id>:<event>` — a plugin can't forge a core event. |
| `ws:broadcast:user` | Push a real-time event to a user's connections | Same namespacing. |
| `notify:send` | Send a persisted notification (bell inbox + email/ntfy/webhook fan-out) via `ctx.notify.send` | Host-mediated: the host owns recipient resolution, channel fan-out and per-user preferences. Recipients are **forced** to the acting user (`scope:'user'`, `targetId` = the acting user) or a trip they belong to (`scope:'trip'`); `scope:'admin'` is refused. The plugin supplies only a plain-text title/body (caps 200/1000) + an optional in-app `link` (must be a relative `/…` path — open-redirect-safe). No arbitrary recipient, no impersonation. |
| `notify:send` | Send a persisted notification (bell inbox + email/ntfy/webhook fan-out) via `ctx.notify.send` | Host-mediated: the host owns recipient resolution, channel fan-out and per-user preferences. Recipients are **forced** to the acting user (`scope:'user'`, `targetId` = the acting user) or a trip they belong to (`scope:'trip'`); `scope:'admin'` is refused. The plugin supplies only a plain-text title/body (caps 200/1000) + an optional in-app `link` (must be a relative `/…` path — open-redirect-safe). No arbitrary recipient, no impersonation. Budgeted: 100 sends/day per plugin, resetting at UTC midnight. |
| `oauth:client` | Become an OAuth *client* of a third-party service via `ctx.oauth.getAccessToken()` | Host-brokered: the host runs the whole flow (authorize→callback→token→refresh) with **PKCE + state** and **holds the tokens** — client secret + refresh token never leave the host. Provider config (`oauth_authorize_url`/`oauth_token_url`/`oauth_scopes` + the secrets `oauth_client_id`/`oauth_client_secret`) is the plugin's **admin-owned instance settings**; endpoints must be **https** (SSRF backstop). Each user connects under **Settings → Plugins**; tokens are per-user + encrypted at rest. The plugin only ever gets a **short-lived access token** for the acting user. |
| `ai:invoke` | Run the admin/user-configured LLM via `ctx.ai.complete` / `ctx.ai.extract` | Host-mediated: the host holds the (encrypted) credential and runs the call under the acting user's resolved provider — the plugin never sees a key. Refused when no provider is configured; prompt/text capped at 20 000 chars. Output is **DATA** — `complete` returns `{ text }`, `extract` returns `{ results }` for your JSON schema — and is never auto-written, so prompt-injection can't reach a write without your own gated call. |
| `ai:invoke` | Run the admin/user-configured LLM via `ctx.ai.complete` / `ctx.ai.extract` | Host-mediated: the host holds the (encrypted) credential and runs the call under the acting user's resolved provider — the plugin never sees a key. Refused when no provider is configured; prompt/text capped at 20 000 chars. Output is **DATA** — `complete` returns `{ text }`, `extract` returns `{ results }` for your JSON schema — and is never auto-written, so prompt-injection can't reach a write without your own gated call. Budgeted: 200 calls/day per plugin, resetting at UTC midnight. |
| `events:subscribe` | React to core activity via `events: [{ on, handler }]` on the plugin definition | The handler gets the **event name + tripId + a `{ entity, entityId }` hint**, plus a **whitelisted `snapshot` of the changed entity when the plugin also holds the family's `db:read:*` grant** (per-plugin filtered at deliver time — no grant, no fields). It runs with **no user** (like a job), so nothing beyond the snapshot is readable. The whitelist never carries user ids, private packing items (#858) or secrets; deletes/bulk/reorder events carry no snapshot, a non-entity id (e.g. a userId) never surfaces. Fire-and-forget on a short timeout; `plugin:*` re-broadcasts are never delivered back. |
| `jobs:run` | Run the plugin's declared background `jobs` on their cron schedule **and** its runtime timers via `ctx.scheduler` (`at`/`in`/`every`/`cancel`) | **Opt-in.** Scheduled work runs with **no user** (its trip reads are refused), so a job can only touch its own `ctx.db` and declared egress. Invalid cron expressions are skipped; jobs stop when the plugin is deactivated. `ctx.scheduler` tasks are persisted (survive restarts), capped at 100/plugin with an 8 KB payload and a 60 s minimum recurring interval, and removed on uninstall. |
| `hook:photo-provider` | Register as a photo provider in Memories | Implement the `PhotoProvider` interface. |
@@ -69,7 +69,7 @@ ungranted capability is physically unreachable**, not just disallowed. See
| `hook:pdf-section-provider` | Append text-only **sections** to the trip PDF export via the `hooks.pdfSectionProvider` provider hook | Implement `PdfSectionProvider` in `hooks` — returns `{title, paragraphs?, table?}[]` of plain strings the export escapes and lays out itself; no markup ever reaches the document. The host caps the section/paragraph/header/row counts (≤5/≤20/≤8/≤50) and every string length, and clips table rows to the header width; a failing provider is skipped. Exposed at `GET /api/pdf-sections/:tripId`. |
| `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: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 (≤4 badges per trip, ≤240 total per 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[]` — unless the manifest sets `operatorEgress: true`. Only a **per-host** `http:outbound:<host>` actually opens a host at runtime — see below. |
@@ -160,11 +160,12 @@ flow is in [[Publishing a Plugin|Plugin-Publishing]].
| `trek-plugin dev [dir]` | Run the plugin locally with a real request loop + hot reload — no full TREK. The injected `ctx` enforces your granted permissions, `db:own` is a real SQLite file, routes serve under `/api/<path>`, and page/widget UI at `/ui`. |
| `trek-plugin validate [dir]` | Manifest + layout checks: parses the manifest with the same rules as install, requires a `README.md` (warns if it has no screenshot or still holds template placeholders) and a built `server/index.js`, and warns if the directory name ≠ the plugin id. This is a **subset** of registry CI — CI additionally verifies the release tag/commit, the artifact's sha256, and the README over the network. A local pass predicts a CI pass. |
| `trek-plugin preflight --repo <o/n> --tag <vX>` | Runs the **full** registry CI checks locally over the network (tag→commit, manifest parity, artifact sha256/size, native scan, README quality gate) against your pushed release — so you catch a CI failure before opening the PR. |
| `trek-plugin submit --repo <o/n> --tag <vX>` | Opens the registry PR for you: forks TREK-Plugins, branches off current main, writes/merges `registry/plugins/<id>.json`, pushes, and creates the PR. Requires `gh`. |
| `trek-plugin publish --repo <o/n> --tag <vX>` | **The one-command release**: pack → tag + GitHub release → preflight → open the registry PR. Stops before submitting if preflight fails. Add `--sign` to sign it. Requires `git` + `gh`. |
| `trek-plugin submit --repo <o/n> --tag <vX>` | Opens the registry PR for you: forks TREK-Plugins, fast-forwards the fork, branches off the registry's current main, writes/merges `registry/plugins/<id>.json`, pushes, and creates the PR. Requires `gh`. |
| `trek-plugin publish --repo <o/n> --tag <vX>` | **The one-command release**: pack → tag + GitHub release → preflight → open the registry PR. A failure after the release is cut rolls back the tag and release that run created, so the same tag can be re-used (`--keep-release` opts out). Add `--sign` to sign it. Requires `git` + `gh`. |
| `trek-plugin unrelease <vX> --repo <o/n>` | Deletes a stranded GitHub release + remote tag + local tag. Refuses a version that is published in the registry (its artifact is immutable); `--yes` consents when the registry index can't be checked. |
| `trek-plugin keygen` / `sign` | `keygen` creates an Ed25519 signing key; `sign` (or `--sign` on `entry`/`release`/`submit`) signs the artifact and fills `authorPublicKey` + `signature` so TREK pins your identity (TOFU). |
| `trek-plugin pack [dir] [--out plugin.zip] [--json]` | Validates, then builds `plugin.zip` in the installer's exact layout (`trek-plugin.json`, `README.md`, `LICENSE`, `package.json` at the root; `server/` and `client/` recursed) and prints its **sha256 + byte size**. Skips `node_modules`, `.git`, `.ts` and `.map` files, and **refuses native binaries** (`.node`, `binding.gyp`, `prebuilds/`) and over-size archives, same as the installer. **`docs/` is intentionally NOT shipped** — the store fetches your screenshot from `docs/screenshot.png` in the repo. |
| `trek-plugin entry --repo <owner/name> --tag <vX.Y.Z> [--zip plugin.zip] [--merge entry.json] [--out file]` | Emits the ready-to-PR registry entry: `commitSha` (resolved from the tag), `downloadUrl`, `sha256` + `size` (from the packed zip), and `minTrekVersion` (derived from the manifest's `trek` range, e.g. `>=3.2.0 <4.0.0` → `3.2.0`). `--merge` prepends this version onto an existing `registry/plugins/<id>.json` for an update, keeping versions newest-first. |
| `trek-plugin entry --repo <owner/name> --tag <vX.Y.Z> [--zip plugin.zip] [--merge entry.json] [--out file]` | Emits the ready-to-PR registry entry: `commitSha` (resolved from the tag), `downloadUrl`, `sha256` + `size` (from the packed zip), and the manifest's **`trek` range verbatim** — the entry's only compatibility field. `--merge` prepends this version onto an existing `registry/plugins/<id>.json` for an update, keeping versions newest-first (registry CI enforces that ordering). |
| `trek-plugin release [dir] --repo <owner/name> --tag <vX.Y.Z>` | The one-shot: `pack` → `gh release create` (uploads the zip) → print the registry `entry`. Requires the `gh` CLI authenticated. |
### Registry policy
+35 -10
View File
@@ -29,8 +29,20 @@ The order is the point. A GitHub release is effectively immutable: the registry
its sha256, so overwriting the bytes breaks the checksum for everyone who already
installed that version. So the local gates run **first** — if one fails, nothing is
packed, tagged, pushed or released, and you can fix it and re-run against the same
version. (Before this, the checks ran *after* the release was cut, and an author who
failed them had burned their `v1.0.0` tag on an unwritten README.)
version.
And when a **later** step fails — a preflight or PR problem after the release is
cut — `publish` rolls back exactly what that run created: the GitHub release, the
pushed tag, and the local tag, so the same tag is free to re-run against once you
have fixed the problem. Anything that existed before the run is never touched, and
`--keep-release` opts out (the error then prints the manual cleanup commands). A
leftover tag from an earlier failed run — one with no release that no longer points
at `HEAD` — is recognised as debris and **moved to your current commit** rather than
silently releasing the stale one. And for a stranded state you already have,
`trek-plugin unrelease <vX.Y.Z> --repo you/repo` deletes the release, the remote
tag, and the local tag in one go — after checking the published registry index and
**refusing, with no override, to touch a version that is actually published** (its
artifact is immutable; ship a new version instead).
It prints the PR URL at the end. In a terminal it **offers to sign** the release (and
creates the key if you have none); scripts and CI are never prompted and pass `--sign`.
@@ -90,6 +102,9 @@ Between them they now catch, **offline**, nearly everything the registry rejects
- an `egress[]` host with no matching `http:outbound:<host>` permission — TREK builds
the network allow-list and the iframe CSP from those permissions only and never reads
`egress[]`, so such a host is silently unreachable at runtime
- a permission TREK doesn't know — the SDK and registry CI check against the same
63-entry list the host enforces, so a typo'd permission fails here instead of at
install on every instance
Only **four** gates genuinely need the network, and they all need the release to exist:
that the tag resolves to the commit the entry pins, that the released artifact downloads
@@ -218,7 +233,9 @@ npx trek-plugin-sdk submit --repo you/trek-plugin-flight-tracker --tag v1.0.0
```
It forks [TREK-Plugins](https://github.com/liketrek/TREK-Plugins) (once),
branches off the current `main`, writes (or, for an update, merges into)
**fast-forwards your fork from the registry** (a diverged fork warns with the
`gh repo sync --force` command to reset it, and the submit continues), branches off
the registry's current `main`, writes (or, for an update, merges into)
`registry/plugins/<id>.json`, pushes, and opens the PR — printing its URL. Add
`--draft` for a draft PR, `--registry <owner/name>` for a mirror. (Requires `gh`,
authenticated.)
@@ -244,16 +261,19 @@ changed `registry/plugins/*.json`. Nearly every rule below is a pure function of
you have tagged anything — you should never learn any of this from CI.
**Entry** (`validate-entry.mjs`): valid schema · `id` matches the filename and
the slug pattern `^[a-z][a-z0-9-]{2,39}$` · your `id` is bound to your GitHub
the slug pattern `^[a-z][a-z0-9-]{2,39}$` and is not one of the reserved ids
`registry`/`install`/`rescan` (they collide with admin API route segments — CI and
the install loader both refuse them) · your `id` is bound to your GitHub
owner on first registration, so nobody can repoint it later (owner changes need a
maintainer override) · homoglyph/mixed-script name check · the release tag exists
maintainer override) · homoglyph/mixed-script name check · `versions[]` sorted
newest-first · the release tag exists
and resolves to `commitSha` · manifest parity at that commit (`id`, `version`,
`type`, `apiVersion`, and `nativeModules` must not be `true`) · **the downloaded
`type`, `apiVersion`, and `nativeModules` must not be `true`) · every declared
permission is on TREK's known-permission list (or a valid `http:outbound:<host>`) ·
**the downloaded
artifact's SHA-256 matches the pin** and its size is within bounds · **no native
binaries** in the archive · `egress[]` present (and no bare `*`) when
`http:outbound` is declared. Any unique slug is fine **except `registry`,
`install` and `rescan`**, which the install loader refuses (they collide with
admin API route segments).
`http:outbound` is declared.
**README** (`check-readme.mjs`, fetched at the pinned commit): must exist at the
repo root, contain the sections **What it does / Screenshots / Permissions /
@@ -330,7 +350,12 @@ next to its `sha256`:
Signing is **opt-in**, and it is a one-way door you may walk through **late**. An
entry without `authorPublicKey`/`signature` installs on `sha256` alone; **going
unsigned → signed later breaks nobody**, because nothing is pinned until a signed
version installs — adding a key at v1.4.0 is a real option, not a lost cause. What
version installs — adding a key at v1.4.0 is a real option, not a lost cause. The
registry does require every version signed once your key appears in the entry, and
the SDK handles that for you: your first signed update **retro-signs the older
versions** (each pinned artifact is downloaded, verified against its `sha256`, and
signed with the same key — a byte mismatch refuses, since that artifact no longer
matches its pin). What
you can never do is go back: once a plugin has shipped signed, an *unsigned* update
is refused (`SIGNATURE_MISSING`) on every instance that already has it, and a key
*rotation* needs a registry maintainer override (`allow-key-change`) plus an admin
+1 -1
View File
@@ -287,7 +287,7 @@ registry entry, so you never hand-compute a SHA-256 or hand-write registry JSON:
|---|---|
| `trek-plugin validate [dir]` | Runs the manifest + layout checks locally (a subset of registry CI, which additionally verifies the release, the artifact SHA-256, and the README over the network). |
| `trek-plugin pack [dir] [--out plugin.zip] [--json]` | Builds `plugin.zip` in the installer's exact layout and prints its SHA-256 + byte size. Refuses native binaries; `docs/` is intentionally not shipped (the store fetches the screenshot from your repo). |
| `trek-plugin entry --repo <o/n> --tag <vX> [--zip z] [--merge entry.json] [--out f]` | Emits the registry entry — `commitSha`, `downloadUrl`, `sha256`, `size` and `minTrekVersion` (derived from the manifest `trek` range) all filled in. `--merge` prepends the new version onto an existing entry for updates. |
| `trek-plugin entry --repo <o/n> --tag <vX> [--zip z] [--merge entry.json] [--out f]` | Emits the registry entry — `commitSha`, `downloadUrl`, `sha256`, `size` and the manifest's `trek` range (verbatim) all filled in. `--merge` prepends the new version onto an existing entry for updates. |
| `trek-plugin release [dir] --repo <o/n> --tag <vX>` | The one-shot: `pack` → create the GitHub release → print the entry. |
Run them via `npx trek-plugin-sdk …`. See [[Plugin Development|Plugin-Development]]