mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
feat(plugins): richer admin panel + registry detail view
Admin list: flush-left header like Addons, type/reviewed badges, runtime health as a dot on the icon tile (text badge only for problem states), description + source-repo link on installed cards, manifest icon. Browse: cards show the plugin screenshot (docs/screenshot.png at the pinned commit) and open a detail dialog fed by GET /api/admin/plugins/registry/:id — live-manifest permissions in plain language, egress hosts, setup preview, repo/homepage links. Manifest fetched server-side through safeDownload at the reviewed commit, cached per plugin for 30 min and only when a detail opens. Also fixes the update flow (restart the running child around the install, keep the admin's enabled intent instead of force-activating disabled plugins), guards the icon lookup against Object.prototype names, reserves ids that would shadow static admin routes, stops negative-caching failed manifest fetches, and makes the version compare prerelease-safe.
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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<string, { data: unknown; expiresAt: number }>();
|
||||
|
||||
/** 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<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null }>> {
|
||||
async browse(): Promise<Array<Omit<RegistryEntry, 'versions'> & { 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<Record<string, unknown>> {
|
||||
const hit = _detailCache.get(id);
|
||||
if (hit && Date.now() < hit.expiresAt) return hit.data as Record<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown> => !!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;
|
||||
|
||||
@@ -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'); }));
|
||||
|
||||
@@ -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/],
|
||||
|
||||
Reference in New Issue
Block a user