)}
diff --git a/client/src/store/pluginStore.ts b/client/src/store/pluginStore.ts
index ae098b999..7e327a807 100644
--- a/client/src/store/pluginStore.ts
+++ b/client/src/store/pluginStore.ts
@@ -12,6 +12,7 @@ export interface ActivePlugin {
name: string
type: 'integration' | 'page' | 'widget'
icon: string | null
+ slot?: 'sidebar' | 'hero'
}
interface PluginState {
@@ -21,6 +22,7 @@ interface PluginState {
getById: (id: string) => ActivePlugin | undefined
pages: () => ActivePlugin[]
widgets: () => ActivePlugin[]
+ heroWidgets: () => ActivePlugin[]
}
export const usePluginStore = create((set, get) => ({
@@ -38,5 +40,6 @@ export const usePluginStore = create((set, get) => ({
getById: (id) => get().plugins.find((p) => p.id === id),
pages: () => get().plugins.filter((p) => p.type === 'page'),
- widgets: () => get().plugins.filter((p) => p.type === 'widget'),
+ widgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot !== 'hero'),
+ heroWidgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot === 'hero'),
}))
diff --git a/client/src/styles/dashboard.css b/client/src/styles/dashboard.css
index 4a1d74323..321c6d159 100644
--- a/client/src/styles/dashboard.css
+++ b/client/src/styles/dashboard.css
@@ -262,6 +262,19 @@
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none;
}
+
+/* Hero-slot plugin overlay: a transparent strip whose bottom edge sits on the
+ pass's top edge, so a plugin (e.g. the mascot) appears to stand ON the bar.
+ pointer-events none — it must never block the pass or hero interactions. */
+.trek-dash .hero-pass-wrap { position: relative; }
+.trek-dash .hero-pass-overlay {
+ position: absolute; left: 16px; right: 16px; bottom: 100%; height: 110px;
+ pointer-events: none; z-index: 3; overflow: hidden;
+}
+.trek-dash .hero-overlay-frame {
+ position: absolute; inset: 0; width: 100%; height: 100% !important;
+ border: 0; background: transparent; color-scheme: light;
+}
.trek-dash .pass-cell {
padding: 4px 18px; position: relative; display: flex; flex-direction: column;
justify-content: center; align-items: center; text-align: center; gap: 6px; flex: 1; min-width: 0;
diff --git a/plugin-sdk/examples/koffi/README.md b/plugin-sdk/examples/koffi/README.md
new file mode 100644
index 000000000..013a73421
--- /dev/null
+++ b/plugin-sdk/examples/koffi/README.md
@@ -0,0 +1,47 @@
+# Koffi
+
+> A tiny animated suitcase mascot that lives on your boarding pass.
+
+
+
+## What it does
+
+Koffi is TREK's mascot — a little vintage suitcase with travel stickers, a
+swinging luggage tag, and big friendly eyes. He walks along the top edge of the
+dashboard's boarding-pass bar, looks around, waves at you, reads his map, naps
+(only when there's plenty of time), rolls a stretch in trolley mode, and
+collects passport-stamp stickers.
+
+He also reacts to your actual trip: the closer the departure, the more excited
+he gets — under 7 days he jumps for joy and his luggage tag flips into an
+airport-style split-flap countdown; while the trip is running he puts on tiny
+sunglasses. Vacation mode.
+
+## Screenshots
+
+
+
+## Permissions
+
+| Permission | Why this plugin needs it |
+|---|---|
+| `db:read:trips` | To read the current trip's start/end dates so Koffi knows how excited to be. The read is membership-checked by TREK, so he only ever sees a trip you already have access to. |
+
+No network access, no data storage of its own — Koffi just vibes.
+
+## Setup
+
+None. Enable plugins on your instance (`TREK_PLUGINS_ENABLED=true`), install
+and activate Koffi, and he appears on the boarding-pass bar of your dashboard
+hero. He respects `prefers-reduced-motion` (static pose, no locomotion).
+
+Requires TREK ≥ 3.2.0 (hero widget slot).
+
+## Building
+
+Plain JS/HTML — no build step. `server/index.js` is the plugin entry;
+`client/index.html` is the mascot iframe.
+
+## License
+
+MIT — see the TREK repository.
diff --git a/plugin-sdk/examples/koffi/client/index.html b/plugin-sdk/examples/koffi/client/index.html
new file mode 100644
index 000000000..7dd94a850
--- /dev/null
+++ b/plugin-sdk/examples/koffi/client/index.html
@@ -0,0 +1,956 @@
+
+
+
+
+
+Koffi
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ z
+ z
+ z
+
+
+
+
+
+
diff --git a/plugin-sdk/examples/koffi/docs/screenshot.png b/plugin-sdk/examples/koffi/docs/screenshot.png
new file mode 100644
index 000000000..1b06adcd8
Binary files /dev/null and b/plugin-sdk/examples/koffi/docs/screenshot.png differ
diff --git a/plugin-sdk/examples/koffi/server/index.js b/plugin-sdk/examples/koffi/server/index.js
new file mode 100644
index 000000000..36d58304e
--- /dev/null
+++ b/plugin-sdk/examples/koffi/server/index.js
@@ -0,0 +1,42 @@
+// Koffi — the TREK suitcase mascot (hero-slot widget).
+//
+// One route: GET /state?tripId= → { days, ongoing, title }. The mascot's mood is
+// driven by how close the trip is. The host membership-checks the trip against
+// the acting user, so Trekki can only ever see a trip that user can see.
+
+const DAY_MS = 86400000;
+
+function midnight(iso) {
+ const d = new Date(iso + 'T00:00:00');
+ return Number.isNaN(d.getTime()) ? null : d.getTime();
+}
+
+module.exports = {
+ routes: [
+ {
+ method: 'GET',
+ path: '/state',
+ auth: true,
+ async handler(req, ctx) {
+ const json = { 'content-type': 'application/json' };
+ const tripId = Number(req.query.tripId);
+ if (!tripId) return { status: 200, headers: json, body: JSON.stringify({ days: null, ongoing: false, title: null }) };
+
+ const t = await ctx.trips.getById(tripId, req.user.id);
+ if (!t) return { status: 200, headers: json, body: JSON.stringify({ days: null, ongoing: false, title: null }) };
+
+ const now = new Date();
+ now.setHours(0, 0, 0, 0);
+ const start = t.start_date ? midnight(t.start_date) : null;
+ const end = t.end_date ? midnight(t.end_date) : null;
+
+ const ongoing = start !== null && start <= now.getTime() && (end === null || now.getTime() <= end);
+ const days = !ongoing && start !== null && start >= now.getTime()
+ ? Math.round((start - now.getTime()) / DAY_MS)
+ : null;
+
+ return { status: 200, headers: json, body: JSON.stringify({ days, ongoing, title: t.title ?? null }) };
+ },
+ },
+ ],
+};
diff --git a/plugin-sdk/examples/koffi/trek-plugin.json b/plugin-sdk/examples/koffi/trek-plugin.json
new file mode 100644
index 000000000..c09e4386c
--- /dev/null
+++ b/plugin-sdk/examples/koffi/trek-plugin.json
@@ -0,0 +1,21 @@
+{
+ "id": "koffi",
+ "name": "Koffi",
+ "version": "1.0.0",
+ "apiVersion": 1,
+ "author": "TREK",
+ "description": "Koffi, the tiny suitcase mascot, lives on your boarding pass — strolling, rolling, collecting stickers, and getting more excited the closer your trip gets.",
+ "homepage": "https://github.com/mauriceboe/trek-plugin-koffi",
+ "license": "MIT",
+ "icon": "Luggage",
+ "type": "widget",
+ "trek": ">=3.2.0 <4.0.0",
+ "nativeModules": false,
+ "permissions": ["db:read:trips"],
+ "capabilities": {
+ "widget": { "title": "Koffi", "slot": "hero" }
+ },
+ "routes": [
+ { "method": "GET", "path": "/state", "auth": true }
+ ]
+}
diff --git a/plugin-sdk/examples/trip-countdown/README.md b/plugin-sdk/examples/trip-countdown/README.md
deleted file mode 100644
index 049c98d13..000000000
--- a/plugin-sdk/examples/trip-countdown/README.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# Trip Countdown
-
-> A dashboard widget that counts down the days to your next trip.
-
-
-
-## What it does
-
-Trip Countdown adds a small dashboard widget showing how many days remain until
-your upcoming trip starts. When a trip is under way it shows "in progress"
-instead. It's the reference plugin for the TREK plugin system — a minimal but
-complete widget that reads trip data through the sandbox and renders it in an
-isolated iframe.
-
-## Screenshots
-
-
-
-## Permissions
-
-| Permission | Why this plugin needs it |
-|---|---|
-| `db:read:trips` | To read the current trip's start date so it can compute the countdown. The read is membership-checked by TREK, so the widget only ever sees a trip you already have access to. |
-
-It requests **no** network access and stores **no** data of its own.
-
-## Setup
-
-None. Enable plugins on your instance (`TREK_PLUGINS_ENABLED=true`), install and
-activate Trip Countdown, and the widget appears on your dashboard.
-
-## Building
-
-The widget ships plain JS/HTML — no build step. `server/index.js` is the plugin
-entry; `client/index.html` is the widget iframe.
-
-## License
-
-MIT — see the TREK repository.
diff --git a/plugin-sdk/examples/trip-countdown/client/index.html b/plugin-sdk/examples/trip-countdown/client/index.html
deleted file mode 100644
index b35d45dea..000000000
--- a/plugin-sdk/examples/trip-countdown/client/index.html
+++ /dev/null
@@ -1,62 +0,0 @@
-
-
-
-
- Trip Countdown
-
-
-
-
Loading…
-
-
-
diff --git a/plugin-sdk/examples/trip-countdown/server/index.js b/plugin-sdk/examples/trip-countdown/server/index.js
deleted file mode 100644
index 03e48f6bf..000000000
--- a/plugin-sdk/examples/trip-countdown/server/index.js
+++ /dev/null
@@ -1,43 +0,0 @@
-// Trip Countdown — reference widget plugin.
-//
-// Demonstrates the full author path: definePlugin, a permission-gated route that
-// reads trip data through ctx, and a response the client iframe renders. Runs
-// isolated; ctx is the only way to reach TREK.
-const { definePlugin } = require('trek-plugin-sdk')
-
-/** Days (integer, >= 0) between now and an ISO date, or null if past/absent. */
-function daysUntil(iso) {
- if (!iso) return null
- const then = new Date(iso).getTime()
- if (Number.isNaN(then)) return null
- const diff = Math.ceil((then - Date.now()) / 86_400_000)
- return diff >= 0 ? diff : null
-}
-
-module.exports = definePlugin({
- routes: [
- {
- method: 'GET',
- path: '/next',
- auth: true,
- async handler(req, ctx) {
- // The client passes the dashboard's current trip id; the host enforces that
- // req.user actually has access to it, so a plugin can't read foreign trips.
- const tripId = Number(req.query.tripId)
- if (!tripId) return { status: 200, headers: json(), body: JSON.stringify({ trip: null }) }
-
- const t = await ctx.trips.getById(tripId, req.user.id)
- const days = daysUntil(t && (t.start_date || t.startDate))
- return {
- status: 200,
- headers: json(),
- body: JSON.stringify({ trip: t ? { name: t.title, days } : null }),
- }
- },
- },
- ],
-})
-
-function json() {
- return { 'content-type': 'application/json' }
-}
diff --git a/plugin-sdk/examples/trip-countdown/trek-plugin.json b/plugin-sdk/examples/trip-countdown/trek-plugin.json
deleted file mode 100644
index f84fa5e46..000000000
--- a/plugin-sdk/examples/trip-countdown/trek-plugin.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "id": "trip-countdown",
- "name": "Trip Countdown",
- "version": "1.0.0",
- "apiVersion": 1,
- "author": "TREK",
- "description": "A dashboard widget counting down the days to your next trip.",
- "homepage": "https://github.com/mauriceboe/TREK",
- "license": "MIT",
- "icon": "CalendarClock",
- "type": "widget",
- "trek": ">=3.2.0 <4.0.0",
- "nativeModules": false,
- "permissions": ["db:read:trips"],
- "capabilities": {
- "widget": { "title": "Trip Countdown", "defaultSize": "small" }
- },
- "routes": [
- { "method": "GET", "path": "/next", "auth": true }
- ]
-}
diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts
index 58befa6c5..642afa8bf 100644
--- a/plugin-sdk/src/manifest.ts
+++ b/plugin-sdk/src/manifest.ts
@@ -56,6 +56,11 @@ export function validateManifest(raw: unknown): ValidationResult {
if (wantsOutbound && egress.length === 0) errors.push('http:outbound declared but egress[] is empty');
if (egress.includes('*')) errors.push('egress[] must not contain a bare "*"');
+ const widget = (m.capabilities as { widget?: { slot?: unknown } } | undefined)?.widget;
+ if (widget?.slot !== undefined && widget.slot !== 'sidebar' && widget.slot !== 'hero') {
+ errors.push(`widget slot must be "sidebar" or "hero", got "${String(widget.slot)}"`);
+ }
+
if (errors.length) return { ok: false, errors };
return {
ok: true,
diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts
index eb0b13ec7..362c0c703 100644
--- a/plugin-sdk/test/sdk.test.ts
+++ b/plugin-sdk/test/sdk.test.ts
@@ -86,8 +86,8 @@ describe('scaffold + validate CLIs', () => {
});
});
-describe('reference plugin (examples/trip-countdown)', () => {
- const dir = path.resolve(import.meta.dirname, '..', 'examples', 'trip-countdown');
+describe('reference plugin (examples/koffi)', () => {
+ const dir = path.resolve(import.meta.dirname, '..', 'examples', 'koffi');
it('passes the same validation authors run', () => {
const r = validatePluginDir(dir);
@@ -95,10 +95,13 @@ describe('reference plugin (examples/trip-countdown)', () => {
expect(r.ok).toBe(true);
});
- it('has a valid, minimal-permission manifest', () => {
- const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'trek-plugin.json'), 'utf8'));
+ it('has a valid, minimal-permission hero-widget manifest', () => {
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'trek-plugin.json'), 'utf8')) as {
+ capabilities?: { widget?: { slot?: string } };
+ };
const res = validateManifest(manifest);
expect(res.ok).toBe(true);
expect(res.manifest?.permissions).toEqual(['db:read:trips']);
+ expect(manifest.capabilities?.widget?.slot).toBe('hero');
});
});
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index 8d4bfd82c..f09621786 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -3357,10 +3357,18 @@ function runMigrations(db: Database.Database): void {
// erased the "keep it on" intent, so the plugin never rebooted after a deploy.
// Boot now retries every enabled plugin regardless of last status.
() => {
- db.exec("ALTER TABLE plugins ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;");
- // Anything not explicitly deactivated was meant to be on ('inactive' is the
- // only status deactivate() sets; a crash/shutdown could leave error/stopped/starting).
- db.exec("UPDATE plugins SET enabled = 1 WHERE status != 'inactive';");
+ try {
+ db.exec("ALTER TABLE plugins ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;");
+ // Anything not explicitly deactivated was meant to be on ('inactive' is the
+ // only status deactivate() sets; a crash/shutdown could leave error/stopped/starting).
+ db.exec("UPDATE plugins SET enabled = 1 WHERE status != 'inactive';");
+ } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
+ },
+ // Migration 157: plugin capabilities (from trek-plugin.json) — the client
+ // needs them to place widgets (e.g. widget.slot 'hero' renders as an overlay
+ // on the boarding-pass bar instead of the dashboard sidebar).
+ () => {
+ try { db.exec("ALTER TABLE plugins ADD COLUMN capabilities TEXT NOT NULL DEFAULT '{}';"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
},
];
diff --git a/server/src/nest/plugins/install/discovery.ts b/server/src/nest/plugins/install/discovery.ts
index d8e8d5160..662929ab9 100644
--- a/server/src/nest/plugins/install/discovery.ts
+++ b/server/src/nest/plugins/install/discovery.ts
@@ -45,13 +45,13 @@ 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 = ?, 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), m.id);
+ min_trek_version = ?, permissions = ?, capabilities = ?, 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), m.id);
} else {
db.prepare(
- `INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, 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));
+ `INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, 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));
}
// Refresh the settings-field descriptors from the manifest.
diff --git a/server/src/nest/plugins/install/manifest.ts b/server/src/nest/plugins/install/manifest.ts
index 96778fa2f..cb60ebd50 100644
--- a/server/src/nest/plugins/install/manifest.ts
+++ b/server/src/nest/plugins/install/manifest.ts
@@ -20,6 +20,17 @@ export interface ManifestSettingField {
oauth?: { initPath?: string; callbackPath?: string };
}
+export interface WidgetCapability {
+ title?: string;
+ defaultSize?: string;
+ /** Where the widget mounts: dashboard sidebar (default) or as a hero-bar overlay. */
+ slot?: 'sidebar' | 'hero';
+}
+
+export interface PluginCapabilities {
+ widget?: WidgetCapability;
+}
+
export interface PluginManifest {
id: string;
name: string;
@@ -36,6 +47,7 @@ export interface PluginManifest {
permissions: string[];
egress: string[];
settings: ManifestSettingField[];
+ capabilities: PluginCapabilities;
}
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
@@ -84,9 +96,27 @@ export function parseManifest(raw: unknown): PluginManifest {
permissions,
egress,
settings: parseSettings(m.settings),
+ capabilities: parseCapabilities(m.capabilities),
};
}
+function parseCapabilities(raw: unknown): PluginCapabilities {
+ if (!raw || typeof raw !== 'object') return {};
+ const c = raw as Record;
+ const out: PluginCapabilities = {};
+ if (c.widget && typeof c.widget === 'object') {
+ const w = c.widget as Record;
+ const slot = optStr(w.slot);
+ if (slot && slot !== 'sidebar' && slot !== 'hero') throw new ManifestError(`invalid widget slot "${slot}"`);
+ out.widget = {
+ title: optStr(w.title),
+ defaultSize: optStr(w.defaultSize),
+ slot: (slot as WidgetCapability['slot']) ?? 'sidebar',
+ };
+ }
+ return out;
+}
+
function parseSettings(raw: unknown): ManifestSettingField[] {
if (!Array.isArray(raw)) return [];
return raw
diff --git a/server/src/nest/plugins/plugins-feed.controller.ts b/server/src/nest/plugins/plugins-feed.controller.ts
index 7a1ee6b93..5d63d2d44 100644
--- a/server/src/nest/plugins/plugins-feed.controller.ts
+++ b/server/src/nest/plugins/plugins-feed.controller.ts
@@ -15,6 +15,7 @@ interface ActivePlugin {
name: string;
type: string;
icon: string | null;
+ slot: 'sidebar' | 'hero';
}
@Controller('api/plugins')
@@ -23,9 +24,19 @@ export class PluginsFeedController {
@Get()
list(): { plugins: ActivePlugin[] } {
if (!pluginsEnabled()) return { plugins: [] };
- const plugins = db
- .prepare("SELECT id, name, type, icon FROM plugins WHERE status = 'active' ORDER BY sort_order, name")
- .all() as ActivePlugin[];
+ const rows = db
+ .prepare("SELECT id, name, type, icon, capabilities FROM plugins WHERE status = 'active' ORDER BY sort_order, name")
+ .all() as Array & { capabilities: string }>;
+ const plugins = rows.map(({ capabilities, ...p }) => ({ ...p, slot: slotOf(capabilities) }));
return { plugins };
}
}
+
+function slotOf(capabilities: string): 'sidebar' | 'hero' {
+ try {
+ const c = JSON.parse(capabilities || '{}') as { widget?: { slot?: string } };
+ return c.widget?.slot === 'hero' ? 'hero' : 'sidebar';
+ } catch {
+ return 'sidebar';
+ }
+}
diff --git a/server/tests/integration/plugins/registry.test.ts b/server/tests/integration/plugins/registry.test.ts
index 4d3cf1cdc..6774eb0c8 100644
--- a/server/tests/integration/plugins/registry.test.ts
+++ b/server/tests/integration/plugins/registry.test.ts
@@ -21,7 +21,7 @@ const { testDb } = vi.hoisted(() => {
const db = new Database(':memory:');
db.exec(`
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
- api_version INTEGER, min_trek_version TEXT, permissions TEXT, granted_permissions TEXT, status TEXT, config TEXT,
+ api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', granted_permissions TEXT, status TEXT, config TEXT,
source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, updated_at TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
diff --git a/server/tests/unit/plugins/discovery.test.ts b/server/tests/unit/plugins/discovery.test.ts
index 2daac5d4f..ab035c54d 100644
--- a/server/tests/unit/plugins/discovery.test.ts
+++ b/server/tests/unit/plugins/discovery.test.ts
@@ -27,7 +27,7 @@ beforeEach(() => {
db = new Database(':memory:');
db.exec(`
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
- api_version INTEGER, min_trek_version TEXT, permissions TEXT, granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT);
+ api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT,
required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
diff --git a/server/tests/unit/plugins/manifest.test.ts b/server/tests/unit/plugins/manifest.test.ts
index b2432fa1d..1ef38eeab 100644
--- a/server/tests/unit/plugins/manifest.test.ts
+++ b/server/tests/unit/plugins/manifest.test.ts
@@ -47,3 +47,17 @@ describe('parseManifest', () => {
expect(() => parseManifest(input)).toThrow(re as RegExp);
});
});
+
+describe('parseManifest capabilities', () => {
+ it('parses a hero widget slot and defaults to sidebar', () => {
+ const hero = parseManifest({ ...base, capabilities: { widget: { slot: 'hero', title: 'T' } } });
+ expect(hero.capabilities.widget?.slot).toBe('hero');
+ const plain = parseManifest({ ...base, capabilities: { widget: {} } });
+ expect(plain.capabilities.widget?.slot).toBe('sidebar');
+ expect(parseManifest(base).capabilities).toEqual({});
+ });
+
+ it('rejects an unknown widget slot', () => {
+ expect(() => parseManifest({ ...base, capabilities: { widget: { slot: 'floating' } } })).toThrow(ManifestError);
+ });
+});
diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts
index 6a3cfb43f..49b343860 100644
--- a/server/tests/unit/plugins/plugins-service.test.ts
+++ b/server/tests/unit/plugins/plugins-service.test.ts
@@ -9,7 +9,7 @@ const { testDb } = vi.hoisted(() => {
const db = new Database(':memory:');
db.exec(`CREATE TABLE plugins (
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
- status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', updated_at TEXT,
+ status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', capabilities TEXT DEFAULT '{}', updated_at TEXT,
sort_order INTEGER DEFAULT 0);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`);
@@ -66,11 +66,20 @@ describe('PluginsFeedController (client feed)', () => {
process.env.TREK_PLUGINS_ENABLED = 'true';
const active = feed.list();
- expect(active.plugins).toEqual([{ id: 'w', name: 'W', type: 'widget', icon: 'Box' }]);
+ expect(active.plugins).toEqual([{ id: 'w', name: 'W', type: 'widget', icon: 'Box', slot: 'sidebar' }]);
process.env.TREK_PLUGINS_ENABLED = 'false';
expect(feed.list().plugins).toEqual([]);
});
+
+ it('exposes the widget slot from capabilities (hero) and defaults on bad JSON', () => {
+ testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('h','H','widget','Box','active','{\"widget\":{\"slot\":\"hero\"}}')").run();
+ testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('b','B','widget','Box','active','not-json')").run();
+ process.env.TREK_PLUGINS_ENABLED = 'true';
+ const out = new PluginsFeedController().list();
+ expect(out.plugins.find((p) => p.id === 'h')?.slot).toBe('hero');
+ expect(out.plugins.find((p) => p.id === 'b')?.slot).toBe('sidebar');
+ });
});
describe('PluginsController M2 endpoints', () => {