diff --git a/plugin-sdk/CLAUDE.md b/plugin-sdk/CLAUDE.md index a5bf48b14..6b23fae48 100644 --- a/plugin-sdk/CLAUDE.md +++ b/plugin-sdk/CLAUDE.md @@ -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 | diff --git a/plugin-sdk/src/generated/host-facts.ts b/plugin-sdk/src/generated/host-facts.ts index d54cb8a76..05b028044 100644 --- a/plugin-sdk/src/generated/host-facts.ts +++ b/plugin-sdk/src/generated/host-facts.ts @@ -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> = { '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', +]; diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index 95895b6d2..876c56749 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -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. diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts index 57e687179..cadae8bb8 100644 --- a/plugin-sdk/src/manifest.ts +++ b/plugin-sdk/src/manifest.ts @@ -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 = 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 = 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(); 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(); 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; 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(); for (const a of m.actions as Array>) { 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; diff --git a/plugin-sdk/test/fixtures/manifest-corpus.ts b/plugin-sdk/test/fixtures/manifest-corpus.ts new file mode 100644 index 000000000..a75a22cda --- /dev/null +++ b/plugin-sdk/test/fixtures/manifest-corpus.ts @@ -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; + /** 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, 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', + }, +]; diff --git a/plugin-sdk/test/host-types-names.test.ts b/plugin-sdk/test/host-types-names.test.ts new file mode 100644 index 000000000..796a94b88 --- /dev/null +++ b/plugin-sdk/test/host-types-names.test.ts @@ -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 => + 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([]); + }); +}); diff --git a/plugin-sdk/test/host-types.test-d.ts b/plugin-sdk/test/host-types.test-d.ts new file mode 100644 index 000000000..89bf4cdb6 --- /dev/null +++ b/plugin-sdk/test/host-types.test-d.ts @@ -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().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); +}); + +test('the shapes that carry a ctx have the same members on both sides', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); +}); + +test('every ctx namespace offers the same methods on both sides', () => { + type Members = T extends (...args: never[]) => unknown ? never : keyof T; + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); +}); diff --git a/plugin-sdk/test/manifest-corpus.test.ts b/plugin-sdk/test/manifest-corpus.test.ts new file mode 100644 index 000000000..210795ee1 --- /dev/null +++ b/plugin-sdk/test/manifest-corpus.test.ts @@ -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); + }); +}); diff --git a/plugin-sdk/test/permissions-parity.test.ts b/plugin-sdk/test/permissions-parity.test.ts index c31baab83..619cdbca8 100644 --- a/plugin-sdk/test/permissions-parity.test.ts +++ b/plugin-sdk/test/permissions-parity.test.ts @@ -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); }); diff --git a/plugin-sdk/tsconfig.typecheck.json b/plugin-sdk/tsconfig.typecheck.json new file mode 100644 index 000000000..d6063e482 --- /dev/null +++ b/plugin-sdk/tsconfig.typecheck.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true + }, + "include": ["src", "test/**/*.test-d.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/plugin-sdk/vitest.config.ts b/plugin-sdk/vitest.config.ts index 9b4cb8759..a587c969f 100644 --- a/plugin-sdk/vitest.config.ts +++ b/plugin-sdk/vitest.config.ts @@ -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'], diff --git a/server/scripts/gen-plugin-facts.ts b/server/scripts/gen-plugin-facts.ts index 4cef98133..6be512c26 100644 --- a/server/scripts/gen-plugin-facts.ts +++ b/server/scripts/gen-plugin-facts.ts @@ -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> = { ${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} diff --git a/server/src/nest/plugins/install/manifest.ts b/server/src/nest/plugins/install/manifest.ts index 120e324c2..70f1187a4 100644 --- a/server/src/nest/plugins/install/manifest.ts +++ b/server/src/nest/plugins/install/manifest.ts @@ -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 = new Set(RESERVED_PLUGIN_IDS); +const TYPES: ReadonlySet = new Set(PLUGIN_TYPES); +const SLOTS: ReadonlySet = new Set(WIDGET_SLOTS); +const TABS: readonly string[] = REPLACEABLE_TABS; +const CHANNEL_EVENTS: readonly string[] = PLUGIN_CHANNEL_EVENTS; +const RESERVED_SETTING_KEYS: ReadonlySet = 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; @@ -346,7 +356,7 @@ function parseCapabilities(raw: unknown): PluginCapabilities { if (c.widget && typeof c.widget === 'object') { const w = c.widget as Record; 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; 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'); diff --git a/server/src/nest/plugins/mcp-tool-schema.ts b/server/src/nest/plugins/mcp-tool-schema.ts index 0767bbe86..4174a8d0a 100644 --- a/server/src/nest/plugins/mcp-tool-schema.ts +++ b/server/src/nest/plugins/mcp-tool-schema.ts @@ -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; diff --git a/server/src/nest/plugins/protocol/manifest-rules.ts b/server/src/nest/plugins/protocol/manifest-rules.ts new file mode 100644 index 000000000..7d65fad61 --- /dev/null +++ b/server/src/nest/plugins/protocol/manifest-rules.ts @@ -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; diff --git a/server/src/nest/plugins/runtime/plugin-sdk.ts b/server/src/nest/plugins/runtime/plugin-sdk.ts index 2856fe1af..87adf6705 100644 --- a/server/src/nest/plugins/runtime/plugin-sdk.ts +++ b/server/src/nest/plugins/runtime/plugin-sdk.ts @@ -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; + /** 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 { diff --git a/server/tests/unit/plugins/manifest-parity.test.ts b/server/tests/unit/plugins/manifest-parity.test.ts new file mode 100644 index 000000000..0f96761ff --- /dev/null +++ b/server/tests/unit/plugins/manifest-parity.test.ts @@ -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([]); + }); +}); diff --git a/shared/src/plugin-permissions.ts b/shared/src/plugin-permissions.ts index a7cec9e9f..ab5c60a33 100644 --- a/shared/src/plugin-permissions.ts +++ b/shared/src/plugin-permissions.ts @@ -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