mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
Fix/plugin audit findings (#2031)
* fix(plugins): enforce manifest apiVersion at install and activation * test(plugins): exercise the absent-apiVersion default * feat(plugins): generate the core-event catalog into host-facts * docs(plugins): explain the packing.listBags write-permission binding * test(plugins): cover apiVersion upload refusal; tidy docblocks and generator header * fix(plugin-sdk): validate settings options and oauth like the host * fix(plugin-sdk): model the full manifest shape and stop dropping fields * fix(plugin-sdk): drop non-string routeProfiles icons like the host * docs(plugin-sdk): document host limits, oauth contract, and ignored asUserId * feat(plugin-sdk): enforce daily budgets and meta quotas in the mock host * feat(plugin-sdk): expose the generated core-event catalog * feat(plugin-sdk): scaffold trek range >=3.4.0 <5.0.0 ahead of TREK 4.0 * feat(plugin-sdk): scaffold the oauth broker settings for oauth:client plugins * chore(plugin-sdk): fix stale mirror docs and the colliding permissions export * docs(plugin-sdk): add the rate-limit string; restore trek docblock; match host meta sizing * feat(plugin-sdk): raise the scaffold trek floor to 4.0.0 * docs(wiki): bring the plugin pages in line with the current runtime * docs: bring the workspace CLAUDE.md guides up to date and make them committable * feat(plugin-sdk): roll back failed publishes, add unrelease, sync the fork, retro-sign updates * docs(wiki): document the publish rollback, unrelease, fork sync, and retro-signing * chore(plugin-sdk): 1.6.0
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import semver from 'semver';
|
||||
import { isKnownPermission } from '../protocol/envelope';
|
||||
import { isKnownPermission, PLUGIN_API_VERSION } from '../protocol/envelope';
|
||||
import { isValidTrekRange, minTrekOf } from './host-compat';
|
||||
import type { NotifEventType } from '../../notifications/notification-events';
|
||||
|
||||
@@ -220,11 +220,21 @@ export function parseManifest(raw: unknown, opts?: { requireTrek?: boolean }): P
|
||||
: 'missing "trek" version range — declare the TREK versions this plugin supports, e.g. ">=3.2.0 <4.0.0"',
|
||||
);
|
||||
}
|
||||
|
||||
const rawApi = m.apiVersion;
|
||||
if (rawApi !== undefined && (typeof rawApi !== 'number' || !Number.isInteger(rawApi) || rawApi < 1)) {
|
||||
throw new ManifestError('apiVersion must be a positive integer');
|
||||
}
|
||||
const apiVersion = (rawApi as number | undefined) ?? 1;
|
||||
if (opts?.requireTrek && apiVersion > PLUGIN_API_VERSION) {
|
||||
throw new ManifestError(`plugin requires plugin-API v${apiVersion}; this TREK supports v${PLUGIN_API_VERSION}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name: str(m.name, 'name'),
|
||||
version,
|
||||
apiVersion: typeof m.apiVersion === 'number' ? m.apiVersion : 1,
|
||||
apiVersion,
|
||||
author: optStr(m.author),
|
||||
description: optStr(m.description),
|
||||
homepage: optStr(m.homepage),
|
||||
|
||||
@@ -33,7 +33,7 @@ import type { PluginDependency } from './install/manifest';
|
||||
import type { VersionMismatch, PluginDepRow } from './dependencies';
|
||||
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, enableOrder, findDependentsTransitive, DependencyCycleError } from './dependencies';
|
||||
|
||||
import { HTTP_OUTBOUND_PREFIX as HTTP_OUTBOUND } from './protocol/envelope';
|
||||
import { HTTP_OUTBOUND_PREFIX as HTTP_OUTBOUND, PLUGIN_API_VERSION } from './protocol/envelope';
|
||||
|
||||
// Mirrors HOST_RE in install/manifest.ts: an exact hostname or a `*.`-prefixed wildcard
|
||||
// with a real multi-label suffix. Rejects a bare `*`, a whole-TLD wildcard, a scheme and
|
||||
@@ -85,7 +85,9 @@ export type PluginDependencyCode =
|
||||
/** The plugin's declared TREK range doesn't admit the running host. */
|
||||
| 'TREK_VERSION_INCOMPATIBLE'
|
||||
/** The plugin never declared a range, so we can't know that it does. */
|
||||
| 'TREK_VERSION_UNKNOWN';
|
||||
| 'TREK_VERSION_UNKNOWN'
|
||||
/** The plugin's manifest apiVersion is newer than this TREK's plugin-API surface. */
|
||||
| 'API_VERSION_INCOMPATIBLE';
|
||||
|
||||
/**
|
||||
* Thrown when a plugin can't activate because a required addon is disabled, a declared
|
||||
@@ -478,11 +480,12 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Read-only activation gate for one plugin — throws (without mutating) if it may
|
||||
* not activate. Checks run most- to least-severe: TREK-version compatibility →
|
||||
* permission re-consent → required addon disabled → missing/mismatched plugin dependency.
|
||||
* plugin-API version compatibility → permission re-consent → required addon disabled →
|
||||
* missing/mismatched plugin dependency.
|
||||
*/
|
||||
private assertActivatable(id: string, installed: Map<string, PluginDepRow>, consentWiden: boolean): void {
|
||||
const row = this.db.prepare('SELECT permissions, granted_permissions, dependencies, trek_range FROM plugins WHERE id = ?').get(id) as
|
||||
| { permissions: string; granted_permissions: string; dependencies: string | null; trek_range: string | null }
|
||||
const row = this.db.prepare('SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?').get(id) as
|
||||
| { permissions: string; granted_permissions: string; dependencies: string | null; trek_range: string | null; api_version: number | null }
|
||||
| undefined;
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
|
||||
@@ -505,6 +508,16 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
{ trekRange: row.trek_range, hostVersion: hostVersion() },
|
||||
);
|
||||
}
|
||||
// Same reasoning as the TREK-version gate above: a plugin whose manifest apiVersion
|
||||
// outpaces this TREK's plugin-API surface can never run correctly, so it must be
|
||||
// refused before any consent dialog is offered.
|
||||
const apiVersion = row.api_version ?? 1;
|
||||
if (apiVersion > PLUGIN_API_VERSION) {
|
||||
throw new PluginDependencyError(
|
||||
`plugin requires plugin-API v${apiVersion}; this TREK supports v${PLUGIN_API_VERSION}`,
|
||||
'API_VERSION_INCOMPATIBLE',
|
||||
);
|
||||
}
|
||||
|
||||
const declared = parseArray(row.permissions).filter(isKnownPermission);
|
||||
const granted = parseArray(row.granted_permissions);
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
* or the websocket server. Keep it dependency-free.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The plugin-API surface version (#plugins, M4) — bumped on any breaking change to the
|
||||
* ctx methods / manifest shape a plugin author can rely on. Mirrored into every
|
||||
* manifest as `apiVersion`; the install and activation gates refuse a plugin declaring
|
||||
* a version newer than this one.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = 1;
|
||||
|
||||
export type PluginErrCode =
|
||||
| 'PERMISSION_DENIED' // a real method the plugin was not granted
|
||||
| 'UNKNOWN_METHOD' // not a method the host exposes at all
|
||||
@@ -202,6 +210,10 @@ export const METHOD_PERMISSION = {
|
||||
'packing.create': 'db:write:packing',
|
||||
'packing.update': 'db:write:packing',
|
||||
'packing.delete': 'db:write:packing',
|
||||
// Intentional: bags are the write-side organizational structure of packing —
|
||||
// a read-only consumer uses packing.list; only bag-managing (write) plugins
|
||||
// need the bag tree. Moving this to db:read:packing would strip access from
|
||||
// every installed write-only plugin and force consent re-prompts.
|
||||
'packing.listBags': 'db:write:packing',
|
||||
'packing.createBag': 'db:write:packing',
|
||||
'packing.updateBag': 'db:write:packing',
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
/** Mirrors the published package's constant — bumped on any breaking API change. */
|
||||
export const PLUGIN_API_VERSION = 1 as const;
|
||||
export { PLUGIN_API_VERSION } from '../protocol/envelope';
|
||||
|
||||
export interface PluginContext {
|
||||
readonly id: string;
|
||||
@@ -93,7 +93,12 @@ export interface PluginContext {
|
||||
update(tripId: number, itemId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
/** Delete a packing item. Needs 'db:write:packing' + 'packing_edit'. */
|
||||
delete(tripId: number, itemId: number): Promise<{ deleted: boolean }>;
|
||||
/** List/create/update/delete packing bags + set members (no privacy). Needs 'db:write:packing' + 'packing_edit'. */
|
||||
/**
|
||||
* List/create/update/delete packing bags + set members (no privacy). Bag methods need
|
||||
* 'db:write:packing' + the acting user's 'packing_edit' trip permission — except this
|
||||
* one: listBags is a read, but the envelope still gates it on the write scope
|
||||
* (intentional — bags are the write-side structure; packing.list is the read surface).
|
||||
*/
|
||||
listBags(tripId: number): Promise<unknown[]>;
|
||||
createBag(tripId: number, input: { name: string; color?: string }): Promise<unknown>;
|
||||
updateBag(tripId: number, bagId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
|
||||
@@ -37,7 +37,7 @@ export function emitPluginEvent(tripId: number, event: string, meta?: PluginEven
|
||||
// from leaking: e.g. `budget:member-paid-updated` carries { itemId, userId } and we
|
||||
// deliberately only read itemId, never userId. Families not listed (settlement/bag
|
||||
// sub-entities, reorders, bulk position updates) yield no entityId, which is correct.
|
||||
const ENTITY_ID_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
export const ENTITY_ID_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
place: ['placeId', 'place.id'],
|
||||
day: ['dayId', 'day.id'],
|
||||
reservation: ['reservationId', 'reservation.id'],
|
||||
|
||||
Reference in New Issue
Block a user