diff --git a/client/src/api/client.ts b/client/src/api/client.ts index d1245eccf..534047ff1 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -462,6 +462,7 @@ export const adminApi = { updateAddon: (id: number | string, data: Record) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), plugins: () => apiClient.get('/admin/plugins').then(r => r.data), pluginBrowse: () => apiClient.get('/admin/plugins/registry').then(r => r.data), + pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data), pluginInstall: (id: string, version?: string) => apiClient.post('/admin/plugins/install', { id, version }).then(r => r.data), pluginActivate: (id: string) => apiClient.post(`/admin/plugins/${id}/activate`).then(r => r.data), pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data), diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index df3343244..6c5f986d9 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -1,5 +1,9 @@ import { useEffect, useState } from 'react' -import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, ArrowUpCircle, Puzzle } from 'lucide-react' +import { + Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, + ArrowUpCircle, Github, ExternalLink, ChevronRight, Check, Luggage, Plane, Globe, Image, + CalendarDays, Map, Bell, Cloud, Camera, Compass, BookOpen, Wallet, Puzzle, +} from 'lucide-react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' @@ -8,8 +12,10 @@ import ToggleSwitch from '../Settings/ToggleSwitch' /** * Admin → Plugins (#plugins). Separates the admin's ON/OFF intent (the toggle, - * backed by `enabled`) from runtime health (`status`), surfaces per-plugin errors - * and available updates, and hosts the registry browser. Gated by the + * backed by `enabled`) from runtime health (`status`, the coloured dot on the + * icon tile), surfaces per-plugin errors and available updates, and hosts the + * registry browser with a per-plugin detail dialog (screenshot, permissions, + * setup preview — fetched live at the reviewed commit). Gated by the * runtime-enabled flag. */ @@ -18,45 +24,109 @@ interface PluginRow { name: string description: string | null type: string + icon: string | null version: string | null status: string enabled: number last_error: string | null reviewed_at: string | null + source_repo: string | null } interface RegistryItem { id: string name: string author: string description: string + repo: string + homepage?: string | null type: string latest: string | null + minTrekVersion: string | null reviewedAt: string | null + screenshotUrl: string | null +} +interface RegistryDetail extends RegistryItem { + size: number | null + publishedAt: string | null + manifest: { + permissions: string[] + egress: string[] + settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }> + license: string | null + icon: string | null + } | null } -// Runtime health → colour + dot. `enabled` is shown separately by the toggle. -const HEALTH: Record = { - active: { cls: 'text-emerald-600', dot: 'bg-emerald-500' }, - starting: { cls: 'text-sky-600', dot: 'bg-sky-500 animate-pulse' }, - error: { cls: 'text-rose-600', dot: 'bg-rose-500' }, - inactive: { cls: 'text-content-faint', dot: 'bg-content-faint/50' }, - disabled: { cls: 'text-amber-600', dot: 'bg-amber-500' }, - incompatible: { cls: 'text-orange-600', dot: 'bg-orange-500' }, +type T = (k: string, p?: Record) => string + +// Runtime health → dot colour on the icon tile. `enabled` is shown by the toggle. +const HEALTH: Record = { + active: 'bg-emerald-500', + starting: 'bg-sky-500 animate-pulse', + error: 'bg-rose-500', + inactive: 'bg-content-faint/50', + disabled: 'bg-amber-500', + incompatible: 'bg-orange-500', } +// States worth calling out with a text badge (ok states speak through the dot). +const STATUS_BADGE: Record = { + starting: 'bg-sky-500/10 text-sky-600', + error: 'bg-rose-500/10 text-rose-600', + disabled: 'bg-amber-500/10 text-amber-600', + incompatible: 'bg-orange-500/10 text-orange-600', +} + +// Manifest `icon` is a lucide name; map the common ones, fall back to Blocks. +const ICON_MAP: Record> = { + Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass, BookOpen, Wallet, Puzzle, Blocks, +} + +// Known permissions → human-readable i18n key; unknown ones render as raw code. +const PERM_KEYS = [ + 'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user', + 'hook:photo-provider', 'hook:calendar-source', 'http:outbound', +] function isNewer(a: string, b: string): boolean { - const pa = a.split('.').map(Number) - const pb = b.split('.').map(Number) + const nums = (v: string) => v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0) + const pa = nums(a) + const pb = nums(b) for (let i = 0; i < 3; i++) { const x = pa[i] || 0 const y = pb[i] || 0 if (x !== y) return x > y } - return false + // Same numeric triple: the stable release is newer than its prerelease. + return !a.includes('-') && b.includes('-') +} + +function PluginIcon({ name, size = 18, className }: { name: string | null; size?: number; className?: string }) { + // hasOwnProperty guard: the icon name is remote manifest data, and a plain + // object lookup would otherwise reach Object.prototype ("constructor", …). + const Icon = (name && Object.prototype.hasOwnProperty.call(ICON_MAP, name) && ICON_MAP[name]) || Blocks + return +} + +const KNOWN_TYPES = ['widget', 'page', 'integration'] + +function TypeBadge({ type, t }: { type: string; t: T }) { + return ( + + {KNOWN_TYPES.includes(type) ? t(`admin.plugins.type.${type}` as never) : type} + + ) +} + +function ReviewedBadge({ t }: { t: T }) { + return ( + + {t('admin.plugins.reviewed')} + + ) } export default function AdminPluginsPanel() { - const { t } = useTranslation() + const { t, locale } = useTranslation() const toast = useToast() const [runtimeOn, setRuntimeOn] = useState(false) const [plugins, setPlugins] = useState([]) @@ -66,6 +136,7 @@ export default function AdminPluginsPanel() { const [view, setView] = useState<'installed' | 'browse'>('installed') const [registry, setRegistry] = useState(null) const [latest, setLatest] = useState>({}) + const [detailFor, setDetailFor] = useState(null) const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null) const [confirmUninstall, setConfirmUninstall] = useState(null) @@ -92,9 +163,10 @@ export default function AdminPluginsPanel() { const act = async (id: string, fn: () => Promise, ok: string) => { setBusy(id) - try { await fn(); toast.success(ok); refresh() } + try { await fn(); toast.success(ok) } catch (e) { toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) } - finally { setBusy(null) } + // Refresh even on failure: a multi-step action may have partially committed. + finally { setBusy(null); refresh() } } const openBrowse = () => { @@ -107,23 +179,22 @@ export default function AdminPluginsPanel() { .catch(() => setErrorsFor({ id, rows: [] })) const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version)) + const install = (id: string) => act(id, () => adminApi.pluginInstall(id), t('admin.plugins.installed')) + const installedIds = new Set(plugins.map(p => p.id)) return (
- {/* Header */} -
-
- -
-
-

