Files
TREK/plugin-sdk/src/manifest.ts
T
mauriceboe af73943be8 feat(plugins): trek-plugin-sdk package — types, mock host, scaffolder, validator (M6)
The author-facing SDK, a standalone dependency-free package (not wired into the
app workspaces, so it can't affect the app build).

- definePlugin + the full plugin type surface (PluginContext, PluginRoute,
  PluginJob, PhotoProvider, CalendarSource) mirroring what the isolated runtime
  injects; PLUGIN_API_VERSION.
- createMockHost (trek-plugin-sdk/testing): a PluginContext that enforces the
  SAME permission model + membership checks, so authors can unit-test that their
  plugin degrades gracefully — no running TREK needed.
- validateManifest: the exact rules the registry CI runs, so a local pass
  predicts a CI pass.
- CLIs: create-trek-plugin (scaffolds a working plugin + README + starter iframe)
  and trek-plugin validate (manifest + README sanity).

Consolidating the server loader to import this shared validator is a follow-up.
2026-07-04 19:11:23 +02:00

75 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 "*"');
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,
},
};
}