mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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`).
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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,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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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')));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) ────
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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' } } });
|
||||
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user