diff --git a/client/src/components/Planner/PlacesSidebar.test.tsx b/client/src/components/Planner/PlacesSidebar.test.tsx
index 9c1089119..0c3325aaa 100644
--- a/client/src/components/Planner/PlacesSidebar.test.tsx
+++ b/client/src/components/Planner/PlacesSidebar.test.tsx
@@ -572,4 +572,36 @@ describe('Google Maps list import', () => {
);
});
});
+
+});
+
+// #1432: a tablet is a touch device at a desktop width. Before the fix, isTouch didn't
+// exist and drag was gated on width alone, so on an iPad the rows stayed draggable and a
+// scroll swipe started an HTML5 drag, which raised the drop-to-import overlay instead of
+// scrolling. These cases pin the desktop-width + coarse-pointer combination.
+describe('touch device at desktop width (#1432)', () => {
+ const touchProps = { ...defaultProps, isMobile: false, isTouch: true };
+
+ it('FE-PLANNER-SIDEBAR-044: place rows are not draggable', () => {
+ const place = buildPlace({ id: 7, name: 'Tablet Place' });
+ render(
);
+ const placeRow = screen.getByText('Tablet Place').closest('div[draggable]')!;
+ expect(placeRow.getAttribute('draggable')).toBe('false');
+ });
+
+ it('FE-PLANNER-SIDEBAR-045: dragging over the sidebar does not raise the drop-to-import overlay', () => {
+ const place = buildPlace({ id: 7, name: 'Tablet Place' });
+ const { container } = render(
);
+ fireEvent.dragEnter(container.firstChild as HTMLElement);
+ expect(screen.queryByText('Drop to import')).not.toBeInTheDocument();
+ });
+
+ it('FE-PLANNER-SIDEBAR-046: a mouse-driven desktop keeps drag and the drop-to-import overlay', () => {
+ const place = buildPlace({ id: 7, name: 'Desktop Place' });
+ const { container } = render(
);
+ const placeRow = screen.getByText('Desktop Place').closest('div[draggable]')!;
+ expect(placeRow.getAttribute('draggable')).toBe('true');
+ fireEvent.dragEnter(container.firstChild as HTMLElement);
+ expect(screen.getByText('Drop to import')).toBeInTheDocument();
+ });
});
diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx
index cefe35382..5cf637627 100644
--- a/client/src/components/Planner/PlacesSidebar.tsx
+++ b/client/src/components/Planner/PlacesSidebar.tsx
@@ -17,19 +17,20 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
sidebarDragOver, handleSidebarDragEnter, handleSidebarDragOver, handleSidebarDragLeave, handleSidebarDrop,
selectMode, filtered, t, dayPickerPlace, listImportOpen,
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo,
- ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
+ ctxMenu, isMobile, isTouch, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen,
collectionsEnabled, saveToListOpen, setSaveToListOpen,
} = S
+ const dragDisabled = isMobile || isTouch
return (
- {!isMobile && sidebarDragOver &&
}
+ {!dragDisabled && sidebarDragOver &&
}
{/* Kopfbereich */}
diff --git a/client/src/components/Planner/PlacesSidebarList.tsx b/client/src/components/Planner/PlacesSidebarList.tsx
index 9b2d75546..c268775e4 100644
--- a/client/src/components/Planner/PlacesSidebarList.tsx
+++ b/client/src/components/Planner/PlacesSidebarList.tsx
@@ -7,7 +7,7 @@ export function PlacesList(S: SidebarState) {
const {
filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace,
categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId,
- isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow, tripId,
+ isMobile, isTouch, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow, tripId,
} = S
// Plugin-contributed columns/actions for the places view, keyed by place id (#plugins).
const contribFor = usePluginViewContributions('places', tripId)
@@ -43,6 +43,7 @@ export function PlacesList(S: SidebarState) {
selectedDayId={selectedDayId}
canEditPlaces={canEditPlaces}
isMobile={isMobile}
+ isTouch={Boolean(isTouch)}
t={t}
onPlaceClick={onPlaceClick}
onContextMenu={openContextMenu}
diff --git a/client/src/components/Planner/PlacesSidebarRow.tsx b/client/src/components/Planner/PlacesSidebarRow.tsx
index 0801b838e..88eea516a 100644
--- a/client/src/components/Planner/PlacesSidebarRow.tsx
+++ b/client/src/components/Planner/PlacesSidebarRow.tsx
@@ -15,6 +15,8 @@ interface MemoPlaceRowProps {
selectedDayId: number | null
canEditPlaces: boolean
isMobile: boolean
+ /** Primary pointer is coarse — HTML5 drag would swallow the scroll gesture (#1432). */
+ isTouch: boolean
t: (key: string, params?: Record
) => string
onPlaceClick: (id: number | null) => void
onContextMenu: (e: React.MouseEvent, place: Place) => void
@@ -26,19 +28,20 @@ interface MemoPlaceRowProps {
export const MemoPlaceRow = React.memo(function MemoPlaceRow({
place, category: cat, isSelected, isPlanned, inDay, isChecked,
- selectMode, selectedDayId, canEditPlaces, isMobile, t,
+ selectMode, selectedDayId, canEditPlaces, isMobile, isTouch, t,
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow,
}: MemoPlaceRowProps) {
const hasGeometry = Boolean(place.route_geometry)
+ const dragDisabled = isMobile || isTouch
return (
registerPlaceRow(place.id, element)}
aria-selected={isSelected}
data-place-id={place.id}
- draggable={!selectMode && !isMobile}
+ draggable={!selectMode && !dragDisabled}
onDragStart={e => {
- if (isMobile) { e.preventDefault(); return }
+ if (dragDisabled) { e.preventDefault(); return }
e.dataTransfer.setData('placeId', String(place.id))
e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(place.id) }
@@ -56,7 +59,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 14px 9px 16px',
- cursor: selectMode || isMobile ? 'pointer' : 'grab',
+ cursor: selectMode || dragDisabled ? 'pointer' : 'grab',
background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent',
borderBottom: '1px solid var(--border-faint)',
transition: 'background 0.1s',
diff --git a/client/src/components/Planner/usePlacesSidebar.ts b/client/src/components/Planner/usePlacesSidebar.ts
index c317d930d..8d773a4e9 100644
--- a/client/src/components/Planner/usePlacesSidebar.ts
+++ b/client/src/components/Planner/usePlacesSidebar.ts
@@ -31,6 +31,8 @@ export interface PlacesSidebarProps {
onBulkChangeCategory?: (ids: number[], categoryId: number | null) => void
days: Day[]
isMobile: boolean
+ /** Primary pointer is coarse — HTML5 drag would swallow the scroll gesture (#1432). */
+ isTouch?: boolean
onCategoryFilterChange?: (categoryIds: Set) => void
onPlacesFilterChange?: (filter: string) => void
pushUndo?: (label: string, undoFn: () => Promise | void) => void
diff --git a/client/src/hooks/useIsTouch.test.ts b/client/src/hooks/useIsTouch.test.ts
new file mode 100644
index 000000000..511cc3379
--- /dev/null
+++ b/client/src/hooks/useIsTouch.test.ts
@@ -0,0 +1,46 @@
+import { describe, it, expect, afterEach, vi } from 'vitest'
+import { renderHook, act } from '@testing-library/react'
+import { useIsTouch } from './useIsTouch'
+
+type Listener = (e: MediaQueryListEvent) => void
+
+/** Fake matchMedia that matches only `(pointer: coarse)` and can fire a change. */
+function mockPointer(coarse: boolean) {
+ const listeners: Listener[] = []
+ vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({
+ matches: query === '(pointer: coarse)' ? coarse : false,
+ media: query,
+ addEventListener: (_: string, l: Listener) => listeners.push(l),
+ removeEventListener: (_: string, l: Listener) => {
+ const i = listeners.indexOf(l)
+ if (i >= 0) listeners.splice(i, 1)
+ },
+ }) as unknown as MediaQueryList)
+ return { fire: (matches: boolean) => listeners.forEach(l => l({ matches } as MediaQueryListEvent)) }
+}
+
+describe('useIsTouch', () => {
+ afterEach(() => vi.restoreAllMocks())
+
+ // A tablet is a coarse-pointer device at a desktop width, so a width check misses it —
+ // which is what left the places list undraggable-but-unscrollable on iPad (#1432).
+ it('is true when the primary pointer is coarse, regardless of viewport width', () => {
+ mockPointer(true)
+ const { result } = renderHook(() => useIsTouch())
+ expect(result.current).toBe(true)
+ })
+
+ it('is false on a mouse-driven desktop', () => {
+ mockPointer(false)
+ const { result } = renderHook(() => useIsTouch())
+ expect(result.current).toBe(false)
+ })
+
+ it('reacts when the pointer type changes', () => {
+ const mq = mockPointer(false)
+ const { result } = renderHook(() => useIsTouch())
+ expect(result.current).toBe(false)
+ act(() => mq.fire(true))
+ expect(result.current).toBe(true)
+ })
+})
diff --git a/client/src/hooks/useIsTouch.ts b/client/src/hooks/useIsTouch.ts
new file mode 100644
index 000000000..8ee90277c
--- /dev/null
+++ b/client/src/hooks/useIsTouch.ts
@@ -0,0 +1,26 @@
+import { useState, useEffect } from 'react'
+
+/**
+ * Returns true when the primary pointer is coarse (finger/stylus) — phones and
+ * tablets, at any viewport width. A mouse-driven desktop is false, including a
+ * touchscreen laptop, whose primary pointer is still the mouse.
+ *
+ * Viewport width is not a proxy for touch: an iPad is 820-1366 CSS px, so a
+ * width-based check leaves HTML5 `draggable` armed there and the drag swallows
+ * the scroll gesture (#1432).
+ */
+export function useIsTouch(): boolean {
+ const [isTouch, setIsTouch] = useState(
+ () => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches,
+ )
+
+ useEffect(() => {
+ const mq = window.matchMedia('(pointer: coarse)')
+ const handler = (e: MediaQueryListEvent) => setIsTouch(e.matches)
+ setIsTouch(mq.matches)
+ mq.addEventListener('change', handler)
+ return () => mq.removeEventListener('change', handler)
+ }, [])
+
+ return isTouch
+}
diff --git a/client/src/index.css b/client/src/index.css
index 7e22da2e7..8d2dfad28 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -144,19 +144,32 @@ html.dark button[class*="text-slate-600"][class*="border-slate-200"]:hover { bac
html.dark .border-dashed.border-gray-300 { border-color: var(--border-primary) !important; }
html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color: transparent !important; }
-/* Reorder buttons: desktop = original style; mobile = always visible, larger touch targets */
+/* Reorder buttons: desktop = original style; touch = always visible, larger touch targets.
+ Keyed on pointer as well as width — a tablet is a wide touch device, and drag reorder is
+ off wherever the pointer is coarse (#1432), so these buttons are the only affordance left. */
.reorder-buttons {
flex-direction: column;
opacity: 0;
+ /* Hidden means non-interactive. Opacity alone still hit-tests, which left an invisible
+ but fully clickable target on every row that silently reordered the itinerary. */
+ pointer-events: none;
}
.reorder-buttons button {
background: none !important;
padding: 1px 2px;
}
-@media (max-width: 767px) {
+/* Reveal on row hover (and on keyboard focus, so the buttons are reachable by Tab). */
+.dp-row:hover .reorder-buttons,
+.dp-row:focus-within .reorder-buttons {
+ opacity: 1;
+ pointer-events: auto;
+}
+@media (max-width: 767px), (pointer: coarse) {
.reorder-buttons {
flex-direction: row !important;
opacity: 1 !important;
+ /* There is no hover on touch, so these are always live — undo the base rule. */
+ pointer-events: auto !important;
align-items: center;
margin-left: auto;
}
@@ -175,11 +188,6 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color
}
}
-/* Ort-Zeile Hover: Sortier-Buttons anzeigen */
-.place-row:hover .reorder-btns {
- opacity: 1 !important;
-}
-
/* Ladeanimation */
@keyframes spin {
to { transform: rotate(360deg); }
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index e365d2dbb..d6c85a881 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -203,7 +203,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
visibleConnections, setVisibleConnections, toggleConnection, mapTransportDetail, setMapTransportDetail,
- isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
+ isMobile, isTouch, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
@@ -387,6 +387,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
}}>
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index 104596ca2..c9cdbfb05 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -21,6 +21,7 @@ import { useRouteCalculation } from '../../hooks/useRouteCalculation'
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
+import { useIsTouch } from '../../hooks/useIsTouch'
import { usePluginStore } from '../../store/pluginStore'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
import { resolvePoolAssignmentId } from './tripPlannerModel'
@@ -269,6 +270,9 @@ export function useTripPlanner() {
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
+ // Layout is width-driven (isMobile); drag affordances are pointer-driven (isTouch).
+ // Conflating them is what left a tablet's places list undraggable-but-unscrollable (#1432).
+ const isTouch = useIsTouch()
// Start photo fetches during splash screen so images are ready when map mounts
useEffect(() => {
@@ -910,7 +914,7 @@ export function useTripPlanner() {
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
visibleConnections, setVisibleConnections, toggleConnection, mapTransportDetail, setMapTransportDetail,
- isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
+ isMobile, isTouch, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
diff --git a/client/src/utils/touchDragPolyfill.test.ts b/client/src/utils/touchDragPolyfill.test.ts
index 7a6c9c2dc..03fbe4d28 100644
--- a/client/src/utils/touchDragPolyfill.test.ts
+++ b/client/src/utils/touchDragPolyfill.test.ts
@@ -5,23 +5,39 @@ import { maybeInstallTouchDragPolyfill } from './touchDragPolyfill'
// document-level touch listeners the package installs on import.
vi.mock('drag-drop-touch', () => ({}))
-function setWidth(w: number) {
- Object.defineProperty(window, 'innerWidth', { value: w, configurable: true, writable: true })
+// The gate is pointer-based, not width-based. The polyfill synthesises HTML5 drag events
+// from touchmove, so on a coarse-pointer device it turns a scroll swipe into a drag
+// (#1432) and fabricates a dblclick that zooms the map (#1440). Drag reorder is disabled
+// on those devices anyway, leaving one device class with a job for it: the hybrid laptop
+// — mouse as primary pointer, touchscreen also present.
+function mockPointer(matching: string[]) {
+ vi.spyOn(window, 'matchMedia').mockImplementation(
+ (query: string) => ({ matches: matching.includes(query), media: query }) as MediaQueryList,
+ )
}
describe('maybeInstallTouchDragPolyfill', () => {
- const original = window.innerWidth
- afterEach(() => setWidth(original))
+ afterEach(() => vi.restoreAllMocks())
- it('does not load the polyfill on mobile viewports (<1024px)', () => {
- setWidth(390)
- expect(maybeInstallTouchDragPolyfill()).toBeUndefined()
- })
-
- it('loads the polyfill on large viewports (>=1024px)', async () => {
- setWidth(1440)
+ it('loads on a hybrid laptop — mouse primary, touchscreen available', async () => {
+ mockPointer(['(pointer: fine) and (any-pointer: coarse)'])
const result = maybeInstallTouchDragPolyfill()
expect(result).toBeInstanceOf(Promise)
await expect(result).resolves.toBeDefined()
})
+
+ it('does not load on a tablet — coarse primary pointer at a desktop width (#1432)', () => {
+ mockPointer(['(pointer: coarse)', '(any-pointer: coarse)'])
+ expect(maybeInstallTouchDragPolyfill()).toBeUndefined()
+ })
+
+ it('does not load on a phone', () => {
+ mockPointer(['(pointer: coarse)', '(any-pointer: coarse)'])
+ expect(maybeInstallTouchDragPolyfill()).toBeUndefined()
+ })
+
+ it('does not load on a plain mouse-driven desktop', () => {
+ mockPointer(['(pointer: fine)'])
+ expect(maybeInstallTouchDragPolyfill()).toBeUndefined()
+ })
})
diff --git a/client/src/utils/touchDragPolyfill.ts b/client/src/utils/touchDragPolyfill.ts
index 96abe7ff1..ead905a51 100644
--- a/client/src/utils/touchDragPolyfill.ts
+++ b/client/src/utils/touchDragPolyfill.ts
@@ -6,13 +6,14 @@
* dblclick fires the default double-click-zoom, so two quick one-finger pans zoomed
* instead of panning (#1440).
*
- * Reorder DnD is disabled on mobile viewports (#1432), so the polyfill has no job below
- * the lg breakpoint — skip it there to remove the phantom-dblclick source on phones
- * while keeping touch DnD on desktop / large (touch) viewports.
+ * Reorder DnD is disabled wherever the primary pointer is coarse (#1432), so the only
+ * device class left with a job for the polyfill is the hybrid laptop: a mouse as the
+ * primary pointer, with a touchscreen also available. Loading it anywhere else — notably
+ * on tablets, which a width check misclassifies as desktop — re-arms the very gesture
+ * hijack #1432 removed, and drags the #1440 phantom-dblclick along with it.
*/
export function maybeInstallTouchDragPolyfill(): Promise
| void {
if (typeof window === 'undefined') return
- // Mirrors useIsMobile's SSR-safe initial check (lg breakpoint = 1024px).
- if (window.innerWidth < 1024) return
+ if (!window.matchMedia('(pointer: fine) and (any-pointer: coarse)').matches) return
return import('drag-drop-touch')
}
diff --git a/package-lock.json b/package-lock.json
index 50fc23716..3002c22fd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6144,8 +6144,7 @@
"optional": true,
"os": [
"android"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.62.0",
@@ -6159,8 +6158,7 @@
"optional": true,
"os": [
"android"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.62.0",
@@ -6174,8 +6172,7 @@
"optional": true,
"os": [
"darwin"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.62.0",
@@ -6189,8 +6186,7 @@
"optional": true,
"os": [
"darwin"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.62.0",
@@ -6204,8 +6200,7 @@
"optional": true,
"os": [
"freebsd"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.62.0",
@@ -6219,8 +6214,7 @@
"optional": true,
"os": [
"freebsd"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.62.0",
@@ -6234,8 +6228,7 @@
"optional": true,
"os": [
"linux"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.62.0",
@@ -6249,8 +6242,7 @@
"optional": true,
"os": [
"linux"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.62.0",
@@ -6264,8 +6256,7 @@
"optional": true,
"os": [
"linux"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.62.0",
@@ -6292,8 +6283,7 @@
"optional": true,
"os": [
"linux"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.62.0",
@@ -6307,8 +6297,7 @@
"optional": true,
"os": [
"linux"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.62.0",
@@ -6322,8 +6311,7 @@
"optional": true,
"os": [
"linux"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.62.0",
@@ -6337,8 +6325,7 @@
"optional": true,
"os": [
"linux"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.62.0",
@@ -6352,8 +6339,7 @@
"optional": true,
"os": [
"linux"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.62.0",
@@ -6367,8 +6353,7 @@
"optional": true,
"os": [
"linux"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.62.0",
@@ -6382,8 +6367,7 @@
"optional": true,
"os": [
"linux"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.62.0",
@@ -6397,8 +6381,7 @@
"optional": true,
"os": [
"linux"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.62.0",
@@ -6425,8 +6408,7 @@
"optional": true,
"os": [
"openbsd"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.62.0",
@@ -6440,8 +6422,7 @@
"optional": true,
"os": [
"openharmony"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.62.0",
@@ -6455,8 +6436,7 @@
"optional": true,
"os": [
"win32"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.62.0",
@@ -6470,8 +6450,7 @@
"optional": true,
"os": [
"win32"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.62.0",
@@ -6485,8 +6464,7 @@
"optional": true,
"os": [
"win32"
- ],
- "peer": true
+ ]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.62.0",
@@ -6500,8 +6478,7 @@
"optional": true,
"os": [
"win32"
- ],
- "peer": true
+ ]
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
@@ -21237,7 +21214,7 @@
"name": "@trek/server",
"version": "3.3.0",
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.28.0",
+ "@modelcontextprotocol/sdk": "^1.29.0",
"@nestjs/common": "^11.1.24",
"@nestjs/core": "^11.1.24",
"@nestjs/platform-express": "^11.1.24",
diff --git a/plugin-sdk/package.json b/plugin-sdk/package.json
index 29848d74a..703f8b82c 100644
--- a/plugin-sdk/package.json
+++ b/plugin-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "trek-plugin-sdk",
- "version": "1.4.1",
+ "version": "1.5.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/dev.ts b/plugin-sdk/src/cli/dev.ts
index 3e8c8fee7..3a6c2aa35 100644
--- a/plugin-sdk/src/cli/dev.ts
+++ b/plugin-sdk/src/cli/dev.ts
@@ -92,9 +92,31 @@ function flatBind(args: unknown[]): unknown[] {
/** A dev db backed by node:sqlite if available, else an in-memory recorder that returns []. */
export function createDevDb(dbFile: string): { db: PluginContextDb; note: string; close: () => void } {
+ // node:sqlite ships on Node 22.5+. Probe for it SEPARATELY from opening the database:
+ // one try/catch around both meant an mkdir/permission failure silently degraded to the
+ // stub too, and the stub swallows every write while reporting success — a db:own plugin
+ // "works" in dev and persists nothing. Fail loudly on a real error; degrade only on old Node.
+ let DatabaseSync: (new (p: string) => SqliteDb) | undefined;
try {
- // node:sqlite is available on Node 22.5+ (experimental). Fall back gracefully if not.
- const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as { DatabaseSync: new (p: string) => SqliteDb };
+ ({ DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as { DatabaseSync: new (p: string) => SqliteDb });
+ } catch {
+ console.warn(
+ `\n ⚠ node:sqlite is unavailable on this Node (${process.version}) — ctx.db is a STUB.\n` +
+ ` Every query returns [] and every write reports 0 changes: a db:own plugin will look\n` +
+ ` like it works and persist NOTHING. Upgrade to Node 22.5+ to test db:own for real.\n`,
+ );
+ return {
+ note: `db:own → in-memory STUB (writes are discarded — upgrade to Node 22.5+; you are on ${process.version})`,
+ close: () => {},
+ db: {
+ async query(sql: string) { guardSql(sql); return []; },
+ async exec(sql: string) { guardSql(sql); return { changes: 0 }; },
+ async migrate(_id: string, sql: string) { guardSql(sql); return { applied: true }; },
+ },
+ };
+ }
+
+ {
fs.mkdirSync(path.dirname(dbFile), { recursive: true });
const sq = new DatabaseSync(dbFile);
const applied = new Set();
@@ -116,16 +138,6 @@ export function createDevDb(dbFile: string): { db: PluginContextDb; note: string
async migrate(id: string, sql: string) { guardSql(sql); if (applied.has(id)) return { applied: false }; sq.exec(sql); applied.add(id); return { applied: true }; },
},
};
- } catch {
- return {
- note: 'db:own → in-memory stub (upgrade to Node 22.5+ for a real SQLite dev db)',
- close: () => {},
- db: {
- async query(sql: string) { guardSql(sql); return []; },
- async exec(sql: string) { guardSql(sql); return { changes: 0 }; },
- async migrate(_id: string, sql: string) { guardSql(sql); return { applied: true }; },
- },
- };
}
}
@@ -341,7 +353,26 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
else if (kind === 'event') { for (const s of plugin.events ?? []) if (s.on === name || s.on === '*') await s.handler({ event: name, tripId: 0, ...(payload as object) }, userlessCtx); out = { delivered: true }; }
else if (kind === 'deleteUserData') { if (!plugin.deleteUserData) return send(404, 'no deleteUserData handler'); out = await plugin.deleteUserData({ userId: Number((payload as { userId?: unknown })?.userId ?? name ?? 0) }, userlessCtx); }
else if (kind === 'exportUserData') { if (!plugin.exportUserData) return send(404, 'no exportUserData handler'); out = await plugin.exportUserData({ userId: Number((payload as { userId?: unknown })?.userId ?? name ?? 0) }, userlessCtx); }
- else if (kind === 'hook') { const impl = plugin.hooks?.[name]; if (!impl || typeof impl[fn] !== 'function') return send(404, `no hook ${name}.${fn}`); out = await impl[fn](...(Array.isArray(payload) ? payload : payload != null ? [payload] : []), ctx); }
+ else if (kind === 'hook') {
+ const impl = plugin.hooks?.[name];
+ if (!impl || typeof impl[fn] !== 'function') return send(404, `no hook ${name}.${fn}`);
+ if (name === 'notificationChannel') {
+ // A notification channel is NOT like the other hooks: the host fires it with no
+ // acting user (it delivers to an arbitrary recipient), and hands the recipient's
+ // decrypted settings in as a separate `config` argument —
+ // send(msg, config, ctx) · test(config, ctx)
+ // Firing it like a normal hook passed `ctx` where `config` belongs, so a channel
+ // plugin read its settings off the ctx object and "worked" in dev while being
+ // broken in production. Mirror the host (and createMockHost) exactly.
+ const config = (fx.userSettings ?? {}) as Record;
+ const args = Array.isArray(payload) ? (payload as unknown[]) : payload != null ? [payload] : [];
+ out = fn === 'test'
+ ? await impl[fn](config, userlessCtx)
+ : await impl[fn](args[0], (args[1] as Record) ?? config, userlessCtx);
+ } else {
+ out = await impl[fn](...(Array.isArray(payload) ? payload : payload != null ? [payload] : []), ctx);
+ }
+ }
else return send(400, `unknown fire kind "${kind}"`);
return send(200, JSON.stringify(out ?? { ok: true }, null, 2), 'application/json; charset=utf-8');
} catch (e) {
@@ -357,7 +388,11 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
// proxies trek:invoke to your /api routes, and honours resize/notify/navigate. This
// is where the design kit actually renders themed.
if (url.pathname === '/preview' && String(manifest.type) !== 'integration') {
- return send(200, preview(id, String(manifest.type)), 'text/html; charset=utf-8');
+ // Preview against a trip that actually EXISTS in the fixtures. This used to be
+ // hard-coded to 42 while the scaffold seeds trip 1, so the widget's first
+ // trek:invoke hit assertMember(42, …) and 500'd with RESOURCE_FORBIDDEN.
+ const previewTripId = Number(Object.keys(fx.trips ?? {})[0] ?? 1) || 1;
+ return send(200, preview(id, String(manifest.type), previewTripId), 'text/html; charset=utf-8');
}
// Static plugin UI at /ui (page/widget client bundle). The iframe loads
@@ -485,7 +520,7 @@ ${LIVE_RELOAD}`;
* toggle), proxies trek:invoke to the dev server's /api routes with the dev user,
* and surfaces resize/notify/navigate. This is where the design kit renders themed.
*/
-function preview(id: string, type: string): string {
+function preview(id: string, type: string, tripId: number): string {
const maxW = type === 'widget' ? '440px' : '1000px';
return `
${id} · preview
@@ -535,7 +570,7 @@ function ctx(){
document.body.classList.toggle("dark", theme==="dark");
var tokens={}; var src=theme==="dark"?DA[accent]:LA[accent]; for(var k in src){tokens[k]=src[k];}
return {type:"trek:context",theme:theme,locale:"en",hostOrigin:location.origin,
- tripId: val("trip").checked?42:null, userId:"1",
+ tripId: val("trip").checked?${tripId}:null, userId:"1",
user:{name:"Dev User",avatar:null,isAdmin:true},
appearance:{scheme:accent,density:"comfortable",reducedMotion:val("rm").checked,noTransparency:val("nt").checked},
formats:{locale:"en",currency:"EUR",timeFormat:"24h",distanceUnit:"metric",temperatureUnit:"celsius",timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},
diff --git a/plugin-sdk/src/cli/entry.ts b/plugin-sdk/src/cli/entry.ts
index b7b471ee5..915fbfbed 100644
--- a/plugin-sdk/src/cli/entry.ts
+++ b/plugin-sdk/src/cli/entry.ts
@@ -4,8 +4,8 @@
* [--merge registry/plugins/.json] [--out file]
*
* Generates the ready-to-PR TREK-Plugins registry entry from the manifest + the
- * packed plugin.zip + the git tag — so the sha256, size, commitSha, downloadUrl
- * and minTrekVersion an author would compute by hand all come out of one command.
+ * packed plugin.zip + the git tag — so the sha256, size, commitSha and downloadUrl
+ * an author would compute by hand all come out of one command.
* With --merge it prepends the new version onto an existing entry (the update
* case), keeping versions newest-first.
*/
@@ -13,12 +13,25 @@ import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
+import { minVersion, validRange } from 'semver';
import { loadPrivateKey, signArtifact, publicKeyBase64 } from './sign.js';
import { readJsonFile } from './json.js';
interface Version {
version: string; gitTag: string; commitSha: string; downloadUrl: string;
- sha256: string; minTrekVersion: string; size: number; apiVersion: number;
+ sha256: string;
+ /**
+ * The manifest's `trek` range, verbatim — the ONE compatibility field an entry carries.
+ * It is what TREK gates installs and activation on, and the only field that can express
+ * an exclusive upper bound.
+ *
+ * There is deliberately no `minTrekVersion` here any more. It said the same thing (the
+ * range's lower bound) in a weaker form, and a second field restating a derived fact is a
+ * second field that can be wrong. The registry still ACCEPTS one on entries published
+ * before `trek` existed — and checks it agrees with the range — but nothing emits one now.
+ */
+ trek: string;
+ size: number; apiVersion: number;
nativeModules: false; operatorEgress?: boolean; publishedAt: string; signature?: string;
}
interface Entry {
@@ -26,10 +39,20 @@ interface Entry {
homepage?: string; tags?: string[]; type: string; authorPublicKey?: string; versions: Version[];
}
-/** 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 ">=3.2.0 <4.0.0" -> "3.2.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
+ * would have been published as the minimum — the precise inverse of what the plugin says.
+ */
function minTrekFrom(trek: unknown): string | null {
- if (typeof trek !== 'string') return null;
- return trek.match(/(\d+\.\d+\.\d+)/)?.[1] ?? null;
+ if (typeof trek !== 'string' || validRange(trek) === null) return null;
+ try {
+ return minVersion(trek)?.version ?? null;
+ } catch {
+ return null;
+ }
}
function resolveCommit(dir: string, tag: string, override?: string): string {
@@ -50,8 +73,9 @@ export function buildEntry(opts: {
}): Entry {
const manifest = readJsonFile>(path.join(opts.dir, 'trek-plugin.json'));
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(opts.repo)) throw new Error(`--repo must be "owner/name", got "${opts.repo}"`);
- const minTrek = minTrekFrom(manifest.trek);
- if (!minTrek) throw new Error('manifest has no "trek" version range to derive minTrekVersion from (e.g. "trek": ">=3.2.0 <4.0.0")');
+ // 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 (!fs.existsSync(opts.zipPath)) throw new Error(`artifact not found: ${opts.zipPath} — run \`trek-plugin pack\` first`);
const buf = fs.readFileSync(opts.zipPath);
@@ -62,7 +86,7 @@ export function buildEntry(opts: {
commitSha: resolveCommit(opts.dir, opts.tag, opts.commit),
downloadUrl: `https://github.com/${opts.repo}/releases/download/${opts.tag}/${asset}`,
sha256: createHash('sha256').update(buf).digest('hex'),
- minTrekVersion: minTrek,
+ trek: String(manifest.trek),
size: buf.length,
apiVersion: typeof manifest.apiVersion === 'number' ? manifest.apiVersion : 1,
nativeModules: false,
diff --git a/plugin-sdk/src/cli/preflight.ts b/plugin-sdk/src/cli/preflight.ts
index b960f968d..db6f4fe9c 100644
--- a/plugin-sdk/src/cli/preflight.ts
+++ b/plugin-sdk/src/cli/preflight.ts
@@ -1,24 +1,31 @@
/**
* trek-plugin preflight — run the registry CI checks locally, over the network,
* BEFORE you open a PR. It mirrors TREK-Plugins' validate-entry.mjs (tag→commit,
- * manifest parity, artifact sha256/size, native-binary scan) and check-readme.mjs
- * (required sections, real prose, a resolving screenshot, permission parity), so
- * you catch what CI would reject without a round-trip through review.
+ * manifest parity incl. operatorEgress/requiredAddons/pluginDependencies, artifact
+ * sha256/size, author-signature shape + verification, native-binary scan) and
+ * check-readme.mjs (required sections, real prose, a resolving screenshot, permission
+ * parity), so you catch what CI would reject without a round-trip through review.
+ *
+ * That mirroring is the whole contract: a gate the registry has and preflight doesn't is
+ * a false green, and an author trusts a green. Keep the two in step.
*
* Dependency-free: global fetch + Node built-ins + our own zip reader.
*/
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { listZipNames } from '../zip.js';
+import { verifyAuthorSignature, checkSignatureShape, SignatureError } from './verify-signature.js';
const REQUIRED_HEADINGS = ['what it does', 'screenshots', 'permissions', 'setup'];
const PLACEHOLDER_RE = [/\{\{[^}]*\}\}/, /\bREPLACE_ME\b/i, /\bDescribe (what|the)\b/i, /\byour-name\/trek-plugin/i];
const MIN_PROSE = 400;
const NATIVE_RE = /(^|\/)[^/]+\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i;
+export interface PluginDependency { id: string; version: string }
export interface EntryVersion {
version: string; gitTag: string; commitSha: string; downloadUrl: string;
sha256: string; size: number; apiVersion: number; nativeModules?: boolean; operatorEgress?: boolean; signature?: string;
+ requiredAddons?: string[]; pluginDependencies?: PluginDependency[];
}
export interface Entry {
id: string; name: string; type: string; repo: string; authorPublicKey?: string; versions: EntryVersion[];
@@ -48,7 +55,10 @@ export async function preflight(entry: Entry, opts: { all?: boolean } = {}): Pro
if (!/^[a-z][a-z0-9-]{2,39}$/.test(entry.id)) fail(`id "${entry.id}" is not a valid slug (^[a-z][a-z0-9-]{2,39}$)`);
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(entry.repo)) fail(`repo "${entry.repo}" is not "owner/name"`);
if (!['integration', 'page', 'widget', 'trip-page'].includes(entry.type)) fail(`type "${entry.type}" is not integration|page|widget|trip-page`);
- if (entry.authorPublicKey && !entry.versions[0]?.signature) fail('entry has authorPublicKey but the newest version has no signature (sign the version too)');
+ // Signature SHAPE (mirrors the registry's checkSignatureShape): a key and a signature
+ // must be both-present or both-absent, and each must parse. TREK refuses to install a
+ // half-signed entry, so one is dead on arrival — better to say so now than at review.
+ for (const p of checkSignatureShape(entry)) fail(p);
const versions = opts.all ? entry.versions : entry.versions.slice(0, 1);
for (const v of versions) {
@@ -79,7 +89,12 @@ export async function preflight(entry: Entry, opts: { all?: boolean } = {}): Pro
if (m.id !== entry.id) fail(`${tag}: manifest id "${m.id}" != entry id "${entry.id}"`);
if (m.version !== v.version) fail(`${tag}: manifest version "${m.version}" != entry "${v.version}"`);
if (m.type !== entry.type) fail(`${tag}: manifest type "${m.type}" != entry "${entry.type}"`);
- if (m.apiVersion !== v.apiVersion) fail(`${tag}: manifest apiVersion ${m.apiVersion} != entry ${v.apiVersion}`);
+ // apiVersion is OPTIONAL in the manifest: the host defaults it to 1 at install
+ // (install/manifest.ts) and `entry` defaults it to 1 when building the entry. Compare
+ // the DEFAULTED values, or a manifest that legally omits it fails here with
+ // "manifest apiVersion undefined != entry 1" while the registry's CI passes it.
+ const mApiVersion = typeof m.apiVersion === 'number' ? m.apiVersion : 1;
+ if (mApiVersion !== v.apiVersion) fail(`${tag}: manifest apiVersion ${mApiVersion} != entry ${v.apiVersion}`);
if (m.nativeModules === true) fail(`${tag}: manifest declares nativeModules:true (forbidden)`);
// Mirrors the registry's operatorEgress parity check (validate-entry.mjs): the entry
// must not understate the plugin's network reach.
@@ -96,6 +111,22 @@ export async function preflight(entry: Entry, opts: { all?: boolean } = {}): Pro
}
if (egress.includes('*')) fail(`${tag}: egress[] must not contain a bare "*"`);
}
+ if (mOperatorEgress && !manifestPerms.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) {
+ fail(`${tag}: operatorEgress declared without an http:outbound permission`);
+ }
+ // Dependency parity (mirrors validate-entry.mjs): the enriched entry must mirror the
+ // manifest, because TREK resolves requiredAddons/pluginDependencies from the INDEX
+ // before it ever downloads the artifact. An entry that understates them resolves
+ // against a dependency set the code doesn't actually have.
+ const normAddons = (a: unknown) => (Array.isArray(a) ? [...a].map(String).sort() : []);
+ if (JSON.stringify(normAddons(m.requiredAddons)) !== JSON.stringify(normAddons(v.requiredAddons))) {
+ fail(`${tag}: manifest requiredAddons != entry requiredAddons`);
+ }
+ const normDeps = (d: unknown) =>
+ (Array.isArray(d) ? d.map((x) => `${(x as PluginDependency)?.id}@${(x as PluginDependency)?.version}`).sort() : []);
+ if (JSON.stringify(normDeps(m.pluginDependencies)) !== JSON.stringify(normDeps(v.pluginDependencies))) {
+ fail(`${tag}: manifest pluginDependencies != entry pluginDependencies`);
+ }
if (!failures.some((f) => f.startsWith(`${tag}: manifest`))) ok(`${tag}: manifest at commit matches the entry`);
}
} catch (e) { fail(`${tag}: manifest parity failed: ${(e as Error).message}`); }
@@ -110,6 +141,23 @@ export async function preflight(entry: Entry, opts: { all?: boolean } = {}): Pro
const sha = createHash('sha256').update(bytes).digest('hex');
if (sha !== v.sha256) fail(`${tag}: sha256 mismatch — release has ${sha.slice(0, 12)}…, entry pins ${v.sha256.slice(0, 12)}… (re-pack + re-upload?)`);
else ok(`${tag}: artifact sha256 matches the release`);
+
+ // Author signature over those same bytes. sha256 proves they are what the REGISTRY
+ // vouches for; the signature proves they came from the AUTHOR's key. The registry's
+ // CI verifies this and TREK verifies it again at install, so a signature that does
+ // not check out is an entry nobody can install — and the author would only find out
+ // at review. We already have the bytes, so it costs nothing to say so now.
+ if (v.signature && entry.authorPublicKey) {
+ try {
+ if (!verifyAuthorSignature(bytes, v.signature, entry.authorPublicKey)) {
+ fail(`${tag}: author signature does not verify against authorPublicKey — did you sign with a different key, or re-pack after signing?`);
+ } else ok(`${tag}: author signature verifies`);
+ } catch (e) {
+ if (e instanceof SignatureError) fail(`${tag}: signature/key is malformed: ${e.message}`);
+ else throw e;
+ }
+ }
+
let names: string[] = [];
try { names = bytes[0] === 0x50 && bytes[1] === 0x4b ? listZipNames(bytes) : []; } catch { /* not a zip */ }
if (names.some((n) => NATIVE_RE.test(n))) fail(`${tag}: artifact contains native binaries (.node / binding.gyp / prebuilds)`);
diff --git a/plugin-sdk/src/cli/publish.ts b/plugin-sdk/src/cli/publish.ts
index 11c84b9e2..e72eab4af 100644
--- a/plugin-sdk/src/cli/publish.ts
+++ b/plugin-sdk/src/cli/publish.ts
@@ -7,7 +7,6 @@
*
* Requires `git` + `gh` (authenticated), same as `release`/`submit`.
*/
-import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { packPluginDir } from './pack.js';
@@ -22,10 +21,15 @@ function git(dir: string, args: string[], quiet = true): string {
function tagExists(dir: string, tag: string): boolean {
try { git(dir, ['rev-parse', `${tag}^{commit}`]); return true; } 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; }
+}
export async function publishPlugin(opts: {
dir: string; repo: string; tag: string; now: string;
signKeyPath?: string; registry?: string; draft?: boolean; notes?: string; skipPreflight?: boolean;
+ /** Overwrite the artifact on an existing release. Only safe if it was never merged. */
+ force?: boolean;
/** Progress sink. Defaults to the plain console.error lines (CI parity). */
log?: LogSink;
}): Promise<{ prUrl: string }> {
@@ -45,11 +49,23 @@ export async function publishPlugin(opts: {
try { git(dir, ['push', '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})`);
}
- try {
- execFileSync('gh', ['release', 'create', opts.tag, packed.artifact, '--repo', opts.repo, '--title', opts.tag, '--notes', opts.notes || `Release ${opts.tag}`], { stdio: 'pipe' });
- } catch {
- // Release already exists — (re)upload the packed artifact so its bytes match the pin.
+ // 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.
+ // (The old code blanket-caught every `gh release create` failure — auth, network, a bad
+ // repo — and turned it into a --clobber upload.)
+ if (releaseExists(opts.repo, opts.tag)) {
+ if (!opts.force) {
+ throw new Error(
+ `release ${opts.tag} already exists on ${opts.repo}.\n` +
+ `Overwriting a released artifact breaks the sha256 pin for everyone who already installed it.\n` +
+ `Cut a new version, or pass --force if this release was never merged into the registry.`,
+ );
+ }
+ log(` ! release ${opts.tag} exists — overwriting the artifact (--force)`);
execFileSync('gh', ['release', 'upload', opts.tag, packed.artifact, '--repo', opts.repo, '--clobber'], { stdio: 'pipe' });
+ } else {
+ execFileSync('gh', ['release', 'create', opts.tag, packed.artifact, '--repo', opts.repo, '--title', opts.tag, '--notes', opts.notes || `Release ${opts.tag}`], { stdio: 'pipe' });
}
log(` ✓ release ${opts.tag} on ${opts.repo}`);
@@ -69,6 +85,9 @@ export async function publishPlugin(opts: {
step(4, 'Opening the registry PR…');
const { prUrl } = submitEntry(entry, { registry: opts.registry, draft: opts.draft });
log(' ✓ done');
- fs.rmSync(zip, { force: true });
+ // 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/submit.ts b/plugin-sdk/src/cli/submit.ts
index f2971ef59..32e28f9d2 100644
--- a/plugin-sdk/src/cli/submit.ts
+++ b/plugin-sdk/src/cli/submit.ts
@@ -61,7 +61,10 @@ export function submitEntry(entry: EntryLike, opts: { registry?: string; branch?
}
if (!cloned) throw new Error(`could not clone your fork ${login}/${name} (is the fork ready on GitHub?)`);
- git(tmp, 'remote', 'add', 'upstream', `https://github.com/${registry}.git`);
+ // `gh repo clone` of a fork may already have wired an `upstream` remote, in which case a
+ // bare `remote add` exits non-zero and takes the whole submit down. Set it either way.
+ try { git(tmp, 'remote', 'add', 'upstream', `https://github.com/${registry}.git`); }
+ catch { git(tmp, 'remote', 'set-url', 'upstream', `https://github.com/${registry}.git`); }
git(tmp, 'fetch', '--depth=1', 'upstream', 'main');
git(tmp, 'checkout', '-B', branch, 'upstream/main');
diff --git a/plugin-sdk/src/cli/trek-plugin.ts b/plugin-sdk/src/cli/trek-plugin.ts
index 418c4b8e2..acb8952d3 100644
--- a/plugin-sdk/src/cli/trek-plugin.ts
+++ b/plugin-sdk/src/cli/trek-plugin.ts
@@ -3,6 +3,8 @@
* `trek-plugin ` — the plugin author CLI (#plugins).
*
* create [name] [--type t] [--interactive] scaffold a plugin (wizard if no name)
+ * [--template blank|notification-channel] type: integration|page|widget|trip-page
+ * [--egress host,host] [--required-addons a,b]
* dev [dir] [--port 4317] run locally with hot reload
* validate [dir] check the manifest + layout
* pack [dir] [--out plugin.zip] [--json] build plugin.zip, print sha256 + size
@@ -64,6 +66,43 @@ const { flags, pos } = parse(args);
type Flags = Record;
+/**
+ * Every flag each command reads. `parse()` accepts any `--x`, so a flag a command
+ * does NOT read used to be silently dropped — `create --template notification-channel`
+ * cheerfully scaffolded a blank plugin. Silently ignoring an author's explicit
+ * instruction is worse than refusing it, so unknown flags are now an error.
+ */
+const COMMAND_FLAGS: Record = {
+ create: ['type', 'interactive', 'author', 'description', 'permissions', 'template', 'egress', 'required-addons'],
+ dev: ['port'],
+ validate: [],
+ pack: ['out', 'json'],
+ keygen: ['key'],
+ sign: ['key'],
+ entry: ['repo', 'tag', 'dir', 'zip', 'commit', 'asset', 'merge', 'sign', 'key', 'out'],
+ preflight: ['repo', 'tag', 'entry', 'zip', 'commit', 'sign', 'key', 'all'],
+ 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', 'force'],
+};
+
+function assertKnownFlags(command: string, f: Flags): void {
+ const known = COMMAND_FLAGS[command];
+ if (!known) return;
+ const unknown = Object.keys(f).filter((k) => !known.includes(k));
+ if (unknown.length) {
+ const list = unknown.map((u) => `--${u}`).join(', ');
+ fail(`unknown flag${unknown.length > 1 ? 's' : ''} for \`${command}\`: ${list}\n ${command} accepts: ${known.map((k) => '--' + k).join(', ') || '(none)'}`);
+ }
+}
+
+/** `--egress a,b` / `--permissions "a b"` → a string[]. */
+function listFlag(v: string | undefined): string[] | undefined {
+ if (!v || v === 'true') return undefined;
+ const parts = v.split(/[\s,]+/).filter(Boolean);
+ return parts.length ? parts : undefined;
+}
+
/** --sign, --sign , or absent → the key path to sign with (or undefined). */
function signKey(f: Flags): string | undefined {
if (!f.sign) return undefined;
@@ -91,7 +130,41 @@ async function ensureRepoTag(f: Flags, failMsg: string): Promise<{ repo: string;
const USAGE = 'usage: trek-plugin [...]';
+const HELP = `${USAGE}
+
+ create [name] [--type integration|page|widget|trip-page] scaffold a plugin (wizard if no name)
+ [--template blank|notification-channel] [--interactive]
+ [--author a] [--description d] [--permissions "a b"]
+ [--egress host,host] [--required-addons a,b]
+ dev [dir] [--port 4317] run locally with hot reload
+ validate [dir] check the manifest + layout
+ pack [dir] [--out plugin.zip] [--json] build plugin.zip, print sha256 + size
+ keygen [--key file] create an Ed25519 signing key
+ sign [zip] [--key file] print a signature + public key for an artifact
+ entry --repo o/n --tag vX [--zip z] print the ready-to-PR registry entry
+ [--dir d] [--commit c] [--asset a] [--merge entry.json] [--sign [key]] [--out f]
+ preflight [dir] --repo o/n --tag vX run the registry CI checks locally
+ [--entry e] [--zip z] [--commit c] [--sign [key]] [--all]
+ submit [dir] --repo o/n --tag vX open the registry PR for you
+ [--sign [key]] [--registry o/n] [--branch b] [--draft] [--keep]
+ release [dir] --repo o/n --tag vX pack -> gh release -> print entry
+ [--out f] [--notes n] [--sign [key]] [--merge entry.json]
+ publish [dir] --repo o/n --tag vX the lot: pack -> tag+release ->
+ [--sign [key]] [--no-preflight] preflight -> open the registry PR
+ [--registry o/n] [--notes n] [--draft]
+ [--force] overwrite an existing release's artifact
+ (breaks the sha256 pin if it was ever merged)
+
+The goal: create -> dev -> publish, and never hand-compute sha256/size/commitSha
+or hand-write the registry JSON.`;
+
async function main(): Promise {
+ // Before dispatch, so assertKnownFlags never sees `--help` and rejects it as unknown.
+ // Help is not an error: it goes to stdout and exits 0.
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h' || flags.help) {
+ console.log(HELP);
+ return;
+ }
if (!cmd) {
// Bare invocation: a menu in a terminal, the usage line for scripts.
if (!isInteractive()) { console.error(USAGE); process.exit(2); }
@@ -104,16 +177,20 @@ async function main(): Promise {
async function dispatch(command: string, f: Flags, positional: string[]): Promise {
const tui = isInteractive();
+ assertKnownFlags(command, f);
if (command === 'create') {
const name = positional[0];
if (!name || f.interactive) {
- if (!tui) fail('create needs a plugin name in non-interactive mode: create [--type integration|page|widget]');
+ if (!tui) fail('create needs a plugin name in non-interactive mode: create [--type integration|page|widget|trip-page]');
await interactiveScaffold(process.cwd(), name);
return;
}
scaffold(name, f.type || 'integration', process.cwd(), {
author: f.author, description: f.description,
- permissions: f.permissions ? f.permissions.split(/[\s,]+/).filter(Boolean) : undefined,
+ permissions: listFlag(f.permissions),
+ egress: listFlag(f.egress),
+ requiredAddons: listFlag(f['required-addons']),
+ template: f.template as 'blank' | 'notification-channel' | undefined,
});
console.log(`Created ${name}/ — build server/index.js, add docs/screenshot.png, then \`npx trek-plugin-sdk dev ${name}\`.`);
} else if (command === 'dev') {
@@ -216,7 +293,8 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
const { prUrl } = await publishPlugin({
dir: positional[0] || '.', repo, tag,
signKeyPath: signKey(f), registry: f.registry, draft: !!f.draft,
- notes: f.notes, skipPreflight: !!f['no-preflight'], now: new Date().toISOString(),
+ notes: f.notes, skipPreflight: !!f['no-preflight'], force: !!f.force,
+ now: new Date().toISOString(),
log: tui ? clackLogSink : undefined,
});
if (tui) logSuccess('Published — registry PR:'); else console.error('\n✓ published — registry PR:');
diff --git a/plugin-sdk/src/cli/validate.ts b/plugin-sdk/src/cli/validate.ts
index d0aafffda..69b935e6f 100644
--- a/plugin-sdk/src/cli/validate.ts
+++ b/plugin-sdk/src/cli/validate.ts
@@ -7,7 +7,7 @@
*/
import fs from 'node:fs';
import path from 'node:path';
-import { validateManifest, KNOWN_ADDONS } from '../manifest.js';
+import { validateManifest, isUnboundedRange, KNOWN_ADDONS } from '../manifest.js';
import { readJsonFile } from './json.js';
export interface ValidateReport {
@@ -33,6 +33,13 @@ export function validatePluginDir(dir: string): ValidateReport {
if (manifestId && path.basename(path.resolve(dir)) !== manifestId) {
warnings.push(`directory name should equal the plugin id "${manifestId}"`);
}
+ // A `trek` of "*" installs on every TREK ever released and every one still to come.
+ // It is legal — a plugin may honestly have no version-specific surface — but it is far
+ // more often an author who didn't want to think about it, and it opts them out of the
+ // one mechanism that stops their plugin running on a host it was never tested against.
+ if (isUnboundedRange(res.manifest?.trek ?? '')) {
+ warnings.push('trek: "*" claims support for every TREK version, including ones that do not exist yet — pin a range like ">=3.2.0 <4.0.0"');
+ }
// A well-formed but unrecognised addon id is not an error (the plugin may target
// a newer TREK), but it can never enable here — surface it as a warning.
for (const a of res.manifest?.requiredAddons ?? []) {
diff --git a/plugin-sdk/src/cli/verify-signature.ts b/plugin-sdk/src/cli/verify-signature.ts
new file mode 100644
index 000000000..ccfd0e13d
--- /dev/null
+++ b/plugin-sdk/src/cli/verify-signature.ts
@@ -0,0 +1,125 @@
+/**
+ * Author-signature verification for `trek-plugin preflight`.
+ *
+ * This is a port of TREK's installer-side verifier (server/src/nest/plugins/install/
+ * verify-signature.ts), and of the registry's port of it (TREK-Plugins'
+ * scripts/lib/verify-signature.mjs). All three MUST accept exactly the same inputs.
+ *
+ * The point of preflight is to tell an author what the registry's CI would say before
+ * they open the PR. `sign.ts`'s verifyArtifact only understands the bare key/signature
+ * pair the SDK itself emits, so it cannot answer that question for a minisign key — and
+ * a preflight that says "green" on a signature the registry would reject is worse than
+ * no preflight at all, because the author trusts it.
+ *
+ * node:crypto only, like the rest of the SDK.
+ */
+import crypto from 'node:crypto';
+
+// SPKI DER prefix for a raw 32-byte Ed25519 public key (RFC 8410).
+const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
+
+export class SignatureError extends Error {}
+
+/** Wrap a raw 32-byte Ed25519 public key as a node KeyObject. */
+function ed25519KeyFromRaw(raw: Buffer): crypto.KeyObject {
+ if (raw.length !== 32) throw new SignatureError('public key is not a 32-byte Ed25519 key');
+ return crypto.createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
+}
+
+/**
+ * Parse a minisign public key: either the two-line minisign format (comment + base64
+ * payload) or a bare base64 payload. The 10-byte payload header is `Ed` + an 8-byte key
+ * id; the trailing 32 bytes are the key. A bare 32-byte base64 key (what `keygen` emits)
+ * is also accepted.
+ */
+function parseMinisignPubKey(pub: string): { key: crypto.KeyObject; keyId: Buffer | null } {
+ const line = pub.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('untrusted comment')).pop();
+ if (!line) throw new SignatureError('empty public key');
+ const buf = Buffer.from(line, 'base64');
+ if (buf.length === 32) return { key: ed25519KeyFromRaw(buf), keyId: null };
+ if (buf.length === 42 && buf.subarray(0, 2).toString('latin1') === 'Ed') {
+ return { key: ed25519KeyFromRaw(buf.subarray(10)), keyId: buf.subarray(2, 10) };
+ }
+ throw new SignatureError('unrecognized public key format');
+}
+
+/**
+ * Parse a minisign signature: 2-byte algorithm (`Ed` legacy | `ED` prehashed) + 8-byte
+ * key id + 64-byte signature. A bare 64-byte base64 signature (what `sign` emits, over
+ * the raw artifact bytes) is also accepted.
+ */
+function parseMinisignSignature(sig: string): { algo: 'Ed' | 'ED' | 'raw'; keyId: Buffer | null; signature: Buffer } {
+ const line = sig.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('untrusted comment')).shift();
+ if (!line) throw new SignatureError('empty signature');
+ const buf = Buffer.from(line, 'base64');
+ if (buf.length === 64) return { algo: 'raw', keyId: null, signature: buf };
+ if (buf.length === 74) {
+ const algo = buf.subarray(0, 2).toString('latin1');
+ if (algo !== 'Ed' && algo !== 'ED') throw new SignatureError(`unsupported signature algorithm ${algo}`);
+ return { algo, keyId: buf.subarray(2, 10), signature: buf.subarray(10) };
+ }
+ throw new SignatureError('unrecognized signature format');
+}
+
+/**
+ * Verify that `bytes` were signed by the holder of `publicKeyB64`'s private key. Returns
+ * true on a valid signature, false on a well-formed but non-matching one, and throws
+ * SignatureError on malformed input.
+ */
+export function verifyAuthorSignature(bytes: Buffer, signatureB64: string, publicKeyB64: string): boolean {
+ const { key, keyId } = parseMinisignPubKey(publicKeyB64);
+ const { algo, keyId: sigKeyId, signature } = parseMinisignSignature(signatureB64);
+
+ // If both carry a key id, they must name the same key (catches a wrong-key mixup).
+ if (keyId && sigKeyId && !keyId.equals(sigKeyId)) return false;
+
+ const message = algo === 'ED' ? crypto.createHash('blake2b512').update(bytes).digest() : bytes;
+ try {
+ return crypto.verify(null, message, key, signature);
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Structural check that needs no artifact (and so no network): a key and a signature must
+ * be both-present or both-absent, and each must parse.
+ *
+ * TREK refuses to install a half-signed entry ("incomplete signature: an author key and a
+ * version signature must both be present"), so such an entry is dead on arrival. Mirrors
+ * the registry's checkSignatureShape. Returns a list of problems (empty === fine).
+ */
+export function checkSignatureShape(entry: { authorPublicKey?: string; versions?: Array<{ version: string; signature?: string }> }): string[] {
+ const problems: string[] = [];
+ const key = entry.authorPublicKey;
+ const versions = Array.isArray(entry.versions) ? entry.versions : [];
+
+ if (key) {
+ try {
+ parseMinisignPubKey(key);
+ } catch (e) {
+ problems.push(`authorPublicKey is not a valid Ed25519/minisign public key: ${(e as Error).message}`);
+ }
+ }
+
+ for (const v of versions) {
+ if (v.signature && !key) {
+ problems.push(`${v.version}: has a signature but the entry has no authorPublicKey — TREK refuses to install a half-signed entry`);
+ }
+ if (v.signature) {
+ try {
+ parseMinisignSignature(v.signature);
+ } catch (e) {
+ problems.push(`${v.version}: signature is malformed: ${(e as Error).message}`);
+ }
+ }
+ }
+
+ // A key with no signed version at all is a publishing mistake: TREK pins the key on
+ // first install and then requires every later version to be signed by it.
+ if (key && versions.length && !versions.some((v) => v.signature)) {
+ problems.push('authorPublicKey is set but no version carries a signature — either sign the release or drop the key');
+ }
+
+ return problems;
+}
diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts
index db1dbb951..a11db6a49 100644
--- a/plugin-sdk/src/index.ts
+++ b/plugin-sdk/src/index.ts
@@ -322,6 +322,13 @@ export interface PluginRequest {
* providers; never Cookie/Authorization/session). Empty on authenticated routes.
* Verify a provider signature against a secret you hold in `ctx.config`/`ctx.settings`. */
headers: Record;
+ /**
+ * The RAW request body, base64-encoded — set only on `auth:false` routes (webhooks),
+ * `null` elsewhere. This is what you must run an HMAC over: `body` above is the PARSED
+ * value, and re-serializing it will not reproduce the exact bytes the sender signed
+ * (key order, whitespace and unicode escaping all differ), so the signature won't match.
+ */
+ rawBodyBase64?: string | null;
user: { id: number; username: string; isAdmin: boolean } | null;
}
export interface PluginResponse {
diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts
index ca49dc75a..4b31d7cbc 100644
--- a/plugin-sdk/src/manifest.ts
+++ b/plugin-sdk/src/manifest.ts
@@ -4,7 +4,7 @@
* result (no throw) so the CLI can print every problem at once.
*/
-import { validRange } from 'semver';
+import { minVersion, validRange } from 'semver';
export interface PluginDependency {
id: string;
@@ -15,6 +15,8 @@ export interface PluginManifest {
name: string;
version: string;
apiVersion: number;
+ /** The semver RANGE of TREK versions this plugin supports (">=3.2.0 <4.0.0"). */
+ trek: string;
type: 'integration' | 'page' | 'widget' | 'trip-page';
permissions: string[];
egress: string[];
@@ -93,6 +95,28 @@ function isKnownPermission(p: string): boolean {
return KNOWN_PERMISSIONS.includes(p) || p.startsWith('http:outbound:');
}
+/**
+ * A semver range a plugin may declare in `trek`. Mirrors the server's isValidTrekRange.
+ *
+ * validRange() alone is not enough: ">=4.0.0 <3.0.0" is a valid range no version can ever
+ * satisfy, so a plugin declaring it would be uninstallable everywhere with nothing to
+ * explain why. minVersion() is null for exactly that, and throws on junk like "latest".
+ */
+export function isSatisfiableRange(r: unknown): r is string {
+ if (typeof r !== 'string' || !r.trim()) return false;
+ if (validRange(r) === null) return false;
+ try {
+ return minVersion(r) !== null;
+ } catch {
+ return false;
+ }
+}
+
+/** A range that admits literally every TREK version, past and future ("*", "x", ">=0"). */
+export function isUnboundedRange(r: string): boolean {
+ return isSatisfiableRange(r) && validRange(r) === '*';
+}
+
export function validateManifest(raw: unknown): ValidationResult {
const errors: string[] = [];
const m = (raw && typeof raw === 'object' ? raw : {}) as Record;
@@ -107,6 +131,15 @@ export function validateManifest(raw: unknown): ValidationResult {
if (typeof m.id === 'string' && RESERVED_IDS.has(m.id)) errors.push(`id "${m.id}" is reserved`);
if (typeof m.version === 'string' && !SEMVER_RE.test(m.version)) errors.push(`version "${m.version}" is not semver`);
if (typeof m.type === 'string' && !TYPES.includes(m.type)) errors.push(`type must be one of ${TYPES.join('/')}`);
+ // TREK refuses to install a plugin that doesn't say which TREK versions it runs on, so
+ // an author must find that out here rather than from a rejected install.
+ 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"',
+ );
+ }
if (m.apiVersion !== undefined && typeof m.apiVersion !== 'number') errors.push('apiVersion must be a number');
if (m.nativeModules === true) errors.push('native modules are not allowed (v1)');
@@ -230,6 +263,7 @@ export function validateManifest(raw: unknown): ValidationResult {
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,
diff --git a/plugin-sdk/test/cli.test.ts b/plugin-sdk/test/cli.test.ts
index 43b0575d5..1c1424381 100644
--- a/plugin-sdk/test/cli.test.ts
+++ b/plugin-sdk/test/cli.test.ts
@@ -110,7 +110,7 @@ describe('scaffold + validate dependencies', () => {
});
describe('validateManifest dependency rules', () => {
- const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'] };
+ const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'], trek: '>=3.2.0 <4.0.0' };
it('accepts valid requiredAddons + pluginDependencies', () => {
const r = validateManifest({ ...base, requiredAddons: ['budget', 'journey'], pluginDependencies: [{ id: 'koffi', version: '>=1.0.0 <2.0.0' }] });
expect(r.ok).toBe(true);
@@ -205,7 +205,7 @@ describe('notification-channel template', () => {
describe('capabilities.notificationChannel validation', () => {
const base = {
id: 'chan', name: 'Chan', version: '1.0.0', apiVersion: 1, type: 'integration',
- nativeModules: false, permissions: ['hook:notification-channel'],
+ nativeModules: false, permissions: ['hook:notification-channel'], trek: '>=3.2.0 <4.0.0',
};
it('accepts a narrowed event list', () => {
diff --git a/plugin-sdk/test/publish.test.ts b/plugin-sdk/test/publish.test.ts
new file mode 100644
index 000000000..66b33ce4e
--- /dev/null
+++ b/plugin-sdk/test/publish.test.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import fs from 'node:fs';
+import os from 'node:os';
+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;
+
+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('');
+ }
+ // `git rev-parse ^{commit}` — pretend the tag does not exist yet.
+ if (bin === 'git' && args.includes('rev-parse')) throw new Error('unknown revision');
+ return Buffer.from('');
+ },
+}));
+
+const { publishPlugin } = await import('../src/cli/publish.js');
+const { scaffold } = await import('../src/cli/create.js');
+
+describe('publish — a released artifact is immutable', () => {
+ let tmp: string;
+ let dir: string;
+
+ beforeEach(() => {
+ calls.length = 0;
+ releaseExistsOnRemote = false;
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-'));
+ scaffold('immutable-plug', 'integration', tmp);
+ dir = path.join(tmp, 'immutable-plug');
+ });
+ afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
+
+ const run = (force?: boolean) => publishPlugin({
+ dir, repo: 'someone/trek-plugin-immutable-plug', tag: 'v1.0.0',
+ now: '2026-01-01T00:00:00Z', skipPreflight: true, force, log: () => {},
+ });
+
+ it('REFUSES to overwrite an existing release, and never uploads', async () => {
+ releaseExistsOnRemote = true;
+ // The registry pins the artifact's sha256. Rewriting the bytes of a release that is
+ // already in the registry breaks that pin for everyone who installed that version.
+ await expect(run()).rejects.toThrow(/already exists/i);
+ expect(calls.some((c) => c.bin === 'gh' && c.args.includes('upload'))).toBe(false);
+ expect(calls.some((c) => c.bin === 'gh' && c.args.includes('--clobber'))).toBe(false);
+ });
+
+ it('--force overwrites deliberately (for a release never merged into the registry)', async () => {
+ releaseExistsOnRemote = true;
+ await run(true).catch(() => {}); // submit/preflight are stubbed out; we only care about the gh calls
+ const upload = calls.find((c) => c.bin === 'gh' && c.args.includes('upload'));
+ expect(upload?.args).toContain('--clobber');
+ });
+
+ it('creates the release normally when it does not exist yet', async () => {
+ releaseExistsOnRemote = false;
+ await run().catch(() => {});
+ expect(calls.some((c) => c.bin === 'gh' && c.args[1] === 'create')).toBe(true);
+ expect(calls.some((c) => c.args.includes('--clobber'))).toBe(false);
+ });
+
+ it('keeps plugin.zip — the entry sha256 must be hashed from the uploaded bytes', async () => {
+ await run().catch(() => {});
+ // A re-pack on another machine/SDK version can produce different bytes (CRLF, walk
+ // order), so deleting the artifact after publishing made a follow-up `entry`/`sign` wrong.
+ expect(fs.existsSync(path.join(dir, 'plugin.zip'))).toBe(true);
+ });
+});
diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts
index 802a60ffd..f5eabec73 100644
--- a/plugin-sdk/test/sdk.test.ts
+++ b/plugin-sdk/test/sdk.test.ts
@@ -5,6 +5,7 @@ import path from 'node:path';
import zlib from 'node:zlib';
import { createRequire } from 'node:module';
import { definePlugin, PLUGIN_API_VERSION, validateManifest, createMockHost } from '../src/index.js';
+import { isUnboundedRange } from '../src/manifest.js';
import { scaffold } from '../src/cli/create.js';
import { validatePluginDir } from '../src/cli/validate.js';
import { makeZip, listZipNames } from '../src/zip.js';
@@ -44,7 +45,7 @@ describe('definePlugin + api version', () => {
});
describe('validateManifest', () => {
- const base = { id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', apiVersion: 1 };
+ const base = { id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', apiVersion: 1, trek: '>=3.2.0 <4.0.0' };
it('accepts a valid manifest', () => {
expect(validateManifest(base).ok).toBe(true);
});
@@ -587,14 +588,14 @@ describe('pack + entry (publishing automation)', () => {
it('refuses a plugin that ships a native binary', () => {
const bad = path.join(tmp, 'bad');
fs.mkdirSync(path.join(bad, 'server'), { recursive: true });
- fs.writeFileSync(path.join(bad, 'trek-plugin.json'), JSON.stringify({ id: 'bad-plug', name: 'Bad', version: '1.0.0', type: 'integration', permissions: [], egress: [] }));
+ fs.writeFileSync(path.join(bad, 'trek-plugin.json'), JSON.stringify({ id: 'bad-plug', name: 'Bad', version: '1.0.0', type: 'integration', permissions: [], egress: [], trek: '>=3.2.0 <4.0.0' }));
fs.writeFileSync(path.join(bad, 'server', 'index.js'), 'module.exports={}');
fs.writeFileSync(path.join(bad, 'server', 'thing.node'), Buffer.from([1, 2, 3]));
fs.writeFileSync(path.join(bad, 'README.md'), '# Bad\n\ncontent');
expect(() => packPluginDir(bad, path.join(tmp, 'x.zip'))).toThrow(/native binaries/);
});
- it('builds a registry entry with sha256/size/commit/minTrekVersion filled in', () => {
+ it('builds a registry entry with sha256/size/commit/trek filled in', () => {
const out = path.join(tmp, 'plugin.zip');
const packed = packPluginDir(koffi, out);
const entry = buildEntry({
@@ -607,11 +608,43 @@ describe('pack + entry (publishing automation)', () => {
expect(v.sha256).toBe(packed.sha256);
expect(v.size).toBe(packed.size);
expect(v.commitSha).toBe('a'.repeat(40));
- expect(v.minTrekVersion).toBe('3.2.0');
+ // The RAW range is the one compatibility field an entry carries. minTrekVersion is gone:
+ // it restated the range's lower bound in a weaker form (it cannot express the exclusive
+ // upper bound), so the entry used to drop "<4.0.0" silently and a TREK 4 server read
+ // "requires 3.2.0+" and considered the plugin compatible.
+ expect(v.trek).toBe('>=3.2.0 <4.0.0');
+ expect(v).not.toHaveProperty('minTrekVersion');
expect(v.downloadUrl).toBe('https://github.com/mauriceboe/trek-plugin-koffi/releases/download/v1.0.0/plugin.zip');
expect(v.nativeModules).toBe(false);
});
+ it('publishes an upper-bound-only range verbatim, with no floor to get wrong', () => {
+ // This is why the derived floor is gone. "<4.0.0" has a first X.Y.Z of 4.0.0 but a lower
+ // bound of 0.0.0, and the old regex published 4.0.0 as the MINIMUM — advertising a plugin
+ // that supports everything BELOW 4 as requiring 4+, the precise inverse of what its author
+ // wrote. A field that only ever restates the range cannot be wrong if it does not exist.
+ const entry = buildEntry({ ...entryOptsFor('upper-only', '<4.0.0'), now: '2026-07-04T00:00:00.000Z' });
+ expect(entry.versions[0].trek).toBe('<4.0.0');
+ expect(entry.versions[0]).not.toHaveProperty('minTrekVersion');
+ });
+
+ it('refuses to publish a plugin with no usable trek range', () => {
+ expect(() => buildEntry({ ...entryOptsFor('no-trek', undefined), now: '2026-07-04T00:00:00.000Z' }))
+ .toThrow(/no valid "trek" version range/);
+ expect(() => buildEntry({ ...entryOptsFor('bad-trek', '>=4.0.0 <3.0.0'), now: '2026-07-04T00:00:00.000Z' }))
+ .toThrow(/no valid "trek" version range/);
+ });
+
+ /** A minimal plugin dir + artifact for buildEntry (which only needs the zip to exist). */
+ function entryOptsFor(id: string, trek: string | undefined) {
+ const dir = path.join(tmp, id);
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, 'trek-plugin.json'), JSON.stringify({ id, name: id, version: '1.0.0', type: 'integration', ...(trek === undefined ? {} : { trek }) }));
+ const zipPath = path.join(tmp, `${id}.zip`);
+ fs.writeFileSync(zipPath, makeZip([{ name: 'trek-plugin.json', data: Buffer.from('{}') }]));
+ return { dir, repo: 'a/b', tag: 'v1.0.0', zipPath, commit: 'a'.repeat(40) };
+ }
+
it('--merge prepends the new version onto an existing entry, newest-first', () => {
const out = path.join(tmp, 'plugin.zip');
packPluginDir(koffi, out);
@@ -808,7 +841,7 @@ describe('mock-host inter-plugin (plugins.call + events.emit)', () => {
});
describe('validateManifest capabilities.provides/emits', () => {
- const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'] };
+ const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'], trek: '>=3.2.0 <4.0.0' };
it('accepts well-formed provides + emits', () => {
const r = validateManifest({ ...base, capabilities: { provides: ['computeRate'], emits: ['rate.updated'] } });
expect(r.ok).toBe(true);
@@ -818,3 +851,37 @@ describe('validateManifest capabilities.provides/emits', () => {
expect(validateManifest({ ...base, capabilities: { emits: 'nope' } }).ok).toBe(false);
});
});
+
+/**
+ * The `trek` range is what TREK gates installs on, so it has to be caught HERE — the same
+ * validateManifest the registry CI runs — rather than by a rejected install.
+ */
+describe('validateManifest: the trek range', () => {
+ const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'] };
+
+ it('requires a range', () => {
+ const r = validateManifest({ ...base });
+ expect(r.ok).toBe(false);
+ expect(r.errors.join()).toMatch(/missing "trek"/);
+ });
+
+ it('rejects a range that is not semver, and one nothing can satisfy', () => {
+ expect(validateManifest({ ...base, trek: '3.2+' }).ok).toBe(false);
+ // Syntactically valid, semantically empty: no TREK could ever run it.
+ expect(validateManifest({ ...base, trek: '>=4.0.0 <3.0.0' }).ok).toBe(false);
+ });
+
+ it('accepts the ranges authors really write, and carries the range through', () => {
+ for (const trek of ['>=3.2.0 <4.0.0', '^3.2.0', '>=3']) {
+ const r = validateManifest({ ...base, trek });
+ expect(r.ok, trek).toBe(true);
+ expect(r.manifest?.trek).toBe(trek);
+ }
+ });
+
+ it('allows "*" (it is a legal claim) but validate warns — it is nearly always a lie', () => {
+ expect(validateManifest({ ...base, trek: '*' }).ok).toBe(true);
+ expect(isUnboundedRange('*')).toBe(true);
+ expect(isUnboundedRange('>=3.2.0 <4.0.0')).toBe(false);
+ });
+});
diff --git a/plugin-sdk/test/verify-signature.test.ts b/plugin-sdk/test/verify-signature.test.ts
new file mode 100644
index 000000000..c7eda08ec
--- /dev/null
+++ b/plugin-sdk/test/verify-signature.test.ts
@@ -0,0 +1,103 @@
+/**
+ * The SDK's author-signature verifier.
+ *
+ * It exists so `preflight` can answer the question the REGISTRY's CI will ask, before the
+ * author opens the PR. That makes agreement with the other two implementations the entire
+ * point: TREK's host verifier (server/src/nest/plugins/install/verify-signature.ts) and the
+ * registry's port of it (TREK-Plugins scripts/lib/verify-signature.mjs). A gate that is
+ * merely *similar* is worse than none — a green preflight is trusted, and an entry the
+ * registry rejects is one nobody can install.
+ */
+import { describe, it, expect } from 'vitest';
+import crypto from 'node:crypto';
+import { verifyAuthorSignature, checkSignatureShape, SignatureError } from '../src/cli/verify-signature.js';
+import { generateKeypair, signArtifact, publicKeyBase64, loadPrivateKey } from '../src/cli/sign.js';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+/** A throwaway Ed25519 keypair, via the SDK's own keygen (not a hand-rolled one). */
+function keypair(): { key: crypto.KeyObject; pub: string } {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-sig-'));
+ const keyPath = path.join(dir, 'signing.key');
+ generateKeypair(keyPath);
+ const key = loadPrivateKey(keyPath);
+ return { key, pub: publicKeyBase64(key) };
+}
+
+const BYTES = Buffer.from('a plugin artifact, pretend this is a zip');
+
+describe('verifyAuthorSignature', () => {
+ // The round-trip that actually ships: `sign` emits a bare 64-byte signature over the raw
+ // bytes and `keygen` a bare 32-byte key, and all three verifiers must accept that pair.
+ it('accepts what the SDK itself signs', () => {
+ const { key, pub } = keypair();
+ expect(verifyAuthorSignature(BYTES, signArtifact(BYTES, key), pub)).toBe(true);
+ });
+
+ it('rejects a signature over DIFFERENT bytes (a re-pack after signing)', () => {
+ const { key, pub } = keypair();
+ const sig = signArtifact(BYTES, key);
+ expect(verifyAuthorSignature(Buffer.concat([BYTES, Buffer.from('!')]), sig, pub)).toBe(false);
+ });
+
+ it('rejects a signature made with a DIFFERENT key', () => {
+ const a = keypair();
+ const b = keypair();
+ expect(verifyAuthorSignature(BYTES, signArtifact(BYTES, a.key), b.pub)).toBe(false);
+ });
+
+ // A well-formed-but-wrong signature returns false; a MALFORMED one throws. The host draws
+ // exactly this line (SignatureError vs a false return), and the registry mirrors it — both
+ // end up refusing the install, but only the throw carries a "your key is garbage" message.
+ it('throws SignatureError on a malformed key or signature, rather than returning false', () => {
+ const { key, pub } = keypair();
+ const sig = signArtifact(BYTES, key);
+ expect(() => verifyAuthorSignature(BYTES, sig, 'not-base64-at-all!!')).toThrow(SignatureError);
+ expect(() => verifyAuthorSignature(BYTES, 'AAAA', pub)).toThrow(SignatureError);
+ expect(() => verifyAuthorSignature(BYTES, sig, '')).toThrow(SignatureError);
+ });
+
+ // The two-line minisign form, which an author may paste in from a `minisign` keypair. The
+ // comment line must be skipped rather than parsed as the payload.
+ it('accepts a minisign-style key with an "untrusted comment" line', () => {
+ const { key, pub } = keypair();
+ const sig = signArtifact(BYTES, key);
+ const twoLine = `untrusted comment: minisign public key ABC\n${pub}\n`;
+ expect(verifyAuthorSignature(BYTES, sig, twoLine)).toBe(true);
+ });
+});
+
+describe('checkSignatureShape', () => {
+ it('passes an entry that is unsigned throughout, and one that is fully signed', () => {
+ const { key, pub } = keypair();
+ const sig = signArtifact(BYTES, key);
+ expect(checkSignatureShape({ versions: [{ version: '1.0.0' }] })).toEqual([]);
+ expect(checkSignatureShape({ authorPublicKey: pub, versions: [{ version: '1.0.0', signature: sig }] })).toEqual([]);
+ });
+
+ // Both halves of a half-signed entry. TREK refuses to install either ("incomplete
+ // signature: an author key and a version signature must both be present"), so merging one
+ // ships an entry nobody can use — the point is to catch it before the PR, not at review.
+ it('refuses a signature with no key', () => {
+ const { key } = keypair();
+ const problems = checkSignatureShape({ versions: [{ version: '1.0.0', signature: signArtifact(BYTES, key) }] });
+ expect(problems).toHaveLength(1);
+ expect(problems[0]).toMatch(/no authorPublicKey/);
+ });
+
+ it('refuses a key with no signed version', () => {
+ const { pub } = keypair();
+ const problems = checkSignatureShape({ authorPublicKey: pub, versions: [{ version: '1.0.0' }] });
+ expect(problems).toHaveLength(1);
+ expect(problems[0]).toMatch(/no version carries a signature/);
+ });
+
+ it('reports a malformed key and a malformed signature by name', () => {
+ const { key, pub } = keypair();
+ expect(checkSignatureShape({ authorPublicKey: 'nonsense', versions: [{ version: '1.0.0', signature: signArtifact(BYTES, key) }] })[0])
+ .toMatch(/authorPublicKey is not a valid/);
+ expect(checkSignatureShape({ authorPublicKey: pub, versions: [{ version: '2.1.0', signature: 'AAAA' }] })[0])
+ .toMatch(/2\.1\.0: signature is malformed/);
+ });
+});
diff --git a/server/package.json b/server/package.json
index c06d00bb0..b68e23bce 100644
--- a/server/package.json
+++ b/server/package.json
@@ -22,7 +22,7 @@
"test:coverage": "vitest run --coverage"
},
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.28.0",
+ "@modelcontextprotocol/sdk": "^1.29.0",
"@nestjs/common": "^11.1.24",
"@nestjs/core": "^11.1.24",
"@nestjs/platform-express": "^11.1.24",
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index a8e958772..785d3a835 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -3626,6 +3626,40 @@ function runMigrations(db: Database.Database): void {
);
`);
},
+
+ // Why a plugin's update was REFUSED by the signature check (#plugins). A refused
+ // update leaves a working plugin pinned at its old version — previously the reason
+ // lived only in a transient toast, so the plugin quietly stopped updating and the
+ // admin had to re-attempt an update to rediscover why. Record it instead.
+ //
+ // `update_block_version` is the registry version that was refused: once the registry
+ // offers something NEWER, the block describes an artifact nobody is being offered
+ // anymore, so it reads as stale and the admin can just re-attempt (the next install
+ // re-verifies and either succeeds or re-blocks with fresh values). Deliberately no
+ // `status = 'error'` — the plugin still runs fine on its old code.
+ () => {
+ for (const col of ['update_block_code TEXT', 'update_block_detail TEXT', 'update_block_version TEXT']) {
+ try {
+ db.exec(`ALTER TABLE plugins ADD COLUMN ${col};`);
+ } catch (err) {
+ console.warn('[migrations] Non-fatal migration step failed:', err);
+ }
+ }
+ },
+
+ // The semver RANGE of TREK versions a plugin declares it supports (its manifest's
+ // `trek`, e.g. ">=3.2.0 <4.0.0"). The existing `min_trek_version` only carries the
+ // lower bound, so it cannot express "stops working at 4.0" — which is precisely the
+ // case the activation gate has to catch after a TREK upgrade. Kept nullable: a plugin
+ // installed before this column existed has no range recorded, and the gate refuses to
+ // activate it rather than guessing (see TREK_VERSION_UNKNOWN).
+ () => {
+ try {
+ db.exec('ALTER TABLE plugins ADD COLUMN trek_range TEXT;');
+ } catch (err) {
+ console.warn('[migrations] Non-fatal migration step failed:', err);
+ }
+ },
];
if (currentVersion < migrations.length) {
diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts
index a86ca7f15..d7c85bae0 100644
--- a/server/src/mcp/index.ts
+++ b/server/src/mcp/index.ts
@@ -9,7 +9,7 @@ import { isAddonEnabled } from '../services/adminService';
import { ADDON_IDS } from '../addons';
import { registerResources } from './resources';
import { registerTools } from './tools';
-import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
+import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient, evictOldestSessionForUser } from './sessionManager';
import { resolveSessionTtlMs, resolveKeepaliveMs } from './config';
import { writeAudit, getClientIp } from '../services/auditLog';
import { getMcpSafeUrl } from '../services/notifications';
@@ -183,6 +183,12 @@ const sessionSweepInterval = setInterval(() => {
// Prevent the interval from keeping the process alive if nothing else is running
sessionSweepInterval.unref();
+/** JSON-RPC 2.0 error body. MCP clients parse this and show `message`; a bare `{ error }`
+ * envelope isn't valid JSON-RPC, so they fall back to a generic "tool execution failed". */
+function jsonRpcError(message: string, code = -32000): object {
+ return { jsonrpc: '2.0', error: { code, message }, id: null };
+}
+
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
const base = (getMcpSafeUrl() || '').replace(/\/+$/, '');
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
@@ -304,9 +310,25 @@ export async function mcpHandler(req: Request, res: Response): Promise {
return;
}
+ // A client that reuses its session sends the header on every call after initialize, so a
+ // steady stream of session-less POSTs means the id isn't making it back to the client —
+ // usually a reverse proxy dropping Mcp-Session-Id, or a client that ignores it. Say so,
+ // because the visible symptom (sessions piling up to the cap) points nowhere near the cause.
+ console.warn(
+ `[MCP] POST without mcp-session-id for user ${user.id} — starting a new session. ` +
+ 'If this repeats on every tool call, the Mcp-Session-Id response header is not reaching ' +
+ 'the client (check that your reverse proxy forwards it).',
+ );
+
+ // At the cap, evict this user's coldest session rather than refusing the request: a client
+ // stuck re-initializing would otherwise be locked out until the process restarted.
if (countSessionsForUser(user.id) >= MAX_SESSIONS_PER_USER) {
- res.status(429).json({ error: 'Session limit reached. Close an existing session before opening a new one.' });
- return;
+ const evicted = evictOldestSessionForUser(user.id);
+ if (!evicted) {
+ res.status(429).json(jsonRpcError('Session limit reached. Close an existing session before opening a new one.'));
+ return;
+ }
+ console.log(`[MCP] Session limit (${MAX_SESSIONS_PER_USER}) reached for user ${user.id} — evicted idle session ${evicted}`);
}
// Create a new per-user MCP server and session
@@ -348,6 +370,10 @@ export async function mcpHandler(req: Request, res: Response): Promise {
sessions.delete(sid);
},
});
+ // Belt-and-braces: whenever the SDK closes the transport for any reason, drop the map entry.
+ transport.onclose = () => {
+ if (transport.sessionId) sessions.delete(transport.sessionId);
+ };
logToolCallAudit(req, user.id, clientId);
armSseKeepalive(res);
@@ -359,6 +385,15 @@ export async function mcpHandler(req: Request, res: Response): Promise {
if (!res.headersSent) {
res.status(500).json({ error: 'Internal MCP error' });
}
+ } finally {
+ // Only an `initialize` request assigns a sessionId (via onsessioninitialized). Any other
+ // session-less POST is rejected by the SDK with "Server not initialized" — and this server,
+ // with its ~200 registered tools, would otherwise be orphaned: never in `sessions`, never
+ // swept, never closed. Reap it here.
+ if (!transport.sessionId) {
+ try { server.close(); } catch { /* ignore */ }
+ try { transport.close(); } catch { /* ignore */ }
+ }
}
}
diff --git a/server/src/mcp/sessionManager.ts b/server/src/mcp/sessionManager.ts
index 276a15730..cad993b5e 100644
--- a/server/src/mcp/sessionManager.ts
+++ b/server/src/mcp/sessionManager.ts
@@ -16,14 +16,18 @@ export interface McpSession {
export const sessions = new Map();
+/** Close both halves of a session and drop it from the map. Close errors are non-fatal:
+ * a transport whose socket is already gone must not block eviction of the map entry. */
+function closeSession(sid: string, session: McpSession): void {
+ try { session.server.close(); } catch { /* ignore */ }
+ try { session.transport.close(); } catch { /* ignore */ }
+ sessions.delete(sid);
+}
+
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
export function revokeUserSessions(userId: number): void {
for (const [sid, session] of sessions) {
- if (session.userId === userId) {
- try { session.server.close(); } catch { /* ignore */ }
- try { session.transport.close(); } catch { /* ignore */ }
- sessions.delete(sid);
- }
+ if (session.userId === userId) closeSession(sid, session);
}
}
@@ -32,10 +36,31 @@ export function revokeUserSessions(userId: number): void {
* sessions are closed, not sessions from other clients for the same user. */
export function revokeUserSessionsForClient(userId: number, clientId: string): void {
for (const [sid, session] of sessions) {
- if (session.userId === userId && session.clientId === clientId) {
- try { session.server.close(); } catch { /* ignore */ }
- try { session.transport.close(); } catch { /* ignore */ }
- sessions.delete(sid);
- }
+ if (session.userId === userId && session.clientId === clientId) closeSession(sid, session);
}
}
+
+/**
+ * Close the least-recently-active session for a user so a new one can take its slot,
+ * and return its id (null when the user has no sessions).
+ *
+ * This is what keeps the per-user cap from becoming a dead end. A client that cannot
+ * persist its Mcp-Session-Id — a proxy stripping the header, a non-conformant client —
+ * re-initializes on every call, and a hard cap would leave the user permanently unable
+ * to open a session until the process restarted. Evicting the coldest session instead
+ * means the worst case is a bounded ring of sessions, not a wedged integration.
+ */
+export function evictOldestSessionForUser(userId: number): string | null {
+ let oldestSid: string | null = null;
+ let oldestSession: McpSession | null = null;
+ for (const [sid, session] of sessions) {
+ if (session.userId !== userId) continue;
+ if (!oldestSession || session.lastActivity < oldestSession.lastActivity) {
+ oldestSid = sid;
+ oldestSession = session;
+ }
+ }
+ if (!oldestSid || !oldestSession) return null;
+ closeSession(oldestSid, oldestSession);
+ return oldestSid;
+}
diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts
index e430b0f14..74240b6c8 100644
--- a/server/src/middleware/globalMiddleware.ts
+++ b/server/src/middleware/globalMiddleware.ts
@@ -82,6 +82,14 @@ export function applyGlobalMiddleware(
// answer OPTIONS without Access-Control-Allow-Origin before the SDK's own cors() runs.
// All /.well-known/* paths get open CORS so clients probing openid-configuration or the
// RFC 8414 path-suffixed AS metadata form don't get CORS-blocked (they get 404 JSON instead).
+ //
+ // `exposedHeaders` is load-bearing, not cosmetic. Without Access-Control-Expose-Headers the
+ // Fetch spec forbids a browser-context client (Claude Desktop connectors, Claude.ai, MCP
+ // Inspector) from *reading* Mcp-Session-Id off the initialize response — so it can never echo
+ // the header back, every request looks like a fresh initialize, and the server mints a new
+ // session per tool call until the per-user cap wedges the connection. Same reasoning for
+ // WWW-Authenticate, which carries the RFC 9728 resource-metadata challenge that drives
+ // OAuth discovery.
app.use(
(req: Request, _res: Response, next: NextFunction) => {
if (
@@ -91,7 +99,11 @@ export function applyGlobalMiddleware(
req.path === '/oauth/userinfo' ||
req.path === '/mcp'
) {
- cors({ origin: '*', credentials: false })(req, _res, next);
+ cors({
+ origin: '*',
+ credentials: false,
+ exposedHeaders: ['Mcp-Session-Id', 'MCP-Protocol-Version', 'WWW-Authenticate'],
+ })(req, _res, next);
} else {
next();
}
diff --git a/server/src/nest/plugins/host/rpc-host.ts b/server/src/nest/plugins/host/rpc-host.ts
index 3b5501290..3db712c32 100644
--- a/server/src/nest/plugins/host/rpc-host.ts
+++ b/server/src/nest/plugins/host/rpc-host.ts
@@ -303,8 +303,11 @@ export class PluginRpcHost {
this.methods.set('trips.getById', (p, uid) =>
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM trips WHERE id = ?').get(num(p.tripId, 'tripId'))),
);
+ // The trip's place POOL — places carry no itinerary position of their own
+ // (day_id/order_index live on day_assignments), so order by created_at like
+ // the REST list does. Use trips.getDays for the day-ordered itinerary.
this.methods.set('trips.getPlaces', (p, uid) =>
- this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM places WHERE trip_id = ? ORDER BY day_id, position').all(num(p.tripId, 'tripId'))),
+ this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM places WHERE trip_id = ? ORDER BY created_at DESC').all(num(p.tripId, 'tripId'))),
);
// Hydrated like the REST list (endpoints, day_positions, joins, normalized
// accommodation_id) — a strict superset of the raw row, so older callers
diff --git a/server/src/nest/plugins/install/discovery.ts b/server/src/nest/plugins/install/discovery.ts
index 7e28100c1..2e000926d 100644
--- a/server/src/nest/plugins/install/discovery.ts
+++ b/server/src/nest/plugins/install/discovery.ts
@@ -67,15 +67,15 @@ function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
if (existing) {
db.prepare(
`UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, api_version = ?,
- min_trek_version = ?, permissions = ?, capabilities = ?, dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
- ).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0, m.id);
+ min_trek_version = ?, trek_range = ?, permissions = ?, capabilities = ?, dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
+ ).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, m.trekRange, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0, m.id);
} else {
db.prepare(
// granted_permissions '' (empty, not '[]') marks "never consented" so the
// first activation is distinguishable from a plugin consented to zero perms.
- `INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, dependencies, operator_egress, granted_permissions, status)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`,
- ).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0);
+ `INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, trek_range, permissions, capabilities, dependencies, operator_egress, granted_permissions, status)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`,
+ ).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, m.trekRange, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0);
}
// Refresh the settings-page action descriptors from the manifest.
diff --git a/server/src/nest/plugins/install/host-compat.ts b/server/src/nest/plugins/install/host-compat.ts
new file mode 100644
index 000000000..bb11c8539
--- /dev/null
+++ b/server/src/nest/plugins/install/host-compat.ts
@@ -0,0 +1,106 @@
+import semver from 'semver';
+
+/**
+ * Host-version compatibility for plugins (#plugins). A manifest declares the TREK
+ * versions it supports as a semver RANGE (`"trek": ">=3.2.0 <4.0.0"`); this module
+ * is the single place that answers "what TREK is running" and "does that range admit
+ * it". Every install front door (registry, sideload, dev-link) and the activation
+ * gate go through here, so a plugin can never run against a host it says it doesn't
+ * support — and, just as importantly, they all answer the question the same way.
+ */
+
+/** The running TREK version (same source as the rest of the app). */
+export function hostVersion(): string {
+ return process.env.APP_VERSION || (require('../../../../package.json') as { version: string }).version;
+}
+
+let warnedUnparseable = false;
+
+/**
+ * The running version as a plain release triple, or null when it can't be parsed at all.
+ *
+ * Prereleases are deliberately COERCED to their target release: 3.4.0-rc.1 compares as
+ * 3.4.0, and 4.0.0-rc.1 as 4.0.0. Both halves of that matter, and semver's own ordering
+ * gets both wrong for this purpose:
+ *
+ * - Plain `satisfies()` excludes a prerelease from any range that doesn't name one, so
+ * 3.4.0-rc.1 would fail ">=3.2.0 <4.0.0" and EVERY plugin on the instance would go
+ * incompatible the moment TREK shipped a release candidate.
+ * - `includePrerelease` fixes that but orders 4.0.0-rc.1 BEFORE 4.0.0, so it satisfies
+ * "<4.0.0" — letting a plugin that disclaims TREK 4 load on a build that already
+ * carries TREK 4's breaking changes. That is the precise failure this gate exists to
+ * prevent.
+ *
+ * Coercing to the release says the useful thing instead: for compatibility purposes, an
+ * rc of 4.0 IS 4.0.
+ *
+ * A null host NEVER blocks anything (see {@link hostSatisfies}). This is deliberate:
+ * APP_VERSION is a Docker build ARG that defaults to the literal string `dev`, and an
+ * unversioned build must stay fully usable. The warning exists so that a MISCONFIGURED
+ * APP_VERSION in production — which silently switches this whole gate off — is visible
+ * in the logs instead of being discovered when an incompatible plugin misbehaves.
+ */
+export function normalizedHost(): string | null {
+ const raw = hostVersion();
+ const coerced = semver.coerce(raw)?.version ?? null;
+ if (!coerced && !warnedUnparseable) {
+ warnedUnparseable = true;
+ console.warn(`[plugins] APP_VERSION "${raw}" is not a semver version — plugin TREK-compatibility checks are disabled`);
+ }
+ return coerced;
+}
+
+/** Test hook: re-arm the one-time unparseable-host warning. */
+export function __resetHostWarningForTests(): void {
+ warnedUnparseable = false;
+}
+
+/**
+ * Whether `r` is a semver range a plugin may declare.
+ *
+ * `semver.validRange` alone is not enough: ">=4.0.0 <3.0.0" is a VALID range that no
+ * version can ever satisfy, and a plugin that declares it would be uninstallable with
+ * no way to tell the author why. minVersion() returns null for exactly that case (and
+ * throws outright on garbage like "latest"), so it is the real satisfiability test.
+ */
+export function isValidTrekRange(r: unknown): r is string {
+ if (typeof r !== 'string' || !r.trim()) return false;
+ if (semver.validRange(r) === null) return false;
+ try {
+ return semver.minVersion(r) !== null;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * The lowest TREK version a range admits — what the UI shows as "Requires TREK x+".
+ * Undefined for a range with no real lower bound (`*`, `<4.0.0`), because "requires
+ * TREK 0.0.0+" is noise rather than information.
+ */
+export function minTrekOf(range: string): string | undefined {
+ if (!isValidTrekRange(range)) return undefined;
+ const min = semver.minVersion(range);
+ if (!min || min.version === '0.0.0') return undefined;
+ return min.version;
+}
+
+/**
+ * Whether the running TREK satisfies `range`. The host arrives here already coerced to a
+ * release triple (see {@link normalizedHost}), which is what makes a plain satisfies()
+ * correct on both prerelease edges.
+ *
+ * A null/empty range is not a decision this function can make — the caller knows whether
+ * an undeclared range means "reject" (an install) or "unknown" (an already-installed
+ * plugin), and they surface different codes. It returns false so neither can pass by
+ * accident.
+ */
+export function hostSatisfies(range: string | null | undefined, host: string | null = normalizedHost()): boolean {
+ // Coerce again rather than trusting the argument: callers that pass a host explicitly
+ // (the registry, tests) would otherwise skip the prerelease normalisation and get a
+ // different answer than the default path for the same TREK.
+ const release = host === null ? null : (semver.coerce(host)?.version ?? null);
+ if (release === null) return true; // unversioned build — never block
+ if (!isValidTrekRange(range)) return false;
+ return semver.satisfies(release, range);
+}
diff --git a/server/src/nest/plugins/install/manifest.ts b/server/src/nest/plugins/install/manifest.ts
index 2009070cd..98f59ec53 100644
--- a/server/src/nest/plugins/install/manifest.ts
+++ b/server/src/nest/plugins/install/manifest.ts
@@ -1,5 +1,6 @@
import semver from 'semver';
import { isKnownPermission } from '../protocol/envelope';
+import { isValidTrekRange, minTrekOf } from './host-compat';
import type { NotifEventType } from '../../../services/notificationPreferencesService';
/**
@@ -87,7 +88,11 @@ export interface PluginManifest {
homepage?: string;
icon?: string;
type: 'integration' | 'page' | 'widget' | 'trip-page';
+ /** The raw semver RANGE of TREK versions this plugin supports (">=3.2.0 <4.0.0"). */
trek?: string;
+ /** Same range, normalized: a valid+satisfiable one, or null. What the host gates on. */
+ trekRange: string | null;
+ /** The range's lower bound, for display ("Requires TREK 3.2.0+"). Derived, never authored. */
minTrekVersion?: string;
nativeModules: boolean;
permissions: string[];
@@ -134,7 +139,19 @@ export function parseJsonText(text: string): unknown {
return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
}
-export function parseManifest(raw: unknown): PluginManifest {
+/**
+ * Validate a manifest.
+ *
+ * `requireTrek` is the INSTALL front doors (registry, sideload, dev-link): a plugin
+ * that doesn't say which TREK versions it supports may not be installed at all.
+ *
+ * It is deliberately OFF for discovery, which is a reconciler, not a gate. Discovery's
+ * failure path only logs and skips — it never touches the plugins row — so a plugin it
+ * rejects keeps its stale `enabled = 1` and gets spawned by the next boot anyway. Making
+ * this strict there would produce a plugin that is invisible to discovery and still
+ * running. Activation is where an undeclared range is actually refused.
+ */
+export function parseManifest(raw: unknown, opts?: { requireTrek?: boolean }): PluginManifest {
if (!raw || typeof raw !== 'object') throw new ManifestError('manifest is not an object');
const m = raw as Record;
@@ -183,6 +200,14 @@ export function parseManifest(raw: unknown): PluginManifest {
if (badEgress !== undefined) throw new ManifestError(`invalid egress host "${badEgress}"`);
const trek = optStr(m.trek);
+ const trekRange = isValidTrekRange(trek) ? trek : null;
+ if (opts?.requireTrek && !trekRange) {
+ throw new ManifestError(
+ trek
+ ? `invalid "trek" version range "${trek}" (expected a satisfiable semver range, e.g. ">=3.2.0 <4.0.0")`
+ : 'missing "trek" version range — declare the TREK versions this plugin supports, e.g. ">=3.2.0 <4.0.0"',
+ );
+ }
return {
id,
name: str(m.name, 'name'),
@@ -194,7 +219,11 @@ export function parseManifest(raw: unknown): PluginManifest {
icon: optStr(m.icon) ?? 'Blocks',
type: type as PluginManifest['type'],
trek,
- minTrekVersion: trek ? (trek.match(/(\d+\.\d+\.\d+)/)?.[1] ?? undefined) : undefined,
+ trekRange,
+ // Derived from the range itself, never from the first number that looks like a
+ // version in it: a range of "<4.0.0" has a *first* semver of 4.0.0 but a lower
+ // bound of 0.0.0, so reading it off the text produced the exact inverse of the truth.
+ minTrekVersion: trekRange ? minTrekOf(trekRange) : undefined,
nativeModules: false,
permissions,
egress,
diff --git a/server/src/nest/plugins/paths.ts b/server/src/nest/plugins/paths.ts
index 19902ffcd..a0ebc9e86 100644
--- a/server/src/nest/plugins/paths.ts
+++ b/server/src/nest/plugins/paths.ts
@@ -119,7 +119,6 @@ export function pluginPermissionArgs(pluginId: string): string[] {
// off by accident.
if (!warnedPermissionsOff) {
warnedPermissionsOff = true;
- // eslint-disable-next-line no-console
console.warn('[plugins] TREK_PLUGIN_PERMISSIONS=off — the OS permission jail is DISABLED; installed plugins run with full Node access to this process. Only use this on a machine you fully trust.');
}
return [];
diff --git a/server/src/nest/plugins/plugin-backup.ts b/server/src/nest/plugins/plugin-backup.ts
index 1ec55d047..04df3464c 100644
--- a/server/src/nest/plugins/plugin-backup.ts
+++ b/server/src/nest/plugins/plugin-backup.ts
@@ -111,7 +111,6 @@ export function applyStagedPluginTrees(): string[] {
swapContents(live, staged);
applied.push(label);
} catch (err) {
- // eslint-disable-next-line no-console
console.error(`[plugins] failed to apply staged ${label} restore:`, err);
}
}
@@ -135,7 +134,6 @@ export async function applyStagedRestoreNow(): Promise {
await applier();
return true;
} catch (err) {
- // eslint-disable-next-line no-console
console.error('[plugins] immediate staged-restore apply failed; will retry on next boot:', err);
return false;
}
diff --git a/server/src/nest/plugins/plugin-frame.controller.ts b/server/src/nest/plugins/plugin-frame.controller.ts
index 4e98f9b24..08959bd06 100644
--- a/server/src/nest/plugins/plugin-frame.controller.ts
+++ b/server/src/nest/plugins/plugin-frame.controller.ts
@@ -67,12 +67,20 @@ export class PluginFrameController {
/** Per-plugin, locked-down CSP for the sandboxed frame document. */
private frameCsp(pluginId: string, host: string | undefined): string {
- // Defense in depth: the manifest validator already constrains these hosts, but
- // never interpolate anything that isn't a clean host/wildcard into connect-src
- // (a stray space or `*` would inject an extra CSP source token).
- const outbound = this.runtime
- .outboundHostsOf(pluginId)
- .filter((h) => /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i.test(h));
+ // The frame must be able to reach exactly what the CHILD may reach. The child's egress
+ // guard is the union of the manifest's http:outbound: grants AND the hosts an admin
+ // added post-install for an operatorEgress plugin (plugin-runtime.service: activate()).
+ // Building connect-src from the grants alone left an operatorEgress plugin WITH A UI able
+ // to call the operator's host from its server but CSP-blocked in its own iframe. The admin
+ // consented to these hosts at install and the child already reaches them, so matching the
+ // frame to the child widens no trust boundary that isn't already crossed.
+ //
+ // Defense in depth: both sources are validated upstream (the manifest validator; the
+ // admin writer's EGRESS_HOST_RE), but never interpolate anything that isn't a clean
+ // host/wildcard into connect-src — a stray space or `*` would inject an extra CSP source.
+ const outbound = [
+ ...new Set([...this.runtime.outboundHostsOf(pluginId), ...this.runtime.operatorEgressHosts(pluginId)]),
+ ].filter((h) => /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i.test(h));
// The frame runs at an OPAQUE origin (sandbox without allow-same-origin), so
// 'self' matches nothing and the plugin's own