{t('admin.plugins.title')}

-

{t('admin.plugins.subtitle')}

+ {/* Header — flush-left title/subtitle, like the Addons panel */} +
+
+

{t('admin.plugins.title')}

+

{t('admin.plugins.subtitle')}

{runtimeOn && ( -
+
)}
-
- - - {t(`admin.plugins.status.${p.status}` as never)} - - · {t(`admin.plugins.type.${p.type}` as never)} -
+ {p.description && ( +

{p.description}

+ )} + {p.source_repo && ( + + {p.source_repo} + + )} {p.status === 'error' && p.last_error && (

{p.last_error}

)} @@ -230,6 +319,14 @@ export default function AdminPluginsPanel() {

{t('admin.plugins.trustNote')}

+ {/* Registry detail dialog */} + {detailFor && ( + setDetailFor(null)} /> + )} + {/* Error-log modal */} {errorsFor && (
setErrorsFor(null)}> @@ -278,11 +375,27 @@ function IconBtn({ children, title, onClick, disabled, danger }: { ) } -function RegistryGrid({ items, onInstall, busy, t, installedIds }: { +function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) { + const [failed, setFailed] = useState(false) + return ( +
+ {url && !failed ? ( + setFailed(true)} /> + ) : ( +
+ +
+ )} +
+ ) +} + +function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds }: { items: RegistryItem[] | null onInstall: (id: string) => void + onOpenDetail: (item: RegistryItem) => void busy: string | null - t: (k: string, p?: Record) => string + t: T installedIds: Set }) { if (!items) return
{t('common.loading')}
@@ -292,32 +405,163 @@ function RegistryGrid({ items, onInstall, busy, t, installedIds }: { {items.map(item => { const installed = installedIds.has(item.id) return ( -
-
-
- +
onOpenDetail(item)} + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }} + className="border border-edge rounded-xl bg-surface-secondary/40 overflow-hidden flex flex-col cursor-pointer hover:border-accent/50 transition-colors"> + +
+
+ {item.name} + {item.latest && v{item.latest}} + + {item.reviewedAt && }
-
-
- {item.name} - {item.latest && v{item.latest}} -
- {item.author} · {t(`admin.plugins.type.${item.type}` as never)} + {item.author} +

{item.description}

+
+ + {t('admin.plugins.details')} + +
-

{item.description}

-
- {item.reviewedAt - ? {t('admin.plugins.reviewed')} - : {t('admin.plugins.unreviewed')}} - -
) })}
) } + +function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale }: { + item: RegistryItem + installed: boolean + busy: string | null + onInstall: (id: string) => void + onClose: () => void + t: T + locale: string +}) { + const [detail, setDetail] = useState(null) + const [failed, setFailed] = useState(false) + + useEffect(() => { + let alive = true + adminApi.pluginDetail(item.id) + .then((d: RegistryDetail) => { if (alive) setDetail(d) }) + .catch(() => { if (alive) setFailed(true) }) + return () => { alive = false } + }, [item.id]) + + const manifest = detail?.manifest ?? null + const repoUrl = `https://github.com/${item.repo}` + // Registry data is curated but still remote: only ever link plain http(s) URLs. + const homepage = item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null + + return ( +
+
e.stopPropagation()}> +
+ + +
+ +
+
+

