mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(plugin-sdk): validate checks apiVersion and the manifest rules exactly as an install does
The SDK accepted any number as apiVersion while the host refuses anything but a positive integer no newer than its plugin API, a false green for an author. The manifest patterns, sets and caps now live in the host's protocol/manifest-rules.ts, and gen-plugin-facts writes them and PLUGIN_API_VERSION into the SDK, so check:plugin-facts covers them. One manifest corpus is judged by both validators in a server test (the SDK may only be stricter, case by case), and a typecheck test holds the SDK's plugin API types to the host child's copy, which gains rawBodyBase64.
This commit is contained in:
@@ -32,7 +32,8 @@ Because the package ships standalone it cannot import server code, so several fi
|
||||
|---|---|---|
|
||||
| `src/generated/host-facts.ts` (permissions, RPC methods, hooks, event catalog, `KNOWN_ADDONS`, the output contract `PLUGIN_ENTITY_FIELDS` / `PLUGIN_ENTITY_NESTED` / `PLUGIN_METHOD_RESULT`) | `server/src/nest/plugins/protocol/envelope.ts`, `protocol/output-contract.ts` and `server/src/addons.ts` via `server/scripts/gen-plugin-facts.ts` (**machine-written, never hand-edit**) | server `check:plugin-facts` CI gate; the entity interfaces in `src/index.ts` are type-checked against `PLUGIN_ENTITY_FIELDS` |
|
||||
| `src/egress-policy.ts` (pure helpers, kept **byte-identical**) | `server/src/nest/plugins/runtime/egress-policy.ts` | `test/permissions-parity.test.ts` |
|
||||
| `src/manifest.ts` (validation rules incl. the `trek` version range) | `server/src/nest/plugins/install/` | kept in sync by hand |
|
||||
| `src/manifest.ts` (validation rules incl. the `trek` version range; its patterns, sets, caps and `PLUGIN_API_VERSION` come from `generated/host-facts.ts`, written from `server/src/nest/plugins/protocol/manifest-rules.ts`) | `server/src/nest/plugins/install/manifest.ts` | the verdicts: `test/fixtures/manifest-corpus.ts`, run here by `test/manifest-corpus.test.ts` and through both validators by `server/tests/unit/plugins/manifest-parity.test.ts` (the SDK may only be stricter, case by case) |
|
||||
| `src/index.ts` (the plugin API types) | `server/src/nest/plugins/runtime/plugin-sdk.ts` (the child's copy) | `test/host-types.test-d.ts` (vitest typecheck mode, on inside the monorepo): plain shapes equal, ctx-carrying shapes and every ctx namespace with the same members; `test/host-types-names.test.ts` makes it list every shared name |
|
||||
| `src/cli/checks/*` (registry gates) | TREK-Plugins registry repo (`scripts/validate-entry.mjs`, `check-readme.mjs`) | `test/checks-parity.test.ts` (runs the registry's real script; `TREK_PLUGINS_REPO` points at a checkout) |
|
||||
| `src/zip.ts` (ZIP writer) | server `safe-extract.ts` reader | format frozen against the reader |
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// GENERATED — do not edit by hand.
|
||||
// Source: server/src/nest/plugins/protocol/envelope.ts + server/src/plugin-event-sink.ts
|
||||
// + server/src/nest/plugins/protocol/manifest-rules.ts
|
||||
// + server/src/nest/plugins/protocol/output-contract.ts + server/src/addons.ts
|
||||
// + shared/src/plugins/plugin-poi-facts.ts
|
||||
// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts
|
||||
@@ -1029,3 +1030,87 @@ export const PLUGIN_METHOD_RESULT: Readonly<Record<string, string>> = {
|
||||
'events.emit': 'host',
|
||||
'settings.get': 'host',
|
||||
};
|
||||
|
||||
/**
|
||||
* The plugin-API version this TREK implements. A manifest's `apiVersion` must be a
|
||||
* positive integer no greater than this, or the install refuses it.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = 1 as const;
|
||||
|
||||
/**
|
||||
* The manifest format rules TREK's install loader enforces
|
||||
* (server/src/nest/plugins/protocol/manifest-rules.ts), so `trek-plugin validate`
|
||||
* refuses exactly what an install refuses.
|
||||
*/
|
||||
export const MANIFEST_ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
|
||||
export const MANIFEST_RESERVED_IDS: readonly string[] = [
|
||||
'registry',
|
||||
'install',
|
||||
'rescan',
|
||||
];
|
||||
export const MANIFEST_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
export const MANIFEST_TYPES: readonly string[] = [
|
||||
'integration',
|
||||
'page',
|
||||
'widget',
|
||||
'trip-page',
|
||||
];
|
||||
export const MANIFEST_ADDON_ID_RE = /^[a-z][a-z0-9_]{1,39}$/;
|
||||
export const MANIFEST_HOST_RE = /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i;
|
||||
export const MANIFEST_WIDGET_SLOTS: readonly string[] = [
|
||||
'sidebar',
|
||||
'hero',
|
||||
'place-detail',
|
||||
'day-detail',
|
||||
'reservation-detail',
|
||||
];
|
||||
export const MANIFEST_REPLACEABLE_TABS: readonly string[] = [
|
||||
'transports',
|
||||
'buchungen',
|
||||
'listen',
|
||||
'finanzplan',
|
||||
'dateien',
|
||||
'collab',
|
||||
];
|
||||
export const MANIFEST_TRIP_PAGE_POSITION_MAX = 50;
|
||||
export const MANIFEST_ROUTE_PROFILES_MAX = 3;
|
||||
export const MANIFEST_ROUTE_PROFILE_ID_RE = /^[a-z][a-z0-9-]{0,23}$/;
|
||||
export const MANIFEST_CAPABILITY_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/;
|
||||
export const MANIFEST_MCP_TOOLS_MAX = 8;
|
||||
export const MANIFEST_TOOL_NAME_RE = /^[a-z0-9_]{1,48}$/;
|
||||
export const MANIFEST_SETTING_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/;
|
||||
export const MANIFEST_RESERVED_SETTING_KEYS: readonly string[] = [
|
||||
'constructor',
|
||||
'prototype',
|
||||
'__proto__',
|
||||
];
|
||||
export const MANIFEST_ACTIONS_MAX = 8;
|
||||
|
||||
/** Every attribute a settings-field object may carry; the host silently drops anything else. */
|
||||
export const SETTING_FIELD_KEYS = [
|
||||
'key',
|
||||
'label',
|
||||
'input_type',
|
||||
'placeholder',
|
||||
'hint',
|
||||
'required',
|
||||
'secret',
|
||||
'scope',
|
||||
'options',
|
||||
'oauth',
|
||||
'default',
|
||||
] as const;
|
||||
|
||||
/** Events a plugin notification channel may carry. Admin-scoped and in-app-only events are excluded. */
|
||||
export const CHANNEL_EVENTS: string[] = [
|
||||
'trip_invite',
|
||||
'booking_change',
|
||||
'trip_reminder',
|
||||
'todo_due',
|
||||
'vacay_invite',
|
||||
'collection_invite',
|
||||
'photos_shared',
|
||||
'collab_message',
|
||||
'packing_tagged',
|
||||
'plugin_notification',
|
||||
];
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
|
||||
import type { PLUGIN_ENTITY_FIELDS } from './generated/host-facts.js';
|
||||
|
||||
/** Bumped on any breaking change to the plugin API surface. Embed as `apiVersion` in your manifest. */
|
||||
export const PLUGIN_API_VERSION = 1 as const;
|
||||
/**
|
||||
* Bumped on any breaking change to the plugin API surface. Embed as `apiVersion` in your
|
||||
* manifest. Generated from TREK's protocol/envelope.ts, so it is the version the host
|
||||
* implements, and `validate` refuses a manifest declaring a newer one.
|
||||
*/
|
||||
export { PLUGIN_API_VERSION } from './generated/host-facts.js';
|
||||
|
||||
// Core entity shapes returned by ctx reads/writes. Only `id` is guaranteed; the rest
|
||||
// are the fields plugins most commonly use (typed for autocomplete), left optional.
|
||||
|
||||
+65
-41
@@ -30,11 +30,8 @@ export interface ManifestSettingField {
|
||||
}
|
||||
|
||||
/** Every attribute a settings-field object may carry — the host silently drops anything else.
|
||||
* Mirrors `SETTING_FIELD_KEYS` in the host's install/manifest.ts (parity-tested in
|
||||
* test/permissions-parity.test.ts); change both together. */
|
||||
export const SETTING_FIELD_KEYS = [
|
||||
'key', 'label', 'input_type', 'placeholder', 'hint', 'required', 'secret', 'scope', 'options', 'oauth', 'default',
|
||||
] as const;
|
||||
* Generated from the host's protocol/manifest-rules.ts (gen:plugin-facts). */
|
||||
export { SETTING_FIELD_KEYS } from './generated/host-facts.js';
|
||||
/**
|
||||
* The `default`s a manifest declares for one settings scope, keyed by field — the
|
||||
* host's effective value for any field nobody set. `trek-plugin dev` seeds `ctx.config`
|
||||
@@ -129,13 +126,16 @@ export interface ValidationResult {
|
||||
manifest?: NormalizedManifest;
|
||||
}
|
||||
|
||||
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
|
||||
// The format rules (patterns, allowed sets, caps) are the host's own
|
||||
// (server/src/nest/plugins/protocol/manifest-rules.ts), generated into host-facts.ts by
|
||||
// gen:plugin-facts, so they cannot drift from what an install enforces.
|
||||
const ID_RE = MANIFEST_ID_RE;
|
||||
// Ids that would collide with admin API route segments — refused by the server's
|
||||
// install loader, so surface it locally too.
|
||||
const RESERVED_IDS = new Set(['registry', 'install', 'rescan']);
|
||||
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
const RESERVED_IDS: ReadonlySet<string> = new Set(MANIFEST_RESERVED_IDS);
|
||||
const SEMVER_RE = MANIFEST_SEMVER_RE;
|
||||
// Addon-id format (lowercase slug, underscores allowed e.g. `llm_parsing`).
|
||||
const ADDON_ID_RE = /^[a-z][a-z0-9_]{1,39}$/;
|
||||
const ADDON_ID_RE = MANIFEST_ADDON_ID_RE;
|
||||
// The server's ADDON_IDS (server/src/addons.ts), generated into host-facts.ts by
|
||||
// gen:plugin-facts so it cannot drift again (dawarich, roadtrip and tours had). Used
|
||||
// so `trek-plugin validate` can WARN on an addon id TREK doesn't know, never a hard
|
||||
@@ -143,30 +143,19 @@ const ADDON_ID_RE = /^[a-z][a-z0-9_]{1,39}$/;
|
||||
export { KNOWN_ADDONS } from './generated/host-facts.js';
|
||||
// An outbound host: exact hostname (single-label sibling or dotted FQDN) or a
|
||||
// `*.`-wildcard with a multi-label suffix. No `*`, no `*.`, no whole-TLD `*.com`,
|
||||
// no spaces (mirrors the server manifest validator).
|
||||
const HOST_RE = /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i;
|
||||
const TYPES = ['integration', 'page', 'widget', 'trip-page'];
|
||||
// no spaces.
|
||||
const HOST_RE = MANIFEST_HOST_RE;
|
||||
const TYPES = MANIFEST_TYPES;
|
||||
|
||||
/** Mirrors the server's settings-key rules (install/manifest.ts). */
|
||||
const SETTING_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/;
|
||||
const RESERVED_SETTING_KEYS = new Set(['constructor', 'prototype', '__proto__']);
|
||||
/** The server's settings-key rules. */
|
||||
const SETTING_KEY_RE = MANIFEST_SETTING_KEY_RE;
|
||||
const RESERVED_SETTING_KEYS: ReadonlySet<string> = new Set(MANIFEST_RESERVED_SETTING_KEYS);
|
||||
|
||||
/**
|
||||
* Events a plugin notification channel may carry (mirrors the server's
|
||||
* PLUGIN_CHANNEL_EVENTS). Admin-scoped and in-app-only events are excluded.
|
||||
* Events a plugin notification channel may carry (the server's PLUGIN_CHANNEL_EVENTS).
|
||||
* Admin-scoped and in-app-only events are excluded.
|
||||
*/
|
||||
export const CHANNEL_EVENTS = [
|
||||
'trip_invite',
|
||||
'booking_change',
|
||||
'trip_reminder',
|
||||
'todo_due',
|
||||
'vacay_invite',
|
||||
'collection_invite',
|
||||
'photos_shared',
|
||||
'collab_message',
|
||||
'packing_tagged',
|
||||
'plugin_notification',
|
||||
];
|
||||
export { CHANNEL_EVENTS } from './generated/host-facts.js';
|
||||
// Mirror of the server's KNOWN_PERMISSIONS (server envelope.ts) — the host hard-rejects
|
||||
// anything not in this list at activation, so validate must know the full set.
|
||||
//
|
||||
@@ -174,7 +163,31 @@ export const CHANNEL_EVENTS = [
|
||||
// in cli/ui.ts, which only supplies the grouping and hints), so a permission added here can
|
||||
// never again go missing from the scaffolder — test/cli.test.ts fails until it has an entry.
|
||||
export { KNOWN_PERMISSIONS } from './generated/host-facts.js';
|
||||
import { KNOWN_PERMISSIONS, POI_CATEGORY_ICONS, POI_CATEGORY_LABEL_MAX, POI_CATEGORY_MAX } from './generated/host-facts.js';
|
||||
import {
|
||||
CHANNEL_EVENTS,
|
||||
KNOWN_PERMISSIONS,
|
||||
MANIFEST_ACTIONS_MAX,
|
||||
MANIFEST_ADDON_ID_RE,
|
||||
MANIFEST_CAPABILITY_NAME_RE,
|
||||
MANIFEST_HOST_RE,
|
||||
MANIFEST_ID_RE,
|
||||
MANIFEST_MCP_TOOLS_MAX,
|
||||
MANIFEST_REPLACEABLE_TABS,
|
||||
MANIFEST_RESERVED_IDS,
|
||||
MANIFEST_RESERVED_SETTING_KEYS,
|
||||
MANIFEST_ROUTE_PROFILE_ID_RE,
|
||||
MANIFEST_ROUTE_PROFILES_MAX,
|
||||
MANIFEST_SEMVER_RE,
|
||||
MANIFEST_SETTING_KEY_RE,
|
||||
MANIFEST_TOOL_NAME_RE,
|
||||
MANIFEST_TRIP_PAGE_POSITION_MAX,
|
||||
MANIFEST_TYPES,
|
||||
MANIFEST_WIDGET_SLOTS,
|
||||
PLUGIN_API_VERSION,
|
||||
POI_CATEGORY_ICONS,
|
||||
POI_CATEGORY_LABEL_MAX,
|
||||
POI_CATEGORY_MAX,
|
||||
} from './generated/host-facts.js';
|
||||
|
||||
function isKnownPermission(p: string): boolean {
|
||||
return KNOWN_PERMISSIONS.includes(p) || p.startsWith('http:outbound:');
|
||||
@@ -225,7 +238,16 @@ export function validateManifest(raw: unknown): ValidationResult {
|
||||
: 'missing "trek" — declare the TREK versions this plugin supports, e.g. ">=4.0.0 <5.0.0"',
|
||||
);
|
||||
}
|
||||
if (m.apiVersion !== undefined && typeof m.apiVersion !== 'number') errors.push('apiVersion must be a number');
|
||||
// The host's rule (install/manifest.ts): a positive integer, and an install refuses one
|
||||
// newer than the plugin API it implements. A typeof check alone let 0, -1 and 1.5 pass
|
||||
// here and fail at install.
|
||||
if (m.apiVersion !== undefined) {
|
||||
if (typeof m.apiVersion !== 'number' || !Number.isInteger(m.apiVersion) || m.apiVersion < 1) {
|
||||
errors.push('apiVersion must be a positive integer');
|
||||
} else if (m.apiVersion > PLUGIN_API_VERSION) {
|
||||
errors.push(`apiVersion ${m.apiVersion} is newer than the plugin API TREK implements (v${PLUGIN_API_VERSION})`);
|
||||
}
|
||||
}
|
||||
if (m.nativeModules === true) errors.push('native modules are not allowed (v1)');
|
||||
|
||||
const permissions = Array.isArray(m.permissions) ? m.permissions.map(String) : [];
|
||||
@@ -270,21 +292,21 @@ export function validateManifest(raw: unknown): ValidationResult {
|
||||
errors.push('capabilities.settingsUi must be a boolean');
|
||||
}
|
||||
const widget = capabilities?.widget;
|
||||
if (widget?.slot !== undefined && widget.slot !== 'sidebar' && widget.slot !== 'hero' && widget.slot !== 'place-detail' && widget.slot !== 'day-detail' && widget.slot !== 'reservation-detail') {
|
||||
if (widget?.slot !== undefined && (typeof widget.slot !== 'string' || !MANIFEST_WIDGET_SLOTS.includes(widget.slot))) {
|
||||
errors.push(`widget slot must be "sidebar", "hero", "place-detail", "day-detail" or "reservation-detail", got "${String(widget.slot)}"`);
|
||||
}
|
||||
// Mirrors the server's REPLACEABLE_TABS — 'plan' is never replaceable.
|
||||
const tripPage = capabilities?.tripPage;
|
||||
if (tripPage !== undefined) {
|
||||
const REPLACEABLE = ['transports', 'buchungen', 'listen', 'finanzplan', 'dateien', 'collab'];
|
||||
const REPLACEABLE = MANIFEST_REPLACEABLE_TABS;
|
||||
if (tripPage.replaces !== undefined) {
|
||||
if (!Array.isArray(tripPage.replaces)) errors.push('capabilities.tripPage.replaces must be an array');
|
||||
else for (const t of tripPage.replaces) {
|
||||
if (typeof t !== 'string' || !REPLACEABLE.includes(t)) errors.push(`capabilities.tripPage.replaces: "${String(t)}" is not a replaceable tab (${REPLACEABLE.join(', ')})`);
|
||||
}
|
||||
}
|
||||
if (tripPage.position !== undefined && (typeof tripPage.position !== 'number' || !Number.isInteger(tripPage.position) || tripPage.position < 0 || tripPage.position > 50)) {
|
||||
errors.push('capabilities.tripPage.position must be an integer between 0 and 50');
|
||||
if (tripPage.position !== undefined && (typeof tripPage.position !== 'number' || !Number.isInteger(tripPage.position) || tripPage.position < 0 || tripPage.position > MANIFEST_TRIP_PAGE_POSITION_MAX)) {
|
||||
errors.push(`capabilities.tripPage.position must be an integer between 0 and ${MANIFEST_TRIP_PAGE_POSITION_MAX}`);
|
||||
}
|
||||
}
|
||||
// Mirrors the server's PLUGIN_CHANNEL_EVENTS. Admin-scoped events (version_available)
|
||||
@@ -319,13 +341,15 @@ export function validateManifest(raw: unknown): ValidationResult {
|
||||
}
|
||||
if (!Array.isArray(routeProfiles)) errors.push('capabilities.routeProfiles must be an array');
|
||||
else {
|
||||
if (routeProfiles.length > 3) errors.push('capabilities.routeProfiles: at most 3 profiles');
|
||||
if (routeProfiles.length > MANIFEST_ROUTE_PROFILES_MAX) {
|
||||
errors.push(`capabilities.routeProfiles: at most ${MANIFEST_ROUTE_PROFILES_MAX} profiles`);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
normalizedRouteProfiles = [];
|
||||
for (const v of routeProfiles) {
|
||||
const p = (v && typeof v === 'object' ? v : {}) as { id?: unknown; label?: unknown; icon?: unknown };
|
||||
const id = typeof p.id === 'string' ? p.id : '';
|
||||
if (!/^[a-z][a-z0-9-]{0,23}$/.test(id)) errors.push('capabilities.routeProfiles: id must be lowercase [a-z][a-z0-9-], max 24 chars');
|
||||
if (!MANIFEST_ROUTE_PROFILE_ID_RE.test(id)) errors.push('capabilities.routeProfiles: id must be lowercase [a-z][a-z0-9-], max 24 chars');
|
||||
else if (seen.has(id)) errors.push(`capabilities.routeProfiles: duplicate id "${id}"`);
|
||||
else seen.add(id);
|
||||
const label = typeof p.label === 'string' ? p.label.trim() : '';
|
||||
@@ -350,7 +374,7 @@ export function validateManifest(raw: unknown): ValidationResult {
|
||||
}
|
||||
if (!Array.isArray(mcpTools)) errors.push('capabilities.mcpTools must be an array');
|
||||
else {
|
||||
if (mcpTools.length > 8) errors.push('capabilities.mcpTools: at most 8 tools');
|
||||
if (mcpTools.length > MANIFEST_MCP_TOOLS_MAX) errors.push(`capabilities.mcpTools: at most ${MANIFEST_MCP_TOOLS_MAX} tools`);
|
||||
const seenTools = new Set<string>();
|
||||
for (const v of mcpTools) {
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) {
|
||||
@@ -359,7 +383,7 @@ export function validateManifest(raw: unknown): ValidationResult {
|
||||
}
|
||||
const t = v as Record<string, unknown>;
|
||||
const name = typeof t.name === 'string' ? t.name : '';
|
||||
if (!/^[a-z0-9_]{1,48}$/.test(name)) {
|
||||
if (!MANIFEST_TOOL_NAME_RE.test(name)) {
|
||||
errors.push('capabilities.mcpTools: name must be lowercase [a-z0-9_], max 48 chars');
|
||||
continue;
|
||||
}
|
||||
@@ -445,7 +469,7 @@ export function validateManifest(raw: unknown): ValidationResult {
|
||||
if (m.actions !== undefined) {
|
||||
if (!Array.isArray(m.actions)) errors.push('actions must be an array');
|
||||
else {
|
||||
if (m.actions.length > 8) errors.push('at most 8 actions');
|
||||
if (m.actions.length > MANIFEST_ACTIONS_MAX) errors.push(`at most ${MANIFEST_ACTIONS_MAX} actions`);
|
||||
const seen = new Set<string>();
|
||||
for (const a of m.actions as Array<Record<string, unknown>>) {
|
||||
if (!a || typeof a !== 'object') { errors.push('each action must be an object'); continue; }
|
||||
@@ -567,7 +591,7 @@ function validatePoiCategories(raw: unknown, permissions: string[], errors: stri
|
||||
}
|
||||
|
||||
// Export/event names exposed to other plugins (dots allowed for event names).
|
||||
const CAPABILITY_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/;
|
||||
const CAPABILITY_NAME_RE = MANIFEST_CAPABILITY_NAME_RE;
|
||||
|
||||
function validateCapabilityNames(raw: unknown, field: string, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* One corpus of manifests, judged by both validators: the SDK's `validateManifest`
|
||||
* (what `trek-plugin validate` says) and the host's `parseManifest` with
|
||||
* `requireTrek` (what an install says). `test/manifest-corpus.test.ts` runs the SDK
|
||||
* over it here; `server/tests/unit/plugins/manifest-parity.test.ts` runs both and
|
||||
* fails on any case where they disagree with the verdicts below.
|
||||
*
|
||||
* The verdict that must never happen is the SDK accepting what the host refuses: a
|
||||
* false green lets an author cut a release the registry then rejects. The SDK may be
|
||||
* stricter than the host on purpose (a declaration the host installs but never
|
||||
* uses); each such case carries `sdk` and the reason in `why`.
|
||||
*
|
||||
* Plain data, no imports: the server test reaches this file by a relative path.
|
||||
*/
|
||||
|
||||
export type Verdict = 'accept' | 'reject';
|
||||
|
||||
export interface ManifestCase {
|
||||
name: string;
|
||||
manifest: Record<string, unknown>;
|
||||
/** What an install answers. */
|
||||
host: Verdict;
|
||||
/** What `validate` answers, when it differs from the host (only ever stricter). */
|
||||
sdk?: Verdict;
|
||||
/** Why the SDK is stricter here. Required with `sdk`. */
|
||||
why?: string;
|
||||
}
|
||||
|
||||
const base = {
|
||||
id: 'demo-plugin',
|
||||
name: 'Demo',
|
||||
version: '1.0.0',
|
||||
type: 'integration',
|
||||
trek: '>=4.0.0 <5.0.0',
|
||||
};
|
||||
|
||||
const tool = (name: string) => ({ name, description: 'Looks something up.' });
|
||||
const poi = { id: 'trails', label: 'Trails', icon: 'Footprints', color: '#22aa66' };
|
||||
|
||||
export const MANIFEST_CORPUS: ManifestCase[] = [
|
||||
{ name: 'the minimal manifest', manifest: { ...base }, host: 'accept' },
|
||||
{ name: 'not an object', manifest: null as unknown as Record<string, unknown>, host: 'reject' },
|
||||
|
||||
// apiVersion: a positive integer no newer than the host's plugin API
|
||||
{ name: 'apiVersion 1', manifest: { ...base, apiVersion: 1 }, host: 'accept' },
|
||||
{ name: 'apiVersion 0', manifest: { ...base, apiVersion: 0 }, host: 'reject' },
|
||||
{ name: 'apiVersion -1', manifest: { ...base, apiVersion: -1 }, host: 'reject' },
|
||||
{ name: 'apiVersion 1.5', manifest: { ...base, apiVersion: 1.5 }, host: 'reject' },
|
||||
{ name: 'apiVersion as a string', manifest: { ...base, apiVersion: '1' }, host: 'reject' },
|
||||
{ name: 'apiVersion null', manifest: { ...base, apiVersion: null }, host: 'reject' },
|
||||
{ name: 'apiVersion newer than the host', manifest: { ...base, apiVersion: 2 }, host: 'reject' },
|
||||
|
||||
// id, version, type, trek
|
||||
{ name: 'id too short', manifest: { ...base, id: 'ab' }, host: 'reject' },
|
||||
{ name: 'id with capitals', manifest: { ...base, id: 'Demo-plugin' }, host: 'reject' },
|
||||
{ name: 'reserved id', manifest: { ...base, id: 'registry' }, host: 'reject' },
|
||||
{ name: 'missing name', manifest: { ...base, name: '' }, host: 'reject' },
|
||||
{ name: 'version without patch', manifest: { ...base, version: '1.0' }, host: 'reject' },
|
||||
{ name: 'prerelease version', manifest: { ...base, version: '1.0.0-beta.1' }, host: 'accept' },
|
||||
{ name: 'version with build metadata', manifest: { ...base, version: '1.0.0+build' }, host: 'reject' },
|
||||
{ name: 'unknown type', manifest: { ...base, type: 'theme' }, host: 'reject' },
|
||||
{ name: 'trip-page type', manifest: { ...base, type: 'trip-page' }, host: 'accept' },
|
||||
{ name: 'missing trek range', manifest: { ...base, trek: undefined }, host: 'reject' },
|
||||
{ name: 'unsatisfiable trek range', manifest: { ...base, trek: '>=5.0.0 <4.0.0' }, host: 'reject' },
|
||||
{ name: 'trek range that is not semver', manifest: { ...base, trek: 'latest' }, host: 'reject' },
|
||||
{ name: 'native modules', manifest: { ...base, nativeModules: true }, host: 'reject' },
|
||||
|
||||
// permissions and egress
|
||||
{ name: 'a known permission', manifest: { ...base, permissions: ['db:read:trips'] }, host: 'accept' },
|
||||
{ name: 'an unknown permission', manifest: { ...base, permissions: ['db:read:everything'] }, host: 'reject' },
|
||||
{
|
||||
name: 'outbound host with its egress',
|
||||
manifest: { ...base, permissions: ['http:outbound:api.example.com'], egress: ['api.example.com'] },
|
||||
host: 'accept',
|
||||
},
|
||||
{
|
||||
name: 'outbound to a whole TLD',
|
||||
manifest: { ...base, permissions: ['http:outbound:*.com'], egress: ['*.com'] },
|
||||
host: 'reject',
|
||||
},
|
||||
{ name: 'outbound without egress', manifest: { ...base, permissions: ['http:outbound'] }, host: 'reject' },
|
||||
{
|
||||
name: 'operator egress without hosts',
|
||||
manifest: { ...base, permissions: ['http:outbound'], operatorEgress: true },
|
||||
host: 'accept',
|
||||
},
|
||||
{ name: 'operator egress not a boolean', manifest: { ...base, operatorEgress: 'yes' }, host: 'reject' },
|
||||
{ name: 'operator egress without outbound', manifest: { ...base, operatorEgress: true }, host: 'reject' },
|
||||
{
|
||||
name: 'bare wildcard egress',
|
||||
manifest: { ...base, permissions: ['http:outbound'], egress: ['*'] },
|
||||
host: 'reject',
|
||||
},
|
||||
{
|
||||
name: 'egress host with a scheme',
|
||||
manifest: { ...base, permissions: ['http:outbound'], egress: ['https://api.example.com'] },
|
||||
host: 'reject',
|
||||
},
|
||||
|
||||
// capabilities
|
||||
{ name: 'hero widget', manifest: { ...base, type: 'widget', capabilities: { widget: { slot: 'hero' } } }, host: 'accept' },
|
||||
{ name: 'unknown widget slot', manifest: { ...base, capabilities: { widget: { slot: 'footer' } } }, host: 'reject' },
|
||||
{ name: 'trip page replacing collab', manifest: { ...base, capabilities: { tripPage: { replaces: ['collab'] } } }, host: 'accept' },
|
||||
{ name: 'trip page replacing the plan', manifest: { ...base, capabilities: { tripPage: { replaces: ['plan'] } } }, host: 'reject' },
|
||||
{ name: 'trip page at position 50', manifest: { ...base, capabilities: { tripPage: { position: 50 } } }, host: 'accept' },
|
||||
{ name: 'trip page at position 51', manifest: { ...base, capabilities: { tripPage: { position: 51 } } }, host: 'reject' },
|
||||
{ name: 'settingsUi not a boolean', manifest: { ...base, capabilities: { settingsUi: 'yes' } }, host: 'reject' },
|
||||
{
|
||||
name: 'route profiles with their grant',
|
||||
manifest: { ...base, permissions: ['hook:route-provider'], capabilities: { routeProfiles: [{ id: 'bike', label: 'Bike' }] } },
|
||||
host: 'accept',
|
||||
},
|
||||
{
|
||||
name: 'four route profiles',
|
||||
manifest: {
|
||||
...base,
|
||||
permissions: ['hook:route-provider'],
|
||||
capabilities: { routeProfiles: ['a', 'b', 'c', 'd'].map((id) => ({ id, label: id })) },
|
||||
},
|
||||
host: 'reject',
|
||||
},
|
||||
{
|
||||
name: 'route profile id with capitals',
|
||||
manifest: { ...base, permissions: ['hook:route-provider'], capabilities: { routeProfiles: [{ id: 'Bike', label: 'Bike' }] } },
|
||||
host: 'reject',
|
||||
},
|
||||
{
|
||||
name: 'route profiles without their grant',
|
||||
manifest: { ...base, capabilities: { routeProfiles: [{ id: 'bike', label: 'Bike' }] } },
|
||||
host: 'accept',
|
||||
sdk: 'reject',
|
||||
why: 'the host installs the declaration but never offers the profiles without hook:route-provider',
|
||||
},
|
||||
{
|
||||
name: 'notification channel with its grant',
|
||||
manifest: {
|
||||
...base,
|
||||
permissions: ['hook:notification-channel'],
|
||||
capabilities: { notificationChannel: { events: ['trip_invite'] } },
|
||||
},
|
||||
host: 'accept',
|
||||
},
|
||||
{
|
||||
name: 'notification channel for an admin-only event',
|
||||
manifest: {
|
||||
...base,
|
||||
permissions: ['hook:notification-channel'],
|
||||
capabilities: { notificationChannel: { events: ['version_available'] } },
|
||||
},
|
||||
host: 'reject',
|
||||
},
|
||||
{
|
||||
name: 'notification channel without its grant',
|
||||
manifest: { ...base, capabilities: { notificationChannel: { events: ['trip_invite'] } } },
|
||||
host: 'accept',
|
||||
sdk: 'reject',
|
||||
why: 'the host installs the declaration but never delivers to it without hook:notification-channel',
|
||||
},
|
||||
{
|
||||
name: 'MCP tools with their grant',
|
||||
manifest: { ...base, permissions: ['mcp:tools'], capabilities: { mcpTools: [tool('lookup')] } },
|
||||
host: 'accept',
|
||||
},
|
||||
{
|
||||
name: 'MCP tools without their grant',
|
||||
manifest: { ...base, capabilities: { mcpTools: [tool('lookup')] } },
|
||||
host: 'reject',
|
||||
},
|
||||
{
|
||||
name: 'MCP tool name with a dash',
|
||||
manifest: { ...base, permissions: ['mcp:tools'], capabilities: { mcpTools: [tool('look-up')] } },
|
||||
host: 'reject',
|
||||
},
|
||||
{
|
||||
name: 'nine MCP tools',
|
||||
manifest: {
|
||||
...base,
|
||||
permissions: ['mcp:tools'],
|
||||
capabilities: { mcpTools: Array.from({ length: 9 }, (_, i) => tool(`tool_${i}`)) },
|
||||
},
|
||||
host: 'reject',
|
||||
},
|
||||
{
|
||||
name: 'MCP tool without a description',
|
||||
manifest: { ...base, permissions: ['mcp:tools'], capabilities: { mcpTools: [{ name: 'lookup' }] } },
|
||||
host: 'reject',
|
||||
},
|
||||
{
|
||||
name: 'POI categories with their grant',
|
||||
manifest: { ...base, permissions: ['hook:poi-category-provider'], capabilities: { poiCategories: [poi] } },
|
||||
host: 'accept',
|
||||
},
|
||||
{
|
||||
name: 'POI category with an unknown icon',
|
||||
manifest: {
|
||||
...base,
|
||||
permissions: ['hook:poi-category-provider'],
|
||||
capabilities: { poiCategories: [{ ...poi, icon: 'NotAnIcon' }] },
|
||||
},
|
||||
host: 'reject',
|
||||
},
|
||||
{
|
||||
name: 'POI categories without their grant',
|
||||
manifest: { ...base, capabilities: { poiCategories: [poi] } },
|
||||
host: 'accept',
|
||||
sdk: 'reject',
|
||||
why: 'the host installs the declaration but never shows the chips without hook:poi-category-provider',
|
||||
},
|
||||
{ name: 'provided names', manifest: { ...base, capabilities: { provides: ['rate.updated'] } }, host: 'accept' },
|
||||
{ name: 'provided name starting with a digit', manifest: { ...base, capabilities: { provides: ['1rate'] } }, host: 'reject' },
|
||||
|
||||
// settings and actions
|
||||
{ name: 'a dotted settings key', manifest: { ...base, settings: [{ key: 'api.key' }] }, host: 'accept' },
|
||||
{ name: 'a prototype settings key', manifest: { ...base, settings: [{ key: '__proto__' }] }, host: 'reject' },
|
||||
{ name: 'settings options not a list', manifest: { ...base, settings: [{ key: 'mode', options: 'a,b' }] }, host: 'reject' },
|
||||
{ name: 'settings option without a value', manifest: { ...base, settings: [{ key: 'mode', options: [{ value: '' }] }] }, host: 'reject' },
|
||||
{ name: 'string settings options', manifest: { ...base, settings: [{ key: 'mode', options: ['a', 'b'] }] }, host: 'accept' },
|
||||
{ name: 'an action', manifest: { ...base, actions: [{ key: 'test', label: 'Test' }] }, host: 'accept' },
|
||||
{
|
||||
name: 'nine actions',
|
||||
manifest: { ...base, actions: Array.from({ length: 9 }, (_, i) => ({ key: `a${i}` })) },
|
||||
host: 'reject',
|
||||
},
|
||||
{ name: 'duplicate actions', manifest: { ...base, actions: [{ key: 'test' }, { key: 'test' }] }, host: 'reject' },
|
||||
{ name: 'action with an unknown scope', manifest: { ...base, actions: [{ key: 'test', scope: 'admin' }] }, host: 'reject' },
|
||||
|
||||
// dependencies
|
||||
{ name: 'a required addon', manifest: { ...base, requiredAddons: ['llm_parsing'] }, host: 'accept' },
|
||||
{ name: 'a malformed addon id', manifest: { ...base, requiredAddons: ['LLM'] }, host: 'reject' },
|
||||
{
|
||||
name: 'a plugin dependency',
|
||||
manifest: { ...base, pluginDependencies: [{ id: 'other-plugin', version: '^1.0.0' }] },
|
||||
host: 'accept',
|
||||
},
|
||||
{
|
||||
name: 'a dependency on itself',
|
||||
manifest: { ...base, pluginDependencies: [{ id: 'demo-plugin', version: '^1.0.0' }] },
|
||||
host: 'reject',
|
||||
},
|
||||
{
|
||||
name: 'a dependency with a junk range',
|
||||
manifest: { ...base, pluginDependencies: [{ id: 'other-plugin', version: 'whenever' }] },
|
||||
host: 'reject',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* test/host-types.test-d.ts holds the SDK's plugin API to the host child's copy, name by
|
||||
* name. This makes sure it names every one: an export both files declare and the type
|
||||
* test does not list would drift unchecked. Skips outside the monorepo, like the other
|
||||
* host parity tests.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const hostFile = path.resolve(here, '../../server/src/nest/plugins/runtime/plugin-sdk.ts');
|
||||
const sdkFile = path.resolve(here, '../src/index.ts');
|
||||
const typeTest = path.resolve(here, 'host-types.test-d.ts');
|
||||
|
||||
const exportsOf = (file: string): Set<string> =>
|
||||
new Set(
|
||||
[...fs.readFileSync(file, 'utf8').matchAll(/^export (?:declare )?(?:interface|type|const|function|class|enum) (\w+)/gm)].map(
|
||||
(m) => m[1],
|
||||
),
|
||||
);
|
||||
|
||||
describe.skipIf(!fs.existsSync(hostFile))('host type parity covers every shared name', () => {
|
||||
it('lists each type both sides export', () => {
|
||||
const host = exportsOf(hostFile);
|
||||
const shared = [...exportsOf(sdkFile)].filter((name) => host.has(name) && name !== 'definePlugin').sort();
|
||||
const listed = new Set([...fs.readFileSync(typeTest, 'utf8').matchAll(/expectTypeOf<(?:keyof )?Sdk\.(\w+)>/g)].map((m) => m[1]));
|
||||
expect(shared.length).toBeGreaterThan(40);
|
||||
expect(shared.filter((name) => !listed.has(name))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Type parity between the SDK's plugin API (src/index.ts) and the copy the host's plugin
|
||||
* child runs on (server/src/nest/plugins/runtime/plugin-sdk.ts). Both declare the same
|
||||
* names; vitest's typecheck mode compiles this file (vitest.config.ts enables it only
|
||||
* inside the monorepo, where the host file exists).
|
||||
*
|
||||
* Two kinds of name:
|
||||
* - the plain shapes (requests, results, contributions) are the same type on both sides;
|
||||
* - the shapes that carry a `ctx` are refined in the SDK: its PluginContext types the
|
||||
* entity results (`Trip`, `Place`, …) and some requests where the host's child says
|
||||
* `unknown`, so those are held to the same members (a method or field on one side
|
||||
* only is the drift this catches, as `rawBodyBase64` was), not to one type.
|
||||
*
|
||||
* test/host-types-names.test.ts checks every name both files export is listed here.
|
||||
*/
|
||||
import { expectTypeOf, test } from 'vitest';
|
||||
import type * as Sdk from '../src/index.js';
|
||||
import type * as Host from '../../server/src/nest/plugins/runtime/plugin-sdk.js';
|
||||
|
||||
test('the plain shapes are one type on both sides', () => {
|
||||
expectTypeOf<Sdk.PluginResponse>().toEqualTypeOf<Host.PluginResponse>();
|
||||
expectTypeOf<Sdk.Photo>().toEqualTypeOf<Host.Photo>();
|
||||
expectTypeOf<Sdk.NotificationMessage>().toEqualTypeOf<Host.NotificationMessage>();
|
||||
expectTypeOf<Sdk.PluginActionResult>().toEqualTypeOf<Host.PluginActionResult>();
|
||||
expectTypeOf<Sdk.CalendarEvent>().toEqualTypeOf<Host.CalendarEvent>();
|
||||
expectTypeOf<Sdk.PlaceDetailItem>().toEqualTypeOf<Host.PlaceDetailItem>();
|
||||
expectTypeOf<Sdk.SearchResultPlace>().toEqualTypeOf<Host.SearchResultPlace>();
|
||||
expectTypeOf<Sdk.SearchRequest>().toEqualTypeOf<Host.SearchRequest>();
|
||||
expectTypeOf<Sdk.TripWarning>().toEqualTypeOf<Host.TripWarning>();
|
||||
expectTypeOf<Sdk.ContributionTone>().toEqualTypeOf<Host.ContributionTone>();
|
||||
expectTypeOf<Sdk.TableColumnContribution>().toEqualTypeOf<Host.TableColumnContribution>();
|
||||
expectTypeOf<Sdk.TableActionContribution>().toEqualTypeOf<Host.TableActionContribution>();
|
||||
expectTypeOf<Sdk.TableContribution>().toEqualTypeOf<Host.TableContribution>();
|
||||
expectTypeOf<Sdk.MapMarkerContribution>().toEqualTypeOf<Host.MapMarkerContribution>();
|
||||
expectTypeOf<Sdk.MapLayerFeature>().toEqualTypeOf<Host.MapLayerFeature>();
|
||||
expectTypeOf<Sdk.MapLayerContribution>().toEqualTypeOf<Host.MapLayerContribution>();
|
||||
expectTypeOf<Sdk.RouteWaypoint>().toEqualTypeOf<Host.RouteWaypoint>();
|
||||
expectTypeOf<Sdk.RouteRequest>().toEqualTypeOf<Host.RouteRequest>();
|
||||
expectTypeOf<Sdk.RouteLeg>().toEqualTypeOf<Host.RouteLeg>();
|
||||
expectTypeOf<Sdk.RouteViaPoint>().toEqualTypeOf<Host.RouteViaPoint>();
|
||||
expectTypeOf<Sdk.RouteProviderResult>().toEqualTypeOf<Host.RouteProviderResult>();
|
||||
expectTypeOf<Sdk.DayScheduleContribution>().toEqualTypeOf<Host.DayScheduleContribution>();
|
||||
expectTypeOf<Sdk.DayTintContribution>().toEqualTypeOf<Host.DayTintContribution>();
|
||||
expectTypeOf<Sdk.PdfSection>().toEqualTypeOf<Host.PdfSection>();
|
||||
expectTypeOf<Sdk.AtlasLayerCountry>().toEqualTypeOf<Host.AtlasLayerCountry>();
|
||||
expectTypeOf<Sdk.AtlasLayer>().toEqualTypeOf<Host.AtlasLayer>();
|
||||
expectTypeOf<Sdk.TripCardContribution>().toEqualTypeOf<Host.TripCardContribution>();
|
||||
expectTypeOf<Sdk.JournalEntryRow>().toEqualTypeOf<Host.JournalEntryRow>();
|
||||
expectTypeOf<Sdk.PluginRequest>().toEqualTypeOf<Host.PluginRequest>();
|
||||
});
|
||||
|
||||
test('the shapes that carry a ctx have the same members on both sides', () => {
|
||||
expectTypeOf<keyof Sdk.PluginContext>().toEqualTypeOf<keyof Host.PluginContext>();
|
||||
expectTypeOf<keyof Sdk.PluginRoute>().toEqualTypeOf<keyof Host.PluginRoute>();
|
||||
expectTypeOf<keyof Sdk.PluginJob>().toEqualTypeOf<keyof Host.PluginJob>();
|
||||
expectTypeOf<keyof Sdk.PhotoProvider>().toEqualTypeOf<keyof Host.PhotoProvider>();
|
||||
expectTypeOf<keyof Sdk.NotificationChannel>().toEqualTypeOf<keyof Host.NotificationChannel>();
|
||||
expectTypeOf<keyof Sdk.CalendarSource>().toEqualTypeOf<keyof Host.CalendarSource>();
|
||||
expectTypeOf<keyof Sdk.PlaceDetailProvider>().toEqualTypeOf<keyof Host.PlaceDetailProvider>();
|
||||
expectTypeOf<keyof Sdk.SearchProvider>().toEqualTypeOf<keyof Host.SearchProvider>();
|
||||
expectTypeOf<keyof Sdk.PoiCategoryProvider>().toEqualTypeOf<keyof Host.PoiCategoryProvider>();
|
||||
expectTypeOf<keyof Sdk.WarningProvider>().toEqualTypeOf<keyof Host.WarningProvider>();
|
||||
expectTypeOf<keyof Sdk.TableContributor>().toEqualTypeOf<keyof Host.TableContributor>();
|
||||
expectTypeOf<keyof Sdk.MapMarkerProvider>().toEqualTypeOf<keyof Host.MapMarkerProvider>();
|
||||
expectTypeOf<keyof Sdk.MapLayerProvider>().toEqualTypeOf<keyof Host.MapLayerProvider>();
|
||||
expectTypeOf<keyof Sdk.RouteProvider>().toEqualTypeOf<keyof Host.RouteProvider>();
|
||||
expectTypeOf<keyof Sdk.DayScheduleProvider>().toEqualTypeOf<keyof Host.DayScheduleProvider>();
|
||||
expectTypeOf<keyof Sdk.DayTintProvider>().toEqualTypeOf<keyof Host.DayTintProvider>();
|
||||
expectTypeOf<keyof Sdk.PdfSectionProvider>().toEqualTypeOf<keyof Host.PdfSectionProvider>();
|
||||
expectTypeOf<keyof Sdk.AtlasLayerProvider>().toEqualTypeOf<keyof Host.AtlasLayerProvider>();
|
||||
expectTypeOf<keyof Sdk.TripCardProvider>().toEqualTypeOf<keyof Host.TripCardProvider>();
|
||||
expectTypeOf<keyof Sdk.JournalEntryProvider>().toEqualTypeOf<keyof Host.JournalEntryProvider>();
|
||||
expectTypeOf<keyof Sdk.PluginEventSubscription>().toEqualTypeOf<keyof Host.PluginEventSubscription>();
|
||||
expectTypeOf<keyof Sdk.PluginExport>().toEqualTypeOf<keyof Host.PluginExport>();
|
||||
expectTypeOf<keyof Sdk.PluginSubscription>().toEqualTypeOf<keyof Host.PluginSubscription>();
|
||||
expectTypeOf<keyof Sdk.McpToolProvider>().toEqualTypeOf<keyof Host.McpToolProvider>();
|
||||
expectTypeOf<keyof Sdk.PluginDefinition>().toEqualTypeOf<keyof Host.PluginDefinition>();
|
||||
});
|
||||
|
||||
test('every ctx namespace offers the same methods on both sides', () => {
|
||||
type Members<T> = T extends (...args: never[]) => unknown ? never : keyof T;
|
||||
expectTypeOf<Members<Sdk.PluginContext['id']>>().toEqualTypeOf<Members<Host.PluginContext['id']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['config']>>().toEqualTypeOf<Members<Host.PluginContext['config']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['settings']>>().toEqualTypeOf<Members<Host.PluginContext['settings']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['db']>>().toEqualTypeOf<Members<Host.PluginContext['db']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['trips']>>().toEqualTypeOf<Members<Host.PluginContext['trips']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['reservations']>>().toEqualTypeOf<Members<Host.PluginContext['reservations']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['accommodations']>>().toEqualTypeOf<Members<Host.PluginContext['accommodations']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['packing']>>().toEqualTypeOf<Members<Host.PluginContext['packing']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['files']>>().toEqualTypeOf<Members<Host.PluginContext['files']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['collab']>>().toEqualTypeOf<Members<Host.PluginContext['collab']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['notify']>>().toEqualTypeOf<Members<Host.PluginContext['notify']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['ai']>>().toEqualTypeOf<Members<Host.PluginContext['ai']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['oauth']>>().toEqualTypeOf<Members<Host.PluginContext['oauth']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['scheduler']>>().toEqualTypeOf<Members<Host.PluginContext['scheduler']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['weather']>>().toEqualTypeOf<Members<Host.PluginContext['weather']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['rates']>>().toEqualTypeOf<Members<Host.PluginContext['rates']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['categories']>>().toEqualTypeOf<Members<Host.PluginContext['categories']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['tags']>>().toEqualTypeOf<Members<Host.PluginContext['tags']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['todos']>>().toEqualTypeOf<Members<Host.PluginContext['todos']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['journal']>>().toEqualTypeOf<Members<Host.PluginContext['journal']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['atlas']>>().toEqualTypeOf<Members<Host.PluginContext['atlas']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['vacay']>>().toEqualTypeOf<Members<Host.PluginContext['vacay']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['collections']>>().toEqualTypeOf<Members<Host.PluginContext['collections']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['daynotes']>>().toEqualTypeOf<Members<Host.PluginContext['daynotes']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['costs']>>().toEqualTypeOf<Members<Host.PluginContext['costs']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['places']>>().toEqualTypeOf<Members<Host.PluginContext['places']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['days']>>().toEqualTypeOf<Members<Host.PluginContext['days']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['itinerary']>>().toEqualTypeOf<Members<Host.PluginContext['itinerary']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['meta']>>().toEqualTypeOf<Members<Host.PluginContext['meta']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['users']>>().toEqualTypeOf<Members<Host.PluginContext['users']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['ws']>>().toEqualTypeOf<Members<Host.PluginContext['ws']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['log']>>().toEqualTypeOf<Members<Host.PluginContext['log']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['plugins']>>().toEqualTypeOf<Members<Host.PluginContext['plugins']>>();
|
||||
expectTypeOf<Members<Sdk.PluginContext['events']>>().toEqualTypeOf<Members<Host.PluginContext['events']>>();
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The SDK half of the manifest parity corpus (test/fixtures/manifest-corpus.ts). It
|
||||
* runs standalone; the server's manifest-parity test runs the same corpus through
|
||||
* the host's loader as well, so the two cannot drift apart unseen.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { validateManifest } from '../src/manifest.js';
|
||||
import { PLUGIN_API_VERSION } from '../src/index.js';
|
||||
import { MANIFEST_CORPUS } from './fixtures/manifest-corpus.js';
|
||||
|
||||
describe('manifest corpus — validate', () => {
|
||||
it.each(MANIFEST_CORPUS)('$name', ({ manifest, host, sdk }) => {
|
||||
const result = validateManifest(manifest);
|
||||
expect(result.ok ? 'accept' : 'reject', result.errors.join('; ')).toBe(sdk ?? host);
|
||||
});
|
||||
|
||||
it('marks every stricter-than-host case as one, with its reason', () => {
|
||||
for (const c of MANIFEST_CORPUS) {
|
||||
if (c.sdk === undefined) continue;
|
||||
expect(c.sdk, `${c.name}: the SDK may only ever be stricter than the host`).toBe('reject');
|
||||
expect(c.host, c.name).toBe('accept');
|
||||
expect(c.why, `${c.name} needs a reason`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiVersion', () => {
|
||||
const base = { id: 'demo-plugin', name: 'Demo', version: '1.0.0', type: 'integration', trek: '>=4.0.0 <5.0.0' };
|
||||
|
||||
it.each([0, -1, 1.5, '1', null])('refuses %p, which is not a positive integer', (apiVersion) => {
|
||||
expect(validateManifest({ ...base, apiVersion }).errors).toContain('apiVersion must be a positive integer');
|
||||
});
|
||||
|
||||
it('refuses a version newer than the plugin API TREK implements', () => {
|
||||
const next = PLUGIN_API_VERSION + 1;
|
||||
expect(validateManifest({ ...base, apiVersion: next }).errors).toEqual([
|
||||
`apiVersion ${next} is newer than the plugin API TREK implements (v${PLUGIN_API_VERSION})`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps an accepted version and defaults a missing one to 1', () => {
|
||||
expect(validateManifest({ ...base, apiVersion: PLUGIN_API_VERSION }).manifest?.apiVersion).toBe(PLUGIN_API_VERSION);
|
||||
expect(validateManifest(base).manifest?.apiVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -99,13 +99,15 @@ describe.skipIf(!inMonorepo)('core event catalog', () => {
|
||||
});
|
||||
|
||||
describe.skipIf(!inMonorepo)('settings-field attribute parity', () => {
|
||||
it("SETTING_FIELD_KEYS matches the host's install/manifest.ts", () => {
|
||||
it("SETTING_FIELD_KEYS matches the host's protocol/manifest-rules.ts", () => {
|
||||
// The `manifest.settings-known-keys` check warns on an attribute the host would
|
||||
// silently drop, so the list must be the host's — compared whole, both ways round.
|
||||
const theirsFile = path.resolve(here, '../../server/src/nest/plugins/install/manifest.ts');
|
||||
// It is generated into host-facts.ts now (check:plugin-facts); this reads the source
|
||||
// as well so a standalone run of this suite catches a stale artefact.
|
||||
const theirsFile = path.resolve(here, '../../server/src/nest/plugins/protocol/manifest-rules.ts');
|
||||
const src = fs.readFileSync(theirsFile, 'utf8');
|
||||
const block = src.match(/export const SETTING_FIELD_KEYS = \[([\s\S]*?)\] as const/);
|
||||
expect(block, 'SETTING_FIELD_KEYS not found in install/manifest.ts').toBeTruthy();
|
||||
expect(block, 'SETTING_FIELD_KEYS not found in protocol/manifest-rules.ts').toBeTruthy();
|
||||
const theirs = [...block![1].matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
||||
expect([...SETTING_FIELD_KEYS]).toEqual(theirs);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "test/**/*.test-d.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
import fs from 'node:fs';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// The host's copy of the plugin API, which test/host-types.test-d.ts compares the SDK's
|
||||
// types against. It exists only inside the TREK monorepo; a standalone checkout of the
|
||||
// published package has no server, so the type parity check is off there.
|
||||
const hostTypes = new URL('../server/src/nest/plugins/runtime/plugin-sdk.ts', import.meta.url);
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
typecheck: {
|
||||
enabled: fs.existsSync(hostTypes),
|
||||
include: ['test/**/*.test-d.ts'],
|
||||
tsconfig: './tsconfig.typecheck.json',
|
||||
},
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['lcov', 'text'],
|
||||
|
||||
@@ -20,8 +20,13 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
HOOK_PERMISSION, KNOWN_METHODS, KNOWN_PERMISSIONS, METHOD_PERMISSION,
|
||||
EVENTS_PERMISSION, JOBS_PERMISSION, USER_DATA_PERMISSION, HTTP_OUTBOUND_PREFIX,
|
||||
EVENTS_PERMISSION, JOBS_PERMISSION, USER_DATA_PERMISSION, HTTP_OUTBOUND_PREFIX, PLUGIN_API_VERSION,
|
||||
} from '../src/nest/plugins/protocol/envelope';
|
||||
import {
|
||||
ACTIONS_MAX, ADDON_ID_RE, CAPABILITY_NAME_RE, EGRESS_HOST_RE, MCP_TOOLS_MAX, PLUGIN_CHANNEL_EVENTS, PLUGIN_ID_RE,
|
||||
PLUGIN_SEMVER_RE, PLUGIN_TYPES, REPLACEABLE_TABS, RESERVED_PLUGIN_IDS, RESERVED_SETTING_KEYS, ROUTE_PROFILE_ID_RE,
|
||||
ROUTE_PROFILES_MAX, SETTING_FIELD_KEYS, SETTING_KEY_RE, TOOL_NAME_RE, TRIP_PAGE_POSITION_MAX, WIDGET_SLOTS,
|
||||
} from '../src/nest/plugins/protocol/manifest-rules';
|
||||
import { SNAPSHOT_GRANT, ENTITY_ID_KEYS } from '../src/plugin-event-sink';
|
||||
import {
|
||||
PLUGIN_ENTITY_CONTRACT, PLUGIN_ENTITY_NESTED, PLUGIN_METHOD_OUTPUT, pluginEntityFields, type PluginEntityName,
|
||||
@@ -40,6 +45,7 @@ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const HEADER = [
|
||||
'// GENERATED — do not edit by hand.',
|
||||
'// Source: server/src/nest/plugins/protocol/envelope.ts + server/src/plugin-event-sink.ts',
|
||||
'// + server/src/nest/plugins/protocol/manifest-rules.ts',
|
||||
'// + server/src/nest/plugins/protocol/output-contract.ts + server/src/addons.ts',
|
||||
'// + shared/src/plugins/plugin-poi-facts.ts',
|
||||
'// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts',
|
||||
@@ -165,6 +171,55 @@ ${ENTITY_NESTED}
|
||||
export const PLUGIN_METHOD_RESULT: Readonly<Record<string, string>> = {
|
||||
${pairs(METHOD_RESULT)}
|
||||
};
|
||||
|
||||
/**
|
||||
* The plugin-API version this TREK implements. A manifest's \`apiVersion\` must be a
|
||||
* positive integer no greater than this, or the install refuses it.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = ${PLUGIN_API_VERSION} as const;
|
||||
|
||||
/**
|
||||
* The manifest format rules TREK's install loader enforces
|
||||
* (server/src/nest/plugins/protocol/manifest-rules.ts), so \`trek-plugin validate\`
|
||||
* refuses exactly what an install refuses.
|
||||
*/
|
||||
export const MANIFEST_ID_RE = ${PLUGIN_ID_RE};
|
||||
export const MANIFEST_RESERVED_IDS: readonly string[] = [
|
||||
${list(RESERVED_PLUGIN_IDS)}
|
||||
];
|
||||
export const MANIFEST_SEMVER_RE = ${PLUGIN_SEMVER_RE};
|
||||
export const MANIFEST_TYPES: readonly string[] = [
|
||||
${list(PLUGIN_TYPES)}
|
||||
];
|
||||
export const MANIFEST_ADDON_ID_RE = ${ADDON_ID_RE};
|
||||
export const MANIFEST_HOST_RE = ${EGRESS_HOST_RE};
|
||||
export const MANIFEST_WIDGET_SLOTS: readonly string[] = [
|
||||
${list(WIDGET_SLOTS)}
|
||||
];
|
||||
export const MANIFEST_REPLACEABLE_TABS: readonly string[] = [
|
||||
${list(REPLACEABLE_TABS)}
|
||||
];
|
||||
export const MANIFEST_TRIP_PAGE_POSITION_MAX = ${TRIP_PAGE_POSITION_MAX};
|
||||
export const MANIFEST_ROUTE_PROFILES_MAX = ${ROUTE_PROFILES_MAX};
|
||||
export const MANIFEST_ROUTE_PROFILE_ID_RE = ${ROUTE_PROFILE_ID_RE};
|
||||
export const MANIFEST_CAPABILITY_NAME_RE = ${CAPABILITY_NAME_RE};
|
||||
export const MANIFEST_MCP_TOOLS_MAX = ${MCP_TOOLS_MAX};
|
||||
export const MANIFEST_TOOL_NAME_RE = ${TOOL_NAME_RE};
|
||||
export const MANIFEST_SETTING_KEY_RE = ${SETTING_KEY_RE};
|
||||
export const MANIFEST_RESERVED_SETTING_KEYS: readonly string[] = [
|
||||
${list(RESERVED_SETTING_KEYS)}
|
||||
];
|
||||
export const MANIFEST_ACTIONS_MAX = ${ACTIONS_MAX};
|
||||
|
||||
/** Every attribute a settings-field object may carry; the host silently drops anything else. */
|
||||
export const SETTING_FIELD_KEYS = [
|
||||
${list(SETTING_FIELD_KEYS)}
|
||||
] as const;
|
||||
|
||||
/** Events a plugin notification channel may carry. Admin-scoped and in-app-only events are excluded. */
|
||||
export const CHANNEL_EVENTS: string[] = [
|
||||
${list(PLUGIN_CHANNEL_EVENTS)}
|
||||
];
|
||||
`;
|
||||
|
||||
const SHARED_FACTS = `${HEADER}
|
||||
|
||||
@@ -2,14 +2,32 @@ import semver from 'semver';
|
||||
import { isKnownPermission, PLUGIN_API_VERSION } from '../protocol/envelope';
|
||||
import { isValidTrekRange, minTrekOf, trekRangeBypassed } from './host-compat';
|
||||
import {
|
||||
MCP_TOOLS_MAX,
|
||||
McpToolSchemaError,
|
||||
TOOL_NAME_RE,
|
||||
buildToolInputSchema,
|
||||
normaliseToolSchema,
|
||||
sanitiseToolText,
|
||||
} from '../mcp-tool-schema';
|
||||
import type { NotifEventType } from '../../notifications/notification-events';
|
||||
import {
|
||||
ACTIONS_MAX,
|
||||
ADDON_ID_RE,
|
||||
CAPABILITY_NAME_RE,
|
||||
EGRESS_HOST_RE,
|
||||
MCP_TOOLS_MAX,
|
||||
PLUGIN_CHANNEL_EVENTS,
|
||||
PLUGIN_ID_RE,
|
||||
PLUGIN_SEMVER_RE,
|
||||
PLUGIN_TYPES,
|
||||
REPLACEABLE_TABS,
|
||||
RESERVED_PLUGIN_IDS,
|
||||
RESERVED_SETTING_KEYS as RESERVED_SETTING_KEY_LIST,
|
||||
ROUTE_PROFILE_ID_RE,
|
||||
ROUTE_PROFILES_MAX,
|
||||
SETTING_FIELD_KEYS,
|
||||
SETTING_KEY_RE,
|
||||
TOOL_NAME_RE,
|
||||
TRIP_PAGE_POSITION_MAX,
|
||||
WIDGET_SLOTS,
|
||||
} from '../protocol/manifest-rules';
|
||||
import { PLUGIN_POI_MAX_CATEGORIES, type PluginPoiCategory } from '@trek/shared';
|
||||
import { readPoiCategory } from '../poi-categories';
|
||||
|
||||
@@ -160,24 +178,20 @@ export interface PluginManifest {
|
||||
pluginDependencies: PluginDependency[];
|
||||
}
|
||||
|
||||
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
|
||||
// Addon ids are lowercase slugs that may contain underscores (e.g. `llm_parsing`).
|
||||
// Validated format-only: existence is checked at activation, so a plugin declaring
|
||||
// an unknown addon still installs but can never enable (matches "allow install").
|
||||
const ADDON_ID_RE = /^[a-z][a-z0-9_]{1,39}$/;
|
||||
// An outbound host: an exact hostname (single-label like a `redis` sibling
|
||||
// service, or a dotted FQDN) OR a `*.`-prefixed wildcard that MUST have a real
|
||||
// multi-label suffix. Rejects `*`, `*.`, whole-TLD `*.com`, schemes, and any
|
||||
// embedded space — all of which would otherwise widen egress or inject a CSP
|
||||
// source token when the host is interpolated into connect-src.
|
||||
const HOST_RE = /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i;
|
||||
// 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.-]+)?$/;
|
||||
// 'trip-page' mounts the plugin's sandboxed UI as a tab inside every trip planner
|
||||
// (tripId-scoped), with no dashboard presence — unlike 'page' (dashboard nav).
|
||||
const TYPES = new Set(['integration', 'page', 'widget', 'trip-page']);
|
||||
// The format rules themselves live in protocol/manifest-rules.ts, which
|
||||
// gen-plugin-facts also writes into the SDK, so `trek-plugin validate` checks the
|
||||
// same patterns and sets as this loader.
|
||||
const ID_RE = PLUGIN_ID_RE;
|
||||
const HOST_RE = EGRESS_HOST_RE;
|
||||
const SEMVER_RE = PLUGIN_SEMVER_RE;
|
||||
const RESERVED_IDS: ReadonlySet<string> = new Set(RESERVED_PLUGIN_IDS);
|
||||
const TYPES: ReadonlySet<string> = new Set(PLUGIN_TYPES);
|
||||
const SLOTS: ReadonlySet<string> = new Set(WIDGET_SLOTS);
|
||||
const TABS: readonly string[] = REPLACEABLE_TABS;
|
||||
const CHANNEL_EVENTS: readonly string[] = PLUGIN_CHANNEL_EVENTS;
|
||||
const RESERVED_SETTING_KEYS: ReadonlySet<string> = new Set(RESERVED_SETTING_KEY_LIST);
|
||||
|
||||
export { PLUGIN_CHANNEL_EVENTS, SETTING_FIELD_KEYS };
|
||||
|
||||
export class ManifestError extends Error {}
|
||||
|
||||
@@ -335,10 +349,6 @@ function parsePluginDependencies(raw: unknown, selfId: string): PluginDependency
|
||||
return out;
|
||||
}
|
||||
|
||||
// Export function / event names exposed to other plugins. Kept to a safe
|
||||
// identifier shape (dots allowed for event names like `rate.updated`).
|
||||
const CAPABILITY_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/;
|
||||
|
||||
function parseCapabilities(raw: unknown): PluginCapabilities {
|
||||
if (!raw || typeof raw !== 'object') return {};
|
||||
const c = raw as Record<string, unknown>;
|
||||
@@ -346,7 +356,7 @@ function parseCapabilities(raw: unknown): PluginCapabilities {
|
||||
if (c.widget && typeof c.widget === 'object') {
|
||||
const w = c.widget as Record<string, unknown>;
|
||||
const slot = optStr(w.slot);
|
||||
if (slot && slot !== 'sidebar' && slot !== 'hero' && slot !== 'place-detail' && slot !== 'day-detail' && slot !== 'reservation-detail') throw new ManifestError(`invalid widget slot "${slot}"`);
|
||||
if (slot && !SLOTS.has(slot)) throw new ManifestError(`invalid widget slot "${slot}"`);
|
||||
out.widget = {
|
||||
title: optStr(w.title),
|
||||
defaultSize: optStr(w.defaultSize),
|
||||
@@ -360,7 +370,7 @@ function parseCapabilities(raw: unknown): PluginCapabilities {
|
||||
if (!Array.isArray(tp.replaces)) throw new ManifestError('capabilities.tripPage.replaces must be an array');
|
||||
const replaces: string[] = [];
|
||||
for (const v of tp.replaces) {
|
||||
if (typeof v !== 'string' || !REPLACEABLE_TABS.includes(v)) {
|
||||
if (typeof v !== 'string' || !TABS.includes(v)) {
|
||||
throw new ManifestError(`capabilities.tripPage.replaces: "${String(v)}" is not a replaceable tab (${REPLACEABLE_TABS.join(', ')})`);
|
||||
}
|
||||
if (!replaces.includes(v)) replaces.push(v);
|
||||
@@ -368,8 +378,8 @@ function parseCapabilities(raw: unknown): PluginCapabilities {
|
||||
if (replaces.length) page.replaces = replaces;
|
||||
}
|
||||
if (tp.position !== undefined) {
|
||||
if (typeof tp.position !== 'number' || !Number.isInteger(tp.position) || tp.position < 0 || tp.position > 50) {
|
||||
throw new ManifestError('capabilities.tripPage.position must be an integer between 0 and 50');
|
||||
if (typeof tp.position !== 'number' || !Number.isInteger(tp.position) || tp.position < 0 || tp.position > TRIP_PAGE_POSITION_MAX) {
|
||||
throw new ManifestError(`capabilities.tripPage.position must be an integer between 0 and ${TRIP_PAGE_POSITION_MAX}`);
|
||||
}
|
||||
page.position = tp.position;
|
||||
}
|
||||
@@ -388,7 +398,7 @@ function parseCapabilities(raw: unknown): PluginCapabilities {
|
||||
if (!Array.isArray(nc.events)) throw new ManifestError('capabilities.notificationChannel.events must be an array');
|
||||
const events: string[] = [];
|
||||
for (const v of nc.events) {
|
||||
if (typeof v !== 'string' || !(PLUGIN_CHANNEL_EVENTS as readonly string[]).includes(v)) {
|
||||
if (typeof v !== 'string' || !CHANNEL_EVENTS.includes(v)) {
|
||||
throw new ManifestError(
|
||||
`capabilities.notificationChannel.events: "${String(v)}" is not a plugin-deliverable event (${PLUGIN_CHANNEL_EVENTS.join(', ')})`,
|
||||
);
|
||||
@@ -401,13 +411,15 @@ function parseCapabilities(raw: unknown): PluginCapabilities {
|
||||
}
|
||||
if (c.routeProfiles !== undefined) {
|
||||
if (!Array.isArray(c.routeProfiles)) throw new ManifestError('capabilities.routeProfiles must be an array');
|
||||
if (c.routeProfiles.length > 3) throw new ManifestError('capabilities.routeProfiles: at most 3 profiles');
|
||||
if (c.routeProfiles.length > ROUTE_PROFILES_MAX) {
|
||||
throw new ManifestError(`capabilities.routeProfiles: at most ${ROUTE_PROFILES_MAX} profiles`);
|
||||
}
|
||||
const profiles: RouteProfileCapability[] = [];
|
||||
for (const v of c.routeProfiles) {
|
||||
if (!v || typeof v !== 'object') throw new ManifestError('capabilities.routeProfiles entries must be objects');
|
||||
const p = v as Record<string, unknown>;
|
||||
const id = typeof p.id === 'string' ? p.id : '';
|
||||
if (!/^[a-z][a-z0-9-]{0,23}$/.test(id)) {
|
||||
if (!ROUTE_PROFILE_ID_RE.test(id)) {
|
||||
throw new ManifestError('capabilities.routeProfiles: id must be lowercase [a-z][a-z0-9-], max 24 chars');
|
||||
}
|
||||
if (profiles.some((x) => x.id === id)) throw new ManifestError(`capabilities.routeProfiles: duplicate id "${id}"`);
|
||||
@@ -432,40 +444,6 @@ function parseCapabilities(raw: unknown): PluginCapabilities {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events a plugin notification channel may carry. Two exclusions, both deliberate:
|
||||
* `version_available` is admin-scoped (it goes out over the admin's own global
|
||||
* credentials, and a community plugin is never a recipient of one), and
|
||||
* `synology_session_cleared` is in-app only.
|
||||
*
|
||||
* Spelled out rather than derived from the notification service, so the plugin
|
||||
* installer carries no runtime dependency on it. The guard below is what keeps the
|
||||
* two in step — same trick as `_eventKeyDriftGuard` in services/notifications.ts.
|
||||
*/
|
||||
export const PLUGIN_CHANNEL_EVENTS = [
|
||||
'trip_invite',
|
||||
'booking_change',
|
||||
'trip_reminder',
|
||||
'todo_due',
|
||||
'vacay_invite',
|
||||
'collection_invite',
|
||||
'photos_shared',
|
||||
'collab_message',
|
||||
'packing_tagged',
|
||||
'plugin_notification',
|
||||
] as const;
|
||||
|
||||
// Compile-time guard: every id above must still be a real notification event.
|
||||
// If one is renamed or dropped, this stops compiling.
|
||||
type _ChannelEventsAreReal = (typeof PLUGIN_CHANNEL_EVENTS)[number] extends NotifEventType ? true : never;
|
||||
const _channelEventDriftGuard: _ChannelEventsAreReal = true;
|
||||
void _channelEventDriftGuard;
|
||||
|
||||
// Core planner tabs a trip-page plugin may replace while it is active. 'plan' is
|
||||
// deliberately NOT in this list — a trip always keeps its planner view, so a
|
||||
// plugin can take over bookings/transports/…, never the whole trip.
|
||||
const REPLACEABLE_TABS = ['transports', 'buchungen', 'listen', 'finanzplan', 'dateien', 'collab'];
|
||||
|
||||
/**
|
||||
* Validate `capabilities.poiCategories`: the plugin's own chips in the explore pill.
|
||||
*
|
||||
@@ -553,28 +531,6 @@ function parseCapabilityNames(raw: unknown, field: string): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A settings field key. Constrained because the key is used as a JSON object key in
|
||||
* the plugin's stored config: an unconstrained one (`__proto__`, `constructor`) resolves
|
||||
* off Object.prototype on read, so a *required* field with such a name would look
|
||||
* "configured" for every user who had configured nothing — enough, for a notification
|
||||
* channel, to be dispatched to everyone without anyone entering credentials.
|
||||
* (safeParse in plugins.service.ts now uses a null-prototype object too; this is the
|
||||
* front door, that is the backstop.)
|
||||
*/
|
||||
const SETTING_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/;
|
||||
const RESERVED_SETTING_KEYS = new Set(['constructor', 'prototype', '__proto__']);
|
||||
|
||||
/**
|
||||
* Every attribute a settings-field object may carry — parseSettings reads exactly these and
|
||||
* silently drops anything else. The SDK mirrors this list (`SETTING_FIELD_KEYS` in
|
||||
* plugin-sdk/src/manifest.ts, parity-tested against this file) so `trek-plugin validate`
|
||||
* can warn on a typo'd or unsupported attribute instead of letting it vanish at install.
|
||||
*/
|
||||
export const SETTING_FIELD_KEYS = [
|
||||
'key', 'label', 'input_type', 'placeholder', 'hint', 'required', 'secret', 'scope', 'options', 'oauth', 'default',
|
||||
] as const;
|
||||
|
||||
function parseSettings(raw: unknown): ManifestSettingField[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
@@ -647,7 +603,7 @@ function parseSettingOptions(raw: unknown): Array<{ value: string; label: string
|
||||
function parseActions(raw: unknown): ManifestAction[] {
|
||||
if (raw === undefined) return [];
|
||||
if (!Array.isArray(raw)) throw new ManifestError('actions must be an array');
|
||||
if (raw.length > 8) throw new ManifestError('at most 8 actions');
|
||||
if (raw.length > ACTIONS_MAX) throw new ManifestError(`at most ${ACTIONS_MAX} actions`);
|
||||
const out: ManifestAction[] = [];
|
||||
for (const a of raw) {
|
||||
if (!a || typeof a !== 'object') throw new ManifestError('each action must be an object');
|
||||
|
||||
@@ -18,8 +18,11 @@ import { z } from 'zod';
|
||||
|
||||
import type { McpZodSchema } from '../../nest-mcp';
|
||||
|
||||
/** Tools one plugin may advertise. A long list crowds out the built-ins. */
|
||||
export const MCP_TOOLS_MAX = 8;
|
||||
import { MCP_TOOLS_MAX, TOOL_NAME_RE } from './protocol/manifest-rules';
|
||||
|
||||
// The per-plugin cap and the tool-name pattern are manifest rules: they live in
|
||||
// protocol/manifest-rules.ts, which gen-plugin-facts writes into the SDK.
|
||||
export { MCP_TOOLS_MAX, TOOL_NAME_RE };
|
||||
|
||||
/**
|
||||
* Tools all plugins together may advertise.
|
||||
@@ -30,9 +33,6 @@ export const MCP_TOOLS_MAX = 8;
|
||||
*/
|
||||
export const MCP_TOOLS_TOTAL_MAX = 32;
|
||||
|
||||
/** Plugin-local tool name. No dot or dash, so the advertised name parses apart. */
|
||||
export const TOOL_NAME_RE = /^[a-z0-9_]{1,48}$/;
|
||||
|
||||
export const TOOL_TITLE_MAX = 80;
|
||||
export const TOOL_DESCRIPTION_MAX = 1024;
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* The format rules of a plugin manifest (trek-plugin.json): the id, version and host
|
||||
* patterns, the allowed sets and the caps. `install/manifest.ts` enforces them, and
|
||||
* `scripts/gen-plugin-facts.ts` writes them into the SDK's `generated/host-facts.ts`,
|
||||
* so `trek-plugin validate` reads the very values an install does instead of a copy
|
||||
* that can drift (`check:plugin-facts` fails the build when the two differ).
|
||||
*
|
||||
* Pure data, no imports with runtime effect: the generator imports this file in a CI
|
||||
* job that installs only the server.
|
||||
*/
|
||||
import type { NotifEventType } from '../../notifications/notification-events';
|
||||
|
||||
/** A plugin id: lowercase slug, 3 to 40 characters. */
|
||||
export const PLUGIN_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).
|
||||
*/
|
||||
export const RESERVED_PLUGIN_IDS = ['registry', 'install', 'rescan'] as const;
|
||||
|
||||
/** A plugin version: plain semver, an optional prerelease, no build metadata. */
|
||||
export const PLUGIN_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
/**
|
||||
* 'trip-page' mounts the plugin's sandboxed UI as a tab inside every trip planner
|
||||
* (tripId-scoped), with no dashboard presence, unlike 'page' (dashboard nav).
|
||||
*/
|
||||
export const PLUGIN_TYPES = ['integration', 'page', 'widget', 'trip-page'] as const;
|
||||
|
||||
/**
|
||||
* Addon ids are lowercase slugs that may contain underscores (e.g. `llm_parsing`).
|
||||
* Validated format-only: existence is checked at activation.
|
||||
*/
|
||||
export const ADDON_ID_RE = /^[a-z][a-z0-9_]{1,39}$/;
|
||||
|
||||
/**
|
||||
* An outbound host: an exact hostname (single-label like a `redis` sibling service,
|
||||
* or a dotted FQDN) OR a `*.`-prefixed wildcard that MUST have a real multi-label
|
||||
* suffix. Rejects `*`, `*.`, whole-TLD `*.com`, schemes, and any embedded space, all
|
||||
* of which would otherwise widen egress or inject a CSP source token when the host is
|
||||
* interpolated into connect-src.
|
||||
*/
|
||||
export const EGRESS_HOST_RE = /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i;
|
||||
|
||||
/** Where a widget mounts: dashboard sidebar (default), hero bar, or a planner detail panel. */
|
||||
export const WIDGET_SLOTS = ['sidebar', 'hero', 'place-detail', 'day-detail', 'reservation-detail'] as const;
|
||||
|
||||
/**
|
||||
* Core planner tabs a trip-page plugin may replace while it is active. 'plan' is
|
||||
* deliberately NOT in this list: a trip always keeps its planner view.
|
||||
*/
|
||||
export const REPLACEABLE_TABS = ['transports', 'buchungen', 'listen', 'finanzplan', 'dateien', 'collab'] as const;
|
||||
|
||||
/** The highest 0-based tab index a trip-page plugin may ask for. */
|
||||
export const TRIP_PAGE_POSITION_MAX = 50;
|
||||
|
||||
/** Route profiles one plugin may declare, and the shape of their ids. */
|
||||
export const ROUTE_PROFILES_MAX = 3;
|
||||
export const ROUTE_PROFILE_ID_RE = /^[a-z][a-z0-9-]{0,23}$/;
|
||||
|
||||
/** Export function / event names exposed to other plugins (dots allowed, `rate.updated`). */
|
||||
export const CAPABILITY_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/;
|
||||
|
||||
/** Tools one plugin may advertise. A long list crowds out the built-ins. */
|
||||
export const MCP_TOOLS_MAX = 8;
|
||||
|
||||
/** Plugin-local tool name. No dot or dash, so the advertised name parses apart. */
|
||||
export const TOOL_NAME_RE = /^[a-z0-9_]{1,48}$/;
|
||||
|
||||
/**
|
||||
* A settings field key. Constrained because the key is used as a JSON object key in
|
||||
* the plugin's stored config: an unconstrained one (`__proto__`, `constructor`)
|
||||
* resolves off Object.prototype on read, so a required field with such a name would
|
||||
* look "configured" for every user who had configured nothing.
|
||||
*/
|
||||
export const SETTING_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/;
|
||||
export const RESERVED_SETTING_KEYS = ['constructor', 'prototype', '__proto__'] as const;
|
||||
|
||||
/**
|
||||
* Every attribute a settings-field object may carry: parseSettings reads exactly
|
||||
* these and silently drops anything else, so `trek-plugin validate` warns on any other.
|
||||
*/
|
||||
export const SETTING_FIELD_KEYS = [
|
||||
'key',
|
||||
'label',
|
||||
'input_type',
|
||||
'placeholder',
|
||||
'hint',
|
||||
'required',
|
||||
'secret',
|
||||
'scope',
|
||||
'options',
|
||||
'oauth',
|
||||
'default',
|
||||
] as const;
|
||||
|
||||
/** Settings-page action buttons one plugin may declare. */
|
||||
export const ACTIONS_MAX = 8;
|
||||
|
||||
/**
|
||||
* Events a plugin notification channel may carry. Two exclusions, both deliberate:
|
||||
* `version_available` is admin-scoped (it goes out over the admin's own global
|
||||
* credentials, and a community plugin is never a recipient of one), and
|
||||
* `synology_session_cleared` is in-app only.
|
||||
*
|
||||
* Spelled out rather than derived from the notification service, so the plugin
|
||||
* installer carries no runtime dependency on it; the type guard below keeps the two
|
||||
* in step.
|
||||
*/
|
||||
export const PLUGIN_CHANNEL_EVENTS = [
|
||||
'trip_invite',
|
||||
'booking_change',
|
||||
'trip_reminder',
|
||||
'todo_due',
|
||||
'vacay_invite',
|
||||
'collection_invite',
|
||||
'photos_shared',
|
||||
'collab_message',
|
||||
'packing_tagged',
|
||||
'plugin_notification',
|
||||
] as const;
|
||||
|
||||
// Compile-time guard: every id above must still be a real notification event.
|
||||
// If one is renamed or dropped, this stops compiling.
|
||||
type ChannelEventsAreReal = (typeof PLUGIN_CHANNEL_EVENTS)[number] extends NotifEventType ? true : never;
|
||||
const channelEventDriftGuard: ChannelEventsAreReal = true;
|
||||
void channelEventDriftGuard;
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* The plugin-author-facing SDK surface (#plugins, M1) — the minimal in-repo
|
||||
* version. The published `@trek/plugin-sdk` (M6) will re-export these types; for
|
||||
* now the runtime ships its own copy so the child has zero external deps.
|
||||
*
|
||||
* The plugin API as the isolated child implements it: its own copy of the published
|
||||
* trek-plugin-sdk types, so the child has zero external deps (plugin-sdk's
|
||||
* test/host-types.test-d.ts holds the two to the same names and members).
|
||||
* PURE — no server imports. This runs inside the isolated child. Every ctx
|
||||
* method is plumbing that turns a call into an RPC message to the host; the
|
||||
* child holds no db handle, no secrets, no network by default.
|
||||
@@ -338,6 +337,9 @@ export interface PluginRequest {
|
||||
* providers; never Cookie/Authorization/session). Empty on authenticated routes.
|
||||
* Verify a provider signature against a secret you hold in `ctx.config`/`ctx.settings`. */
|
||||
headers: Record<string, string>;
|
||||
/** The RAW body, base64 — only on `auth:false` routes (webhooks), else null. Run an HMAC
|
||||
* over this, never over the parsed `body` (re-serializing does not reproduce the bytes). */
|
||||
rawBodyBase64?: string | null;
|
||||
user: { id: number; username: string; isAdmin: boolean } | null;
|
||||
}
|
||||
export interface PluginResponse {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* One verdict per manifest, whoever judges it: the host's install loader
|
||||
* (`parseManifest` with `requireTrek`, what an install answers) and the SDK's
|
||||
* `validateManifest` (what `trek-plugin validate` answers) run over the shared corpus
|
||||
* in plugin-sdk/test/fixtures/manifest-corpus.ts and must each give the verdict
|
||||
* recorded there. The SDK may be stricter than the host only where a case says so;
|
||||
* accepting what the host refuses is the false green this test exists to stop.
|
||||
*
|
||||
* The SDK is a standalone package outside this workspace's rootDir, so both modules
|
||||
* are loaded by path at run time rather than through a typed import.
|
||||
*/
|
||||
import { ManifestError, parseManifest } from '../../../src/nest/plugins/install/manifest';
|
||||
|
||||
import path from 'node:path';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
type Verdict = 'accept' | 'reject';
|
||||
interface ManifestCase {
|
||||
name: string;
|
||||
manifest: unknown;
|
||||
host: Verdict;
|
||||
sdk?: Verdict;
|
||||
why?: string;
|
||||
}
|
||||
interface SdkValidation {
|
||||
ok: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const sdkRoot = path.resolve(__dirname, '../../../../plugin-sdk');
|
||||
let corpus: ManifestCase[] = [];
|
||||
let validateManifest: (raw: unknown) => SdkValidation;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ MANIFEST_CORPUS: corpus } = (await import(path.join(sdkRoot, 'test/fixtures/manifest-corpus.ts'))) as {
|
||||
MANIFEST_CORPUS: ManifestCase[];
|
||||
});
|
||||
({ validateManifest } = (await import(path.join(sdkRoot, 'src/manifest.ts'))) as {
|
||||
validateManifest: (raw: unknown) => SdkValidation;
|
||||
});
|
||||
});
|
||||
|
||||
function hostVerdict(manifest: unknown): { verdict: Verdict; reason: string } {
|
||||
try {
|
||||
parseManifest(manifest, { requireTrek: true });
|
||||
return { verdict: 'accept', reason: '' };
|
||||
} catch (e) {
|
||||
// Anything but a ManifestError is a crash in the loader, not a verdict.
|
||||
if (!(e instanceof ManifestError)) throw e;
|
||||
return { verdict: 'reject', reason: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function sdkVerdict(manifest: unknown): { verdict: Verdict; reason: string } {
|
||||
const result = validateManifest(manifest);
|
||||
return { verdict: result.ok ? 'accept' : 'reject', reason: result.errors.join('; ') };
|
||||
}
|
||||
|
||||
// Each test lists every case that disagrees, so one run names all of them.
|
||||
describe('manifest parity: host loader and SDK validate', () => {
|
||||
it('has a corpus to judge', () => {
|
||||
expect(corpus.length).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it('the host gives every case its recorded verdict', () => {
|
||||
const wrong = corpus
|
||||
.map((c) => ({ c, got: hostVerdict(c.manifest) }))
|
||||
.filter(({ c, got }) => got.verdict !== c.host)
|
||||
.map(({ c, got }) => `${c.name}: expected ${c.host}, got ${got.verdict} ${got.reason}`);
|
||||
expect(wrong).toEqual([]);
|
||||
});
|
||||
|
||||
it('the SDK gives every case its recorded verdict', () => {
|
||||
const wrong = corpus
|
||||
.map((c) => ({ c, got: sdkVerdict(c.manifest) }))
|
||||
.filter(({ c, got }) => got.verdict !== (c.sdk ?? c.host))
|
||||
.map(({ c, got }) => `${c.name}: expected ${c.sdk ?? c.host}, got ${got.verdict} ${got.reason}`);
|
||||
expect(wrong).toEqual([]);
|
||||
});
|
||||
|
||||
it('never has the SDK accept what the host refuses', () => {
|
||||
const falseGreens = corpus
|
||||
.filter((c) => sdkVerdict(c.manifest).verdict === 'accept' && hostVerdict(c.manifest).verdict === 'reject')
|
||||
.map((c) => c.name);
|
||||
expect(falseGreens).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// GENERATED — do not edit by hand.
|
||||
// Source: server/src/nest/plugins/protocol/envelope.ts + server/src/plugin-event-sink.ts
|
||||
// + server/src/nest/plugins/protocol/manifest-rules.ts
|
||||
// + server/src/nest/plugins/protocol/output-contract.ts + server/src/addons.ts
|
||||
// + shared/src/plugins/plugin-poi-facts.ts
|
||||
// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts
|
||||
|
||||
Reference in New Issue
Block a user