diff --git a/CLAUDE.md b/CLAUDE.md index 5864762e9..09b59e87d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=`, not `npm -w `**. +Run from the repo root unless noted. **Use `npm run ... --workspace=`, not `npm -w `**. ```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//`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up. - **i18n locale files live in `shared/src/i18n//`**, 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. diff --git a/client/CLAUDE.md b/client/CLAUDE.md new file mode 100644 index 000000000..57337de94 --- /dev/null +++ b/client/CLAUDE.md @@ -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()` hook** under `src/pages//`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `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/.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()` 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 `` 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/')`** 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//` (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` / ``; 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`). diff --git a/plugin-sdk/CLAUDE.md b/plugin-sdk/CLAUDE.md new file mode 100644 index 000000000..257c2599d --- /dev/null +++ b/plugin-sdk/CLAUDE.md @@ -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 `` 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. diff --git a/plugin-sdk/README.md b/plugin-sdk/README.md index 222cd163c..cefa007e4 100644 --- a/plugin-sdk/README.md +++ b/plugin-sdk/README.md @@ -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 --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 ` — 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 [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 diff --git a/plugin-sdk/package-lock.json b/plugin-sdk/package-lock.json index d9aac5e6f..22129add8 100644 --- a/plugin-sdk/package-lock.json +++ b/plugin-sdk/package-lock.json @@ -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", diff --git a/plugin-sdk/package.json b/plugin-sdk/package.json index ff3f14104..11ea5a9ed 100644 --- a/plugin-sdk/package.json +++ b/plugin-sdk/package.json @@ -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", diff --git a/plugin-sdk/src/cli/checks/offline.ts b/plugin-sdk/src/cli/checks/offline.ts index e518085a3..f3c160402 100644 --- a/plugin-sdk/src/cli/checks/offline.ts +++ b/plugin-sdk/src/cli/checks/offline.ts @@ -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); }, diff --git a/plugin-sdk/src/cli/create.ts b/plugin-sdk/src/cli/create.ts index 410ab693b..0302894d8 100644 --- a/plugin-sdk/src/cli/create.ts +++ b/plugin-sdk/src/cli/create.ts @@ -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'))); diff --git a/plugin-sdk/src/cli/entry.ts b/plugin-sdk/src/cli/entry.ts index 068d2b35f..0db5bc104 100644 --- a/plugin-sdk/src/cli/entry.ts +++ b/plugin-sdk/src/cli/entry.ts @@ -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); diff --git a/plugin-sdk/src/cli/help.ts b/plugin-sdk/src/cli/help.ts index f37283cc5..3e0d374e3 100644 --- a/plugin-sdk/src/cli/help.ts +++ b/plugin-sdk/src/cli/help.ts @@ -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 --tag [--sign]', + usage: 'trek-plugin publish [dir] --repo --tag [--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 [dir] --repo [--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 { diff --git a/plugin-sdk/src/cli/menu.ts b/plugin-sdk/src/cli/menu.ts index d60d13743..901dbbe4d 100644 --- a/plugin-sdk/src/cli/menu.ts +++ b/plugin-sdk/src/cli/menu.ts @@ -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: '' }, ]; diff --git a/plugin-sdk/src/cli/publish.ts b/plugin-sdk/src/cli/publish.ts index 3632c8b38..807872c4c 100644 --- a/plugin-sdk/src/cli/publish.ts +++ b/plugin-sdk/src/cli/publish.ts @@ -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 }; } diff --git a/plugin-sdk/src/cli/retro-sign.ts b/plugin-sdk/src/cli/retro-sign.ts new file mode 100644 index 000000000..10492d954 --- /dev/null +++ b/plugin-sdk/src/cli/retro-sign.ts @@ -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 { + 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(); + 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); +} diff --git a/plugin-sdk/src/cli/submit.ts b/plugin-sdk/src/cli/submit.ts index ffca8cd51..41c90e5da 100644 --- a/plugin-sdk/src/cli/submit.ts +++ b/plugin-sdk/src/cli/submit.ts @@ -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'); diff --git a/plugin-sdk/src/cli/trek-plugin.ts b/plugin-sdk/src/cli/trek-plugin.ts index 971b420ff..0ae8f2067 100644 --- a/plugin-sdk/src/cli/trek-plugin.ts +++ b/plugin-sdk/src/cli/trek-plugin.ts @@ -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 = { 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 { + 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 : trek-plugin unrelease vX.Y.Z --repo '); + 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); diff --git a/plugin-sdk/src/cli/ui.ts b/plugin-sdk/src/cli/ui.ts index f8a0955dd..08400859a 100644 --- a/plugin-sdk/src/cli/ui.ts +++ b/plugin-sdk/src/cli/ui.ts @@ -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) ──── diff --git a/plugin-sdk/src/cli/unrelease.ts b/plugin-sdk/src/cli/unrelease.ts new file mode 100644 index 000000000..d0111ba29 --- /dev/null +++ b/plugin-sdk/src/cli/unrelease.ts @@ -0,0 +1,110 @@ +/** + * trek-plugin unrelease — 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 { + 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 }; +} diff --git a/plugin-sdk/src/generated/host-facts.ts b/plugin-sdk/src/generated/host-facts.ts index cc8a46976..48db7e5eb 100644 --- a/plugin-sdk/src/generated/host-facts.ts +++ b/plugin-sdk/src/generated/host-facts.ts @@ -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> = { @@ -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, + * `:` (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> = { + 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', +}; diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index 5c30157be..a2d79997f 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -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>; @@ -32,6 +37,9 @@ export interface PluginContext { settings: { get(key: string): Promise; }; + /** 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(sql: string, ...args: unknown[]): Promise; 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; - getPlaces(tripId: number, asUserId?: number): Promise; + // `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; + getPlaces(tripId: number, /** @deprecated ignored by the host */ asUserId?: number): Promise; /** Hydrated like the REST list: each row carries `endpoints`, `day_positions` + the day/place joins. */ - getReservations(tripId: number, asUserId?: number): Promise; + getReservations(tripId: number, /** @deprecated ignored by the host */ asUserId?: number): Promise; /** The trip's days with their `assignments` + `notes_items` (the planner GET's shape). Needs `db:read:trips`. */ getDays(tripId: number): Promise; /** 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): Promise; /** 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; createBag(tripId: number, input: { name: string; color?: string }): Promise; updateBag(tripId: number, bagId: number, input: Record): Promise; @@ -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[] }>; @@ -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; }; @@ -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; set(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number, key: string, value: unknown): Promise; @@ -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; } @@ -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; } /** 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; } /** 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; } @@ -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; } @@ -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; } @@ -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; } @@ -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; } @@ -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; } @@ -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 `:` (e.g. `place:created`, `day:updated`, `file:created`) + * where `` 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 }, ctx: PluginContext): Promise | 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'; diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts index c7099b3bb..c0bae80dd 100644 --- a/plugin-sdk/src/manifest.ts +++ b/plugin-sdk/src/manifest.ts @@ -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; + 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(); + 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)[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). diff --git a/plugin-sdk/src/mock-host.ts b/plugin-sdk/src/mock-host.ts index 5d20b0ab9..b79f79164 100644 --- a/plugin-sdk/src/mock-host.ts +++ b/plugin-sdk/src/mock-host.ts @@ -107,6 +107,18 @@ export interface MockHostOptions { aiResults?: Record[]; /** 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 | undefined, what: string): R const PLACE_STR_LIMITS: Record = { name: 200, description: 2000, address: 500, notes: 2000 }; const TRIP_STR_LIMITS: Record = { 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 = {}; 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) { diff --git a/plugin-sdk/test/cli.test.ts b/plugin-sdk/test/cli.test.ts index adc2dfabd..8000a37a8 100644 --- a/plugin-sdk/test/cli.test.ts +++ b/plugin-sdk/test/cli.test.ts @@ -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); + } + }); +}); diff --git a/plugin-sdk/test/manifest-roundtrip.test.ts b/plugin-sdk/test/manifest-roundtrip.test.ts new file mode 100644 index 000000000..f9fa9cd56 --- /dev/null +++ b/plugin-sdk/test/manifest-roundtrip.test.ts @@ -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(); + }); +}); diff --git a/plugin-sdk/test/manifest-settings.test.ts b/plugin-sdk/test/manifest-settings.test.ts new file mode 100644 index 000000000..d6b287a44 --- /dev/null +++ b/plugin-sdk/test/manifest-settings.test.ts @@ -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); + }); +}); diff --git a/plugin-sdk/test/mock-limits.test.ts b/plugin-sdk/test/mock-limits.test.ts new file mode 100644 index 000000000..44c0792d0 --- /dev/null +++ b/plugin-sdk/test/mock-limits.test.ts @@ -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)', + ); + }); +}); diff --git a/plugin-sdk/test/permissions-parity.test.ts b/plugin-sdk/test/permissions-parity.test.ts index c01d5754d..04d3e30ce 100644 --- a/plugin-sdk/test/permissions-parity.test.ts +++ b/plugin-sdk/test/permissions-parity.test.ts @@ -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); + } + }); +}); diff --git a/plugin-sdk/test/publish.test.ts b/plugin-sdk/test/publish.test.ts index 758383aab..a2a629319 100644 --- a/plugin-sdk/test/publish.test.ts +++ b/plugin-sdk/test/publish.test.ts @@ -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 ^{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 ` and `git tag -f ` 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; diff --git a/plugin-sdk/test/retro-sign.test.ts b/plugin-sdk/test/retro-sign.test.ts new file mode 100644 index 000000000..42504ab22 --- /dev/null +++ b/plugin-sdk/test/retro-sign.test.ts @@ -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); + }); +}); diff --git a/plugin-sdk/test/submit.test.ts b/plugin-sdk/test/submit.test.ts new file mode 100644 index 000000000..8a8ad68e6 --- /dev/null +++ b/plugin-sdk/test/submit.test.ts @@ -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/); + }); +}); diff --git a/plugin-sdk/test/unrelease.test.ts b/plugin-sdk/test/unrelease.test.ts new file mode 100644 index 000000000..d7258e65d --- /dev/null +++ b/plugin-sdk/test/unrelease.test.ts @@ -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']); + }); +}); diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 35da3276b..e4786d994 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -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 `.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 `.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: diff --git a/server/scripts/gen-plugin-facts.ts b/server/scripts/gen-plugin-facts.ts index 0cdc0da22..eccb32a33 100644 --- a/server/scripts/gen-plugin-facts.ts +++ b/server/scripts/gen-plugin-facts.ts @@ -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>) => * 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> = { ${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, + * \`:\` (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> = { +${pairs(SNAPSHOT_GRANT)} +}; `; const SHARED_FACTS = `${HEADER} diff --git a/server/src/nest/plugins/install/manifest.ts b/server/src/nest/plugins/install/manifest.ts index c6450ed4c..7b0872130 100644 --- a/server/src/nest/plugins/install/manifest.ts +++ b/server/src/nest/plugins/install/manifest.ts @@ -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), diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 49c77708f..561860651 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -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, 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); diff --git a/server/src/nest/plugins/protocol/envelope.ts b/server/src/nest/plugins/protocol/envelope.ts index 1e920579b..54a084541 100644 --- a/server/src/nest/plugins/protocol/envelope.ts +++ b/server/src/nest/plugins/protocol/envelope.ts @@ -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', diff --git a/server/src/nest/plugins/runtime/plugin-sdk.ts b/server/src/nest/plugins/runtime/plugin-sdk.ts index 0510541f6..bc8ce59ba 100644 --- a/server/src/nest/plugins/runtime/plugin-sdk.ts +++ b/server/src/nest/plugins/runtime/plugin-sdk.ts @@ -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): Promise; /** 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; createBag(tripId: number, input: { name: string; color?: string }): Promise; updateBag(tripId: number, bagId: number, input: Record): Promise; diff --git a/server/src/plugin-event-sink.ts b/server/src/plugin-event-sink.ts index b66f80934..98006edf2 100644 --- a/server/src/plugin-event-sink.ts +++ b/server/src/plugin-event-sink.ts @@ -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> = { +export const ENTITY_ID_KEYS: Readonly> = { place: ['placeId', 'place.id'], day: ['dayId', 'day.id'], reservation: ['reservationId', 'reservation.id'], diff --git a/server/tests/integration/plugins/plugin-runtime.test.ts b/server/tests/integration/plugins/plugin-runtime.test.ts index 2ef14e2f3..2c7fdf329 100644 --- a/server/tests/integration/plugins/plugin-runtime.test.ts +++ b/server/tests/integration/plugins/plugin-runtime.test.ts @@ -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'); + }); +}); diff --git a/server/tests/integration/plugins/registry.test.ts b/server/tests/integration/plugins/registry.test.ts index 8215ef00f..51df63d98 100644 --- a/server/tests/integration/plugins/registry.test.ts +++ b/server/tests/integration/plugins/registry.test.ts @@ -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 () => { diff --git a/server/tests/unit/plugins/event-facts.test.ts b/server/tests/unit/plugins/event-facts.test.ts new file mode 100644 index 000000000..765a23df3 --- /dev/null +++ b/server/tests/unit/plugins/event-facts.test.ts @@ -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); + } +}); diff --git a/server/tests/unit/plugins/manifest.test.ts b/server/tests/unit/plugins/manifest.test.ts index 8a8c6d45f..3d41acf6e 100644 --- a/server/tests/unit/plugins/manifest.test.ts +++ b/server/tests/unit/plugins/manifest.test.ts @@ -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' } } }); diff --git a/shared/CLAUDE.md b/shared/CLAUDE.md new file mode 100644 index 000000000..bb2e19e14 --- /dev/null +++ b/shared/CLAUDE.md @@ -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/` (a single locale). + +## Contracts (`src//.schema.ts`) + +One folder per domain; each exports Zod schemas plus their inferred types (`export type X = z.infer`). 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. +- **`/`** — 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. diff --git a/shared/src/plugin-permissions.ts b/shared/src/plugin-permissions.ts index 3004701a9..752daca74 100644 --- a/shared/src/plugin-permissions.ts +++ b/shared/src/plugin-permissions.ts @@ -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. */ diff --git a/wiki/Plugin-Cookbook.md b/wiki/Plugin-Cookbook.md index 4f125815b..790244b1f 100644 --- a/wiki/Plugin-Cookbook.md +++ b/wiki/Plugin-Cookbook.md @@ -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' diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index 327f18a87..7d08b6b4e 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -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//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 `:` (`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/.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 diff --git a/wiki/Plugin-Permissions.md b/wiki/Plugin-Permissions.md index 1be61ea56..57f89919a 100644 --- a/wiki/Plugin-Permissions.md +++ b/wiki/Plugin-Permissions.md @@ -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::` — 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:` | Make outbound network requests | **Requires** a non-empty `egress[]` — unless the manifest sets `operatorEgress: true`. Only a **per-host** `http:outbound:` 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/`, 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 --tag ` | 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 --tag ` | Opens the registry PR for you: forks TREK-Plugins, branches off current main, writes/merges `registry/plugins/.json`, pushes, and creates the PR. Requires `gh`. | -| `trek-plugin publish --repo --tag ` | **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 --tag ` | Opens the registry PR for you: forks TREK-Plugins, fast-forwards the fork, branches off the registry's current main, writes/merges `registry/plugins/.json`, pushes, and creates the PR. Requires `gh`. | +| `trek-plugin publish --repo --tag ` | **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 --repo ` | 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 --tag [--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/.json` for an update, keeping versions newest-first. | +| `trek-plugin entry --repo --tag [--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/.json` for an update, keeping versions newest-first (registry CI enforces that ordering). | | `trek-plugin release [dir] --repo --tag ` | The one-shot: `pack` → `gh release create` (uploads the zip) → print the registry `entry`. Requires the `gh` CLI authenticated. | ### Registry policy diff --git a/wiki/Plugin-Publishing.md b/wiki/Plugin-Publishing.md index 279957568..4f5300520 100644 --- a/wiki/Plugin-Publishing.md +++ b/wiki/Plugin-Publishing.md @@ -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 --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:` 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/.json`, pushes, and opens the PR — printing its URL. Add `--draft` for a draft PR, `--registry ` 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:`) · +**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 diff --git a/wiki/Plugins.md b/wiki/Plugins.md index 6bce900fa..e8a1cbf16 100644 --- a/wiki/Plugins.md +++ b/wiki/Plugins.md @@ -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 --tag [--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 --tag [--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 --tag ` | The one-shot: `pack` → create the GitHub release → print the entry. | Run them via `npx trek-plugin-sdk …`. See [[Plugin Development|Plugin-Development]]