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:
Maurice
2026-10-10 15:20:42 +02:00
parent 15db688586
commit 81ec7dd28d
18 changed files with 949 additions and 146 deletions
+2 -1
View File
@@ -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 |
+85
View File
@@ -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',
];
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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',
},
];
+32
View File
@@ -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([]);
});
});
+116
View File
@@ -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']>>();
});
+45
View File
@@ -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);
});
});
+5 -3
View File
@@ -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);
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "..",
"noEmit": true
},
"include": ["src", "test/**/*.test-d.ts"],
"exclude": ["dist", "node_modules"]
}
+11
View File
@@ -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'],
+56 -1
View File
@@ -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}
+45 -89
View File
@@ -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');
+5 -5
View File
@@ -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
View File
@@ -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