{item.name}

+ {item.latest && v{item.latest}} + + {item.reviewedAt && } +
+

{item.author}

+ +

{item.description}

+ + {failed &&

{t('admin.plugins.detailError')}

} + + {/* Permissions */} + {manifest && ( +
+

{t('admin.plugins.permissionsTitle')}

+ {manifest.permissions.length === 0 ? ( +

{t('admin.plugins.noPermissions')}

+ ) : ( +
    + {manifest.permissions.map(perm => ( +
  • + + {PERM_KEYS.includes(perm) + ? {t(`admin.plugins.perm.${perm}` as never)} + : {perm}} +
  • + ))} +
+ )} + {manifest.egress.length > 0 && ( +

+ {t('admin.plugins.egressNote', { hosts: manifest.egress.join(', ') })} +

+ )} +
+ )} + + {/* Setup preview */} + {manifest && ( +
+

{t('admin.plugins.setupTitle')}

+ {manifest.settings.length === 0 ? ( +

{t('admin.plugins.noSetup')}

+ ) : ( +
    + {manifest.settings.map(s => ( +
  • + {s.label} + + {t(`admin.plugins.scope.${s.scope}` as never)} + + {s.required && ( + + {t('admin.plugins.fieldRequired')} + + )} +
  • + ))} +
+ )} +
+ )} + + {/* Links */} + +
+ +
+
+ {item.minTrekVersion && {t('admin.plugins.requiresTrek', { version: item.minTrekVersion })}} + {item.reviewedAt && {t('admin.plugins.reviewedOn', { date: new Date(item.reviewedAt).toLocaleDateString(locale) })}} +
+ +
+
+
+ ) +} diff --git a/server/src/nest/plugins/install/manifest.ts b/server/src/nest/plugins/install/manifest.ts index cb60ebd50..18732fd98 100644 --- a/server/src/nest/plugins/install/manifest.ts +++ b/server/src/nest/plugins/install/manifest.ts @@ -51,6 +51,9 @@ export interface PluginManifest { } const ID_RE = /^[a-z][a-z0-9-]{2,39}$/; +// Static path segments under /api/admin/plugins — a plugin id must never shadow them +// (id "registry" would collide with GET registry/:id vs :id/errors routing). +const RESERVED_IDS = new Set(['registry', 'install', 'rescan']); const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; const TYPES = new Set(['integration', 'page', 'widget']); @@ -62,6 +65,7 @@ export function parseManifest(raw: unknown): PluginManifest { const id = str(m.id, 'id'); if (!ID_RE.test(id)) throw new ManifestError(`invalid id "${id}" (lowercase slug, 3–40 chars)`); + if (RESERVED_IDS.has(id)) throw new ManifestError(`reserved id "${id}"`); const version = str(m.version, 'version'); if (!SEMVER_RE.test(version)) throw new ManifestError(`invalid version "${version}"`); const type = str(m.type, 'type'); diff --git a/server/src/nest/plugins/plugins.controller.ts b/server/src/nest/plugins/plugins.controller.ts index 4ee32d968..cf0be9f5c 100644 --- a/server/src/nest/plugins/plugins.controller.ts +++ b/server/src/nest/plugins/plugins.controller.ts @@ -33,6 +33,15 @@ export class PluginsController { return this.registry.browse(); } + @Get('registry/:id') + async registryDetail(@Param('id') id: string) { + try { + return await this.registry.detail(id); + } catch (e) { + throw new HttpException({ error: e instanceof Error ? e.message : 'not found' }, 404); + } + } + @Post('install') @HttpCode(200) async install(@Body() body: { id?: string; version?: string }) { diff --git a/server/src/nest/plugins/registry/registry.service.ts b/server/src/nest/plugins/registry/registry.service.ts index 5f4a50ec6..4d2cfe481 100644 --- a/server/src/nest/plugins/registry/registry.service.ts +++ b/server/src/nest/plugins/registry/registry.service.ts @@ -22,6 +22,7 @@ const REGISTRY_URL = process.env.TREK_PLUGIN_REGISTRY_URL || 'https://raw.githubusercontent.com/mauriceboe/TREK-Plugins/main/dist/index.json'; const CACHE_TTL = 30 * 60 * 1000; +const MANIFEST_MAX_BYTES = 256 * 1024; interface RegistryVersion { version: string; @@ -34,6 +35,7 @@ interface RegistryVersion { size?: number; apiVersion?: number; nativeModules?: boolean; + publishedAt?: string; } export interface RegistryEntry { id: string; @@ -53,11 +55,22 @@ interface Registry { plugins: RegistryEntry[]; } +/** Anzeige-only preview of a plugin's live manifest (fetched at the reviewed commit). */ +export interface ManifestPreview { + permissions: string[]; + egress: string[]; + settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>; + license: string | null; + icon: string | null; +} + let _cache: { data: Registry; expiresAt: number } | null = null; +const _detailCache = new Map(); /** Test hook: drop the in-memory registry cache. */ export function __clearRegistryCacheForTests(): void { _cache = null; + _detailCache.clear(); } export class RegistryError extends Error {} @@ -80,7 +93,7 @@ export class PluginRegistryService { } /** The browse list the admin UI renders (metadata only, no code). */ - async browse(): Promise & { latest: string | null; minTrekVersion: string | null }>> { + async browse(): Promise & { latest: string | null; minTrekVersion: string | null; screenshotUrl: string | null }>> { const reg = await this.fetchRegistry(); return reg.plugins.map((p) => { const latest = p.versions[0] ?? null; @@ -88,10 +101,53 @@ export class PluginRegistryService { id: p.id, name: p.name, author: p.author, description: p.description, repo: p.repo, homepage: p.homepage, tags: p.tags, type: p.type, reviewedAt: p.reviewedAt ?? null, latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null, + screenshotUrl: latest ? rawFileUrl(p.repo, latest.commitSha, 'docs/screenshot.png') : null, }; }); } + /** + * Everything the browse detail view shows for one plugin: the registry entry + * plus a preview of its live manifest (permissions, egress, settings) fetched + * from the author repo at the pinned/reviewed commit. Display-only — the + * install pipeline re-validates the manifest inside the downloaded artifact. + * Cached per plugin (30 min) and only fetched when a detail view opens, never + * for the whole grid (the HACS rate-limit lesson). + */ + async detail(id: string): Promise> { + const hit = _detailCache.get(id); + if (hit && Date.now() < hit.expiresAt) return hit.data as Record; + + const reg = await this.fetchRegistry(); + const entry = reg.plugins.find((p) => p.id === id); + if (!entry) throw new RegistryError(`plugin ${id} not in registry`); + const latest = entry.versions[0] ?? null; + + let manifest: ManifestPreview | null = null; + if (latest) { + try { + const { bytes } = await safeDownload(rawFileUrl(entry.repo, latest.commitSha, 'trek-plugin.json'), MANIFEST_MAX_BYTES); + manifest = previewManifest(JSON.parse(bytes.toString('utf8'))); + } catch { + // Soft-fail: the detail view still renders from registry metadata alone. + } + } + + const data = { + id: entry.id, name: entry.name, author: entry.author, description: entry.description, + repo: entry.repo, homepage: entry.homepage ?? null, tags: entry.tags ?? [], type: entry.type, + reviewedAt: entry.reviewedAt ?? null, + latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null, + size: latest?.size ?? null, publishedAt: latest?.publishedAt ?? null, + screenshotUrl: latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null, + manifest, + }; + // Don't negative-cache a transiently failed manifest fetch — the next + // detail open should retry instead of hiding the preview for 30 minutes. + if (manifest || !latest) _detailCache.set(id, { data, expiresAt: Date.now() + CACHE_TTL }); + return data; + } + /** Install a pinned version from the registry. Returns the installed plugin id. */ async install(id: string, version?: string): Promise<{ id: string; version: string }> { const reg = await this.fetchRegistry(); @@ -134,6 +190,40 @@ export class PluginRegistryService { } } +function rawFileUrl(repo: string, commitSha: string, file: string): string { + return `https://raw.githubusercontent.com/${repo}/${commitSha}/${file}`; +} + +/** + * Tolerant projection of a live manifest for display. Unknown shapes degrade to + * empty lists instead of throwing — a future manifest field must never break + * browsing (strict validation happens against the downloaded artifact instead). + */ +function previewManifest(raw: unknown): ManifestPreview { + const m = (raw && typeof raw === 'object' ? raw : {}) as Record; + const strings = (v: unknown): string[] => + Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; + const settings = Array.isArray(m.settings) + ? m.settings + .filter((s): s is Record => !!s && typeof s === 'object') + .map((s) => ({ + key: typeof s.key === 'string' ? s.key : '', + label: typeof s.label === 'string' ? s.label : typeof s.key === 'string' ? s.key : '', + inputType: typeof s.input_type === 'string' ? s.input_type : 'text', + scope: s.scope === 'user' ? 'user' : 'instance', + required: s.required === true, + })) + .filter((s) => s.key) + : []; + return { + permissions: strings(m.permissions), + egress: strings(m.egress), + settings, + license: typeof m.license === 'string' ? m.license : null, + icon: typeof m.icon === 'string' ? m.icon : null, + }; +} + /** The extracted plugin root: staging itself, or its single wrapper subdir (codeload archives wrap in {repo}-{sha}/). */ function locateManifestDir(staging: string): string | null { if (fs.existsSync(path.join(staging, 'trek-plugin.json'))) return staging; diff --git a/server/tests/integration/plugins/registry.test.ts b/server/tests/integration/plugins/registry.test.ts index 6774eb0c8..9120b6842 100644 --- a/server/tests/integration/plugins/registry.test.ts +++ b/server/tests/integration/plugins/registry.test.ts @@ -100,6 +100,73 @@ describe('PluginRegistryService', () => { ]); }); + it('browse exposes a screenshot url pinned at the reviewed commit', async () => { + const list = await svc.browse(); + expect(list[0].screenshotUrl).toBe(`https://raw.githubusercontent.com/acme/trek-flight/${'a'.repeat(40)}/docs/screenshot.png`); + }); + + it('detail merges registry metadata with a live manifest preview', async () => { + safeDownload.mockResolvedValue({ + bytes: Buffer.from(JSON.stringify({ + id: 'flight-tracker', permissions: ['db:read:trips', 'http:outbound'], egress: ['api.example.com'], + settings: [{ key: 'api_key', label: 'API Key', input_type: 'password', scope: 'instance', required: true, secret: true }], + license: 'MIT', icon: 'Plane', + })), + sha256: 'unused', + }); + const d = await svc.detail('flight-tracker'); + expect(safeDownload).toHaveBeenCalledWith( + `https://raw.githubusercontent.com/acme/trek-flight/${'a'.repeat(40)}/trek-plugin.json`, + expect.any(Number), + ); + expect(d).toMatchObject({ + id: 'flight-tracker', repo: 'acme/trek-flight', latest: '1.0.0', reviewedAt: '2026-06-20', + screenshotUrl: `https://raw.githubusercontent.com/acme/trek-flight/${'a'.repeat(40)}/docs/screenshot.png`, + manifest: { + permissions: ['db:read:trips', 'http:outbound'], + egress: ['api.example.com'], + settings: [{ key: 'api_key', label: 'API Key', inputType: 'password', scope: 'instance', required: true }], + license: 'MIT', icon: 'Plane', + }, + }); + }); + + it('detail soft-fails the manifest fetch (registry metadata still renders)', async () => { + safeDownload.mockRejectedValue(new Error('offline')); + const d = await svc.detail('flight-tracker'); + expect(d).toMatchObject({ id: 'flight-tracker', manifest: null }); + }); + + it('detail tolerates a malformed manifest shape', async () => { + safeDownload.mockResolvedValue({ + bytes: Buffer.from(JSON.stringify({ permissions: 'not-an-array', settings: [{ label: 'no key' }], egress: [7, 'ok.host'] })), + sha256: 'unused', + }); + const d = (await svc.detail('flight-tracker')) as { manifest: { permissions: string[]; egress: string[]; settings: unknown[] } }; + expect(d.manifest).toMatchObject({ permissions: [], egress: ['ok.host'], settings: [] }); + }); + + it('detail is cached per plugin (one manifest fetch across calls)', async () => { + safeDownload.mockResolvedValue({ bytes: Buffer.from('{}'), sha256: 'unused' }); + await svc.detail('flight-tracker'); + await svc.detail('flight-tracker'); + expect(safeDownload).toHaveBeenCalledTimes(1); + }); + + it('detail does not negative-cache a failed manifest fetch (next open retries)', async () => { + safeDownload.mockRejectedValueOnce(new Error('github hiccup')); + const first = (await svc.detail('flight-tracker')) as { manifest: unknown }; + expect(first.manifest).toBeNull(); + safeDownload.mockResolvedValue({ bytes: Buffer.from(JSON.stringify({ permissions: ['db:own'] })), sha256: 'unused' }); + const second = (await svc.detail('flight-tracker')) as { manifest: { permissions: string[] } }; + expect(second.manifest).toMatchObject({ permissions: ['db:own'] }); + expect(safeDownload).toHaveBeenCalledTimes(2); + }); + + it('detail rejects an unknown plugin id', async () => { + await expect(svc.detail('ghost')).rejects.toThrow(RegistryError); + }); + it('fetchRegistry soft-fails to an empty registry on a cold cache', async () => { __clearRegistryCacheForTests(); vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); })); diff --git a/server/tests/unit/plugins/manifest.test.ts b/server/tests/unit/plugins/manifest.test.ts index 1ef38eeab..c9699bd2d 100644 --- a/server/tests/unit/plugins/manifest.test.ts +++ b/server/tests/unit/plugins/manifest.test.ts @@ -34,6 +34,7 @@ describe('parseManifest', () => { it.each([ ['Bad-Id', { ...base, id: 'Bad-Id' }, /invalid id/], + ['reserved id', { ...base, id: 'registry' }, /reserved id/], ['bad version', { ...base, version: '1.x' }, /invalid version/], ['bad type', { ...base, type: 'thing' }, /invalid type/], ['native', { ...base, nativeModules: true }, /native modules/], diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 86c1e5260..0f6a36789 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -200,6 +200,28 @@ const admin: TranslationStrings = { 'admin.plugins.status.disabled': 'Deaktiviert', 'admin.plugins.status.error': 'Fehler', 'admin.plugins.status.incompatible': 'Inkompatibel', + 'admin.plugins.details': 'Details', + 'admin.plugins.detailError': 'Plugin-Details konnten nicht geladen werden.', + 'admin.plugins.permissionsTitle': 'Berechtigungen', + 'admin.plugins.noPermissions': 'Dieses Plugin benötigt keine besonderen Berechtigungen.', + 'admin.plugins.egressNote': 'Darf verbinden zu: {hosts}', + 'admin.plugins.setupTitle': 'Einrichtung', + 'admin.plugins.noSetup': 'Funktioniert sofort — nichts zu konfigurieren.', + 'admin.plugins.scope.instance': 'Instanzweit', + 'admin.plugins.scope.user': 'Pro Nutzer', + 'admin.plugins.fieldRequired': 'Erforderlich', + 'admin.plugins.sourceRepo': 'Quell-Repository', + 'admin.plugins.homepage': 'Homepage', + 'admin.plugins.requiresTrek': 'Benötigt TREK {version}+', + 'admin.plugins.reviewedOn': 'Geprüft am {date}', + 'admin.plugins.perm.db:own': 'Eigene Daten in einer isolierten Datenbank speichern', + 'admin.plugins.perm.db:read:trips': 'Reisen lesen, auf die der handelnde Nutzer Zugriff hat', + 'admin.plugins.perm.db:read:users': 'Grundlegende Profilinfos lesen (Name, Avatar — nie Zugangsdaten)', + 'admin.plugins.perm.ws:broadcast:trip': 'Echtzeit-Updates an Reisemitglieder senden', + 'admin.plugins.perm.ws:broadcast:user': 'Echtzeit-Updates an einzelne Nutzer senden', + 'admin.plugins.perm.hook:photo-provider': 'Fotos für Erinnerungen bereitstellen', + 'admin.plugins.perm.hook:calendar-source': 'Termine für den Kalender bereitstellen', + 'admin.plugins.perm.http:outbound': 'Ausgehende Anfragen an deklarierte Hosts stellen', 'admin.addons.title': 'Addons', 'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.', 'admin.addons.catalog.packing.name': 'Listen', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 5b87145de..915b48419 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -252,6 +252,28 @@ const admin: TranslationStrings = { 'admin.plugins.status.disabled': 'Disabled', 'admin.plugins.status.error': 'Error', 'admin.plugins.status.incompatible': 'Incompatible', + 'admin.plugins.details': 'Details', + 'admin.plugins.detailError': 'Could not load plugin details.', + 'admin.plugins.permissionsTitle': 'Permissions', + 'admin.plugins.noPermissions': 'This plugin requests no special permissions.', + 'admin.plugins.egressNote': 'May connect to: {hosts}', + 'admin.plugins.setupTitle': 'Setup', + 'admin.plugins.noSetup': 'Works out of the box — nothing to configure.', + 'admin.plugins.scope.instance': 'Instance-wide', + 'admin.plugins.scope.user': 'Per user', + 'admin.plugins.fieldRequired': 'Required', + 'admin.plugins.sourceRepo': 'Source repository', + 'admin.plugins.homepage': 'Homepage', + 'admin.plugins.requiresTrek': 'Requires TREK {version}+', + 'admin.plugins.reviewedOn': 'Reviewed {date}', + 'admin.plugins.perm.db:own': 'Store its own data in an isolated database', + 'admin.plugins.perm.db:read:trips': 'Read trips the acting user can access', + 'admin.plugins.perm.db:read:users': 'Read basic profile info (name, avatar — never credentials)', + 'admin.plugins.perm.ws:broadcast:trip': 'Send real-time updates to trip members', + 'admin.plugins.perm.ws:broadcast:user': 'Send real-time updates to individual users', + 'admin.plugins.perm.hook:photo-provider': 'Provide photos to Memories', + 'admin.plugins.perm.hook:calendar-source': 'Provide events to the calendar', + 'admin.plugins.perm.http:outbound': 'Make outbound requests to its declared hosts', 'admin.addons.title': 'Addons', 'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.', 'admin.addons.catalog.packing.name': 'Lists',