mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
17f3c86967
Widget plugins can now declare capabilities.widget.slot 'hero' to render as a transparent, click-through overlay sitting on the boarding-pass bar's top edge (migration 157 persists capabilities; manifest validation server+SDK, feed exposes the slot, dashboard mounts hero frames above the pass). Sidebar stays the default slot. Replaces the trip-countdown example with Koffi, the TREK mascot: an animated suitcase with a 14-state behavior engine driven by real trip data — walking, waving, napping, trolley rolls, passport-stamp stickers, a split-flap luggage- tag countdown under 7 days, and sunglasses while the trip runs. Validated by the SDK suite like any author plugin; published as mauriceboe/trek-plugin-koffi in the registry. Migrations 156/157 follow the idempotent ALTER pattern (the reconciliation test re-runs everything from v135, so duplicate-column must stay non-fatal).
80 lines
3.2 KiB
TypeScript
80 lines
3.2 KiB
TypeScript
/**
|
||
* Manifest validation (#plugins, M6). Identical rules to the TREK server's
|
||
* loader, so `trek-plugin validate` locally == the registry CI gate. Returns a
|
||
* result (no throw) so the CLI can print every problem at once.
|
||
*/
|
||
|
||
export interface PluginManifest {
|
||
id: string;
|
||
name: string;
|
||
version: string;
|
||
apiVersion: number;
|
||
type: 'integration' | 'page' | 'widget';
|
||
permissions: string[];
|
||
egress: string[];
|
||
nativeModules?: boolean;
|
||
}
|
||
export interface ValidationResult {
|
||
ok: boolean;
|
||
errors: string[];
|
||
manifest?: PluginManifest;
|
||
}
|
||
|
||
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
|
||
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||
const TYPES = ['integration', 'page', 'widget'];
|
||
const KNOWN_PERMISSIONS = [
|
||
'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user',
|
||
'hook:photo-provider', 'hook:calendar-source', 'http:outbound',
|
||
];
|
||
|
||
function isKnownPermission(p: string): boolean {
|
||
return KNOWN_PERMISSIONS.includes(p) || p.startsWith('http:outbound:');
|
||
}
|
||
|
||
export function validateManifest(raw: unknown): ValidationResult {
|
||
const errors: string[] = [];
|
||
const m = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
|
||
if (!raw || typeof raw !== 'object') return { ok: false, errors: ['manifest is not an object'] };
|
||
|
||
const req = (k: string) => {
|
||
if (typeof m[k] !== 'string' || !m[k]) errors.push(`missing/invalid "${k}"`);
|
||
};
|
||
req('id'); req('name'); req('version'); req('type');
|
||
|
||
if (typeof m.id === 'string' && !ID_RE.test(m.id)) errors.push(`id "${m.id}" must be a lowercase slug (3–40 chars)`);
|
||
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('/')}`);
|
||
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)');
|
||
|
||
const permissions = Array.isArray(m.permissions) ? m.permissions.map(String) : [];
|
||
for (const p of permissions) if (!isKnownPermission(p)) errors.push(`unknown permission: ${p}`);
|
||
|
||
const egress = Array.isArray(m.egress) ? m.egress.map(String) : [];
|
||
const wantsOutbound = permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'));
|
||
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,
|
||
errors: [],
|
||
manifest: {
|
||
id: m.id as string,
|
||
name: m.name as string,
|
||
version: m.version as string,
|
||
apiVersion: typeof m.apiVersion === 'number' ? m.apiVersion : 1,
|
||
type: m.type as PluginManifest['type'],
|
||
permissions,
|
||
egress,
|
||
nativeModules: false,
|
||
},
|
||
};
|
||
}
|