chore: lock the plugin boundary and remove 2.9.0 leftovers

This commit is contained in:
LukeGus committed 2026-09-25 07:24:46 -05:00
1 parent 6489b62ece
commit 2961297989
399 files changed
+3964 -5627

No files matched your search

+1 -11
View File
@@ -47,11 +47,7 @@ export function getActor(): string | undefined {
return storage.getStore()?.userId;
}
export function getActorSource(): ActorStore["source"] | undefined {
return storage.getStore()?.source;
}
export class PluginActorError extends Error {
class PluginActorError extends Error {
readonly code = "EPLUGINACTOR";
constructor(pluginId: string, operation: string) {
@@ -62,9 +58,3 @@ export class PluginActorError extends Error {
this.name = "PluginActorError";
}
}
export function requireActor(pluginId: string, operation: string): string {
const userId = getActor();
if (!userId) throw new PluginActorError(pluginId, operation);
return userId;
}
-72
View File
@@ -1,72 +0,0 @@
/**
* Boot copies of 2.8 data into plugin settings and plugin tables.
*
* They run after the plugins are seeded and activated: plugin_settings has a
* foreign key to plugins, and some copies write into tables a plugin adopts
* in its own migrations. Each one is idempotent, so they also run again when
* an admin enables a plugin without a restart.
*/
import { databaseLogger } from "../utils/logger.js";
import { getErrorMessage } from "../utils/error-message.js";
import { runTailscaleSettingsMigration } from "../utils/crypto-migration/tailscale-settings-migration.js";
import { runProxmoxSettingsMigration } from "../utils/crypto-migration/proxmox-settings-migration.js";
import { runFileManagerSettingsMigration } from "../utils/crypto-migration/file-manager-settings-migration.js";
import { runTunnelsSettingsMigration } from "../utils/crypto-migration/tunnels-settings-migration.js";
import { runWebEndpointSettingsMigration } from "../utils/crypto-migration/web-endpoint-settings-migration.js";
import { runSshTerminalSettingsMigration } from "../utils/crypto-migration/ssh-terminal-settings-migration.js";
import { runTmuxMonitorSettingsMigration } from "../utils/crypto-migration/tmux-monitor-settings-migration.js";
import { runSessionSharingSettingsMigration } from "../utils/crypto-migration/session-sharing-settings-migration.js";
import { runSessionRecordingSettingsMigration } from "../utils/crypto-migration/session-recording-settings-migration.js";
import { runRemoteDesktopSettingsMigration } from "../utils/crypto-migration/remote-desktop-settings-migration.js";
import { runHostMetricsSettingsMigration } from "../utils/crypto-migration/host-metrics-settings-migration.js";
import { runDockerSettingsMigration } from "../utils/crypto-migration/docker-settings-migration.js";
import { runAiSettingsMigration } from "../utils/crypto-migration/ai-settings-migration.js";
import { runWakeOnLanSettingsMigration } from "../utils/crypto-migration/wake-on-lan-settings-migration.js";
import { runWarpgateSettingsMigration } from "../utils/crypto-migration/warpgate-settings-migration.js";
import { runStepCaSettingsMigration } from "../utils/crypto-migration/step-ca-settings-migration.js";
import { runAcmeSslSettingsMigration } from "../utils/crypto-migration/acme-ssl-settings-migration.js";
import { runVaultSettingsMigration } from "../utils/crypto-migration/vault-settings-migration.js";
import { runSecretSourcesTokenMigration } from "../utils/crypto-migration/secret-sources-token-migration.js";
import { runTotpMigration } from "../utils/crypto-migration/totp-migration.js";
import { runTermixIdentityCaMigration } from "../utils/crypto-migration/termix-identity-ca-migration.js";
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
["runTailscaleSettingsMigration", runTailscaleSettingsMigration],
["runProxmoxSettingsMigration", runProxmoxSettingsMigration],
["runFileManagerSettingsMigration", runFileManagerSettingsMigration],
["runTunnelsSettingsMigration", runTunnelsSettingsMigration],
["runWebEndpointSettingsMigration", runWebEndpointSettingsMigration],
["runSshTerminalSettingsMigration", runSshTerminalSettingsMigration],
["runTmuxMonitorSettingsMigration", runTmuxMonitorSettingsMigration],
["runSessionSharingSettingsMigration", runSessionSharingSettingsMigration],
[
"runSessionRecordingSettingsMigration",
runSessionRecordingSettingsMigration,
],
["runRemoteDesktopSettingsMigration", runRemoteDesktopSettingsMigration],
["runHostMetricsSettingsMigration", runHostMetricsSettingsMigration],
["runDockerSettingsMigration", runDockerSettingsMigration],
["runAiSettingsMigration", runAiSettingsMigration],
["runWakeOnLanSettingsMigration", runWakeOnLanSettingsMigration],
["runWarpgateSettingsMigration", runWarpgateSettingsMigration],
["runStepCaSettingsMigration", runStepCaSettingsMigration],
["runAcmeSslSettingsMigration", runAcmeSslSettingsMigration],
["runVaultSettingsMigration", runVaultSettingsMigration],
["runSecretSourcesTokenMigration", runSecretSourcesTokenMigration],
["runTotpMigration", runTotpMigration],
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
];
export async function runPluginDataMigrations(): Promise<void> {
for (const [name, run] of MIGRATIONS) {
try {
await run();
} catch (error) {
databaseLogger.warn(`Plugin data migration ${name} failed`, {
operation: "plugin_data_migration",
error: getErrorMessage(error),
});
}
}
}
-5
View File
@@ -22,8 +22,3 @@ export function recordConflict(conflict: RegistrationConflict): void {
export function listRegistrationConflicts(): RegistrationConflict[] {
return [...conflicts];
}
/** Test seam. */
export function clearRegistrationConflicts(): void {
conflicts.length = 0;
}
+16 -15
View File
@@ -39,6 +39,7 @@ import type {
MutableConnectConfig,
SshAuthProvider,
SshConnectHost,
SshConnectProfile,
SshConnectPurpose,
} from "../hosts/connect/types.js";
@@ -54,24 +55,22 @@ interface Deps {
audit: AuditFn;
}
const PLUGIN_PURPOSES = new Set<SshConnectPurpose>([
"plugin",
"docker",
"docker-console",
"metrics",
"proxmox",
"fleet",
"remote-desktop",
"tunnel",
"file-manager",
"file-transfer",
const PLUGIN_PROFILES = new Set<SshConnectProfile>([
"terminal",
"tmux",
"session",
"stream",
"forward",
"background",
]);
function purposeOf(options?: PluginSshConnectOptions): SshConnectPurpose {
const purpose = (options?.purpose ?? "plugin") as SshConnectPurpose;
return PLUGIN_PURPOSES.has(purpose) ? purpose : "plugin";
function purposeOf(options?: { purpose?: string }): SshConnectPurpose {
return options?.purpose || "plugin";
}
// "jump" belongs to core's own jump chain; a plugin gets background.
function profileOf(options?: { profile?: string }): SshConnectProfile {
const profile = options?.profile as SshConnectProfile | undefined;
return profile && PLUGIN_PROFILES.has(profile) ? profile : "background";
}
/**
@@ -141,6 +140,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
const connection = await connectHost(host as number | SshConnectHost, {
userId,
purpose: purposeOf(options),
profile: profileOf(options),
timeoutMs: options?.timeoutMs,
prompt: options?.prompt,
overrides: options?.overrides as Partial<MutableConnectConfig>,
@@ -273,6 +273,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
const built = await buildConnectConfig(host as SshConnectHost, {
userId: actingUser(host),
purpose: purposeOf(options),
profile: profileOf(options),
client: options.client as never,
serverHostId: options.serverHostId,
log: options.log,
+14 -15
View File
@@ -17,7 +17,6 @@ import { buildTable, dropTableSql } from "./table-builder.js";
import {
applyPluginMigrations,
readMigrations,
tableExistsSql,
type MigrationRunner,
} from "./migrations.js";
@@ -49,16 +48,26 @@ export function registerTable(
return table;
}
export function getTable(pluginId: string, name: string): unknown {
function getTable(pluginId: string, name: string): unknown {
return built.get(pluginId)?.get(name);
}
export function listTables(pluginId: string): PluginTableDefinition[] {
function listTables(pluginId: string): PluginTableDefinition[] {
return [...(registered.get(pluginId) ?? [])];
}
/** Every registered definition, with the plugin that owns it. */
export function listAllTables(): Array<{
pluginId: string;
definition: PluginTableDefinition;
}> {
return [...registered.entries()].flatMap(([pluginId, definitions]) =>
definitions.map((definition) => ({ pluginId, definition })),
);
}
/** Forgets a plugin's definitions. Used by the tests and by uninstall. */
export function forgetTables(pluginId: string): void {
function forgetTables(pluginId: string): void {
registered.delete(pluginId);
built.delete(pluginId);
}
@@ -84,7 +93,7 @@ async function execute(statements: string[]): Promise<void> {
* the engine does not keep. A half-applied migration is recorded statement by
* statement either way, so a retry resumes rather than repeating.
*/
export async function createMigrationRunner(
async function createMigrationRunner(
dialect: DatabaseDialect = resolveDatabaseDialect(),
): Promise<MigrationRunner> {
const { createCurrentPluginMigrationRepository } =
@@ -125,16 +134,6 @@ export async function migratePlugin(
return applyPluginMigrations(pluginId, pluginDir, runner);
}
/** Whether a table exists, asked in the active dialect's own catalog. */
export async function tableExists(
table: string,
dialect: DatabaseDialect = resolveDatabaseDialect(),
): Promise<boolean> {
const { getDb } = await import("../database/db/index.js");
const rows = (await getDb().all(tableExistsSql(dialect, table))) as unknown[];
return rows.length > 0;
}
/**
* Removes everything a plugin owns.
*
-56
View File
@@ -1,56 +0,0 @@
/**
* The only shape of a host a plugin ever sees.
*
* This is an ALLOWLIST, deliberately, not a list of fields to strip. A
* blocklist rots: the moment someone adds a new secret-bearing column to
* SSHHost, a blocklist silently starts leaking it, and nothing fails. With an
* allowlist a new field is invisible to plugins until someone consciously adds
* it here.
*
* resolveHostById returns an object carrying password, key, keyPassword,
* certPublicKey, sudoPassword and the autostart* secrets in plaintext. None of
* them are in this list, and none of them may ever be.
*/
export interface PluginHostView {
id: number;
name: string | null;
ip: string;
port: number;
username: string;
folder: string | null;
tags: string[];
authType: string | null;
createdAt: string | null;
updatedAt: string | null;
}
const NUMBER_FIELDS = ["id", "port"] as const;
const STRING_FIELDS = [
"name",
"ip",
"username",
"folder",
"authType",
"createdAt",
"updatedAt",
] as const;
export function toPluginHostView(
host: Record<string, unknown>,
): PluginHostView {
const view: Record<string, unknown> = {};
for (const field of NUMBER_FIELDS) {
view[field] = Number(host[field] ?? 0);
}
for (const field of STRING_FIELDS) {
const value = host[field];
view[field] = typeof value === "string" ? value : null;
}
view.tags = Array.isArray(host.tags)
? host.tags.filter((tag): tag is string => typeof tag === "string")
: [];
return view as unknown as PluginHostView;
}
-4
View File
@@ -42,10 +42,6 @@ export function getPluginRouter(pluginId: string): Router | undefined {
return routers.get(pluginId);
}
export function getRegisteredHttpPluginIds(): string[] {
return [...routers.keys()];
}
export function unregisterPluginHttp(pluginId: string): void {
if (!routers.delete(pluginId)) return;
pluginLogger.info(`Unmounted /plugin-api/${pluginId}`, {
+2 -1
View File
@@ -431,7 +431,8 @@ export async function activatePlugin(pluginId: string): Promise<void> {
await pluginLoader.activate(pluginId);
// A plugin enabled without a restart still needs its 2.8 data copied.
const { runPluginDataMigrations } = await import("./boot-migrations.js");
const { runPluginDataMigrations } =
await import("../upgrade/boot-migrations.js");
await runPluginDataMigrations();
}
+4 -4
View File
@@ -48,8 +48,8 @@ export type PluginSource = "bundled" | "user";
const ACTIVATION_TIMEOUT_MS = 30_000;
/** Defaults for the runtime error budget. Both are configurable. */
export const DEFAULT_ERROR_THRESHOLD = 5;
export const DEFAULT_ERROR_WINDOW_MS = 60_000;
const DEFAULT_ERROR_THRESHOLD = 5;
const DEFAULT_ERROR_WINDOW_MS = 60_000;
export interface LoadedPlugin {
id: string;
@@ -141,7 +141,7 @@ export class PluginLoader {
/**
* Scans both roots. A user-installed plugin can never shadow a bundled one:
* the collision is rejected with an error rather than silently skipped, so
* dropping an "ssh-terminal" folder into the data directory is visibly
* dropping a folder with a bundled plugin's id into the data directory is visibly
* refused instead of quietly ignored.
*
* The check is on the parsed manifest id, not the directory name, because a
@@ -347,7 +347,7 @@ export class PluginLoader {
// A new table may be where core data is waiting to move.
if (applied.length > 0) {
const { runPluginDataMoves } =
await import("../utils/crypto-migration/plugin-data-moves.js");
await import("../upgrade/plugin-data-moves.js");
await runPluginDataMoves();
}
-13
View File
@@ -11,12 +11,6 @@ export {
validateManifest,
parseManifest,
SUPPORTED_PLUGIN_API_VERSION,
PLUGIN_CATEGORIES,
PLUGIN_PLATFORMS,
ACTION_CONTRIBUTION_KINDS,
DEFAULT_BACKEND_ENTRY,
DEFAULT_FRONTEND_ENTRY,
DEFAULT_LOCALES_DIR,
SYSTEM_ROLE_NAMES,
RESERVED_PERMISSION_PREFIXES,
qualifyPermission,
@@ -41,13 +35,6 @@ export type {
ParsedManifest,
} from "@termix/plugin-sdk/manifest";
export {
CAPABILITY_CATALOG,
CAPABILITY_IDS,
isKnownCapability,
getCapabilityInfo,
} from "@termix/plugin-sdk/capabilities";
export type {
Capability,
CapabilityInfo,
+1 -9
View File
@@ -57,10 +57,7 @@ export function checksum(contents: string): string {
return crypto.createHash("sha256").update(normalised).digest("hex");
}
export function migrationsDir(
pluginDir: string,
dialect: DatabaseDialect,
): string {
function migrationsDir(pluginDir: string, dialect: DatabaseDialect): string {
return path.join(pluginDir, "migrations", dialect);
}
@@ -211,11 +208,6 @@ export async function applyPluginMigrations(
return pending.map((migration) => migration.id);
}
/** The dialect directories a plugin is expected to ship. */
export function expectedDialects(): readonly DatabaseDialect[] {
return DIALECTS;
}
/**
* Renames a legacy core table into a plugin's namespace, or creates it fresh.
*
-4
View File
@@ -47,10 +47,6 @@ export function getBundledPluginsDir(): string {
return candidates[0];
}
export function getPluginDir(pluginId: string): string {
return path.join(getPluginsDir(), pluginId);
}
/** The backend entry, from the manifest's own `backend` field. */
export function getPluginBackendEntry(
pluginDir: string,
-2
View File
@@ -15,8 +15,6 @@
import { createCurrentPluginPermissionGrantRepository } from "../database/repositories/factory.js";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
export { PluginCapabilityError };
const cache = new Map<string, Set<string>>();
export function invalidatePluginPermissionCache(pluginId?: string): void {
+1 -1
View File
@@ -29,7 +29,7 @@
* ## Authorization is the provider's own permission
*
* No fourth grant mechanism. The provider declares which of its own role
* permissions gates sharing ("ai.secrets.share", alongside "ai.services.use"),
* permissions gates sharing (for example "<plugin>.secrets.share"),
* and getShared runs the acting user through the same PermissionManager check
* that service-registry.ts and requirePermission() use. So:
*
-8
View File
@@ -12,7 +12,6 @@
* order rather than relying on registration sequence.
*/
import { pluginLogger } from "../utils/logger.js";
import type { SyncEntityRegistration } from "@termix/plugin-sdk/backend";
export interface RegisteredSyncEntity extends SyncEntityRegistration {
@@ -91,10 +90,3 @@ export function unregisterByOwner(owner: string): void {
export function resetSyncRegistry(): void {
entities.clear();
}
/** Logged once at boot so a missing entity is visible without a debugger. */
export function logRegisteredEntities(): void {
pluginLogger.info(`Sync entities: ${listEntityTypes().join(", ")}`, {
operation: "sync_registry",
});
}
-2
View File
@@ -13,6 +13,4 @@ export {
dropTableSql,
addColumnSql,
keyedColumns,
columnName,
REFERENCEABLE,
} from "@termix/plugin-sdk/ddl";
+195
View File
@@ -0,0 +1,195 @@
/**
* A user's rows in plugin tables, for the per-user exports and imports.
*
* Core never names a plugin table. Any table a running plugin declared with a
* refUser column belongs to a user, so it travels with that user's export.
* Encrypted columns stay behind: they are sealed with this server's keys.
*/
import { sql } from "drizzle-orm";
import { columnName, createTableSql } from "@termix/plugin-sdk/ddl";
import { prefixedTableName } from "@termix/plugin-sdk/db";
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
import { getDb } from "../database/db/index.js";
import { listAllTables } from "./data.js";
export interface UserOwnedTable {
pluginId: string;
/** The physical table name, p_<id>_<name>. */
table: string;
/** The legacy core name the table adopted, for older export files. */
legacyName?: string;
userColumn: string;
/** Columns that travel, as SQL names. The primary key and secrets do not. */
columns: string[];
definition: PluginTableDefinition;
}
export function listUserOwnedTables(): UserOwnedTable[] {
const owned: UserOwnedTable[] = [];
for (const { pluginId, definition } of listAllTables()) {
const entries = Object.entries(definition.columns);
const user = entries.find(([, column]) => column.type === "refUser");
if (!user) continue;
owned.push({
pluginId,
table: prefixedTableName(pluginId, definition.name),
legacyName: definition.adopts,
userColumn: columnName(user[0], user[1]),
columns: entries
.filter(
([, column]) =>
column.type !== "id" &&
!column.primaryKey &&
column.type !== "encryptedText",
)
.map(([property, column]) => columnName(property, column)),
definition,
});
}
return owned;
}
type Row = Record<string, unknown>;
/** The user's rows in every user-owned plugin table, keyed by table. */
export async function readUserPluginRows(
userId: string,
): Promise<Record<string, Row[]>> {
const db = getDb();
const result: Record<string, Row[]> = {};
for (const owned of listUserOwnedTables()) {
try {
const rows = await db.all<Row>(
sql`SELECT ${sql.join(
owned.columns.map((column) => sql.identifier(column)),
sql`, `,
)} FROM ${sql.identifier(owned.table)} WHERE ${sql.identifier(
owned.userColumn,
)} = ${userId}`,
);
if (rows.length > 0) result[owned.table] = rows;
} catch {
// The plugin's migration has not created the table yet.
}
}
return result;
}
interface SqliteLike {
exec(source: string): unknown;
prepare(source: string): {
run(...params: unknown[]): unknown;
all(...params: unknown[]): unknown[];
};
}
/** Writes the user's plugin rows into a SQLite export file. */
export async function writeUserPluginTables(
exportDb: SqliteLike,
userId: string,
): Promise<number> {
const rowsByTable = await readUserPluginRows(userId);
let written = 0;
for (const owned of listUserOwnedTables()) {
const rows = rowsByTable[owned.table];
if (!rows?.length) continue;
for (const statement of createTableSql(
"sqlite",
owned.pluginId,
owned.definition,
)) {
exportDb.exec(statement);
}
const insert = exportDb.prepare(
`INSERT INTO "${owned.table}" (${owned.columns
.map((column) => `"${column}"`)
.join(", ")}) VALUES (${owned.columns.map(() => "?").join(", ")})`,
);
for (const row of rows) {
insert.run(...owned.columns.map((column) => toSqlite(row[column])));
written++;
}
}
return written;
}
function toSqlite(value: unknown): unknown {
if (value === undefined) return null;
if (typeof value === "boolean") return value ? 1 : 0;
if (value instanceof Date) return value.toISOString();
if (value !== null && typeof value === "object") return JSON.stringify(value);
return value;
}
export interface PluginRowsImport {
imported: number;
skipped: number;
errors: string[];
}
/**
* Copies plugin rows from an import file into this server as `userId`. A row
* matching one the user already has is skipped. Files from before 2.9 carry
* the legacy table names, which are read too.
*/
export async function importUserPluginRows(
importDb: SqliteLike,
userId: string,
): Promise<PluginRowsImport> {
const db = getDb();
const summary: PluginRowsImport = { imported: 0, skipped: 0, errors: [] };
for (const owned of listUserOwnedTables()) {
let rows: Row[] | null = null;
for (const name of [owned.table, owned.legacyName].filter(Boolean)) {
try {
rows = importDb.prepare(`SELECT * FROM "${name}"`).all() as Row[];
break;
} catch {
// not in this file under that name
}
}
if (!rows) continue;
for (const source of rows) {
const columns = owned.columns.filter(
(column) => column === owned.userColumn || source[column] !== undefined,
);
const values = columns.map((column) =>
column === owned.userColumn ? userId : source[column],
);
try {
const existing = await db.all(
sql`SELECT 1 FROM ${sql.identifier(owned.table)} WHERE ${sql.join(
columns.map((column, index) =>
values[index] === null
? sql`${sql.identifier(column)} IS NULL`
: sql`${sql.identifier(column)} = ${values[index]}`,
),
sql` AND `,
)} LIMIT 1`,
);
if (existing.length > 0) {
summary.skipped++;
continue;
}
await db.run(
sql`INSERT INTO ${sql.identifier(owned.table)} (${sql.join(
columns.map((column) => sql.identifier(column)),
sql`, `,
)}) VALUES (${sql.join(
values.map((value) => sql`${value}`),
sql`, `,
)})`,
);
summary.imported++;
} catch (error) {
summary.errors.push(
`${owned.table} import error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
return summary;
}
+2 -2
View File
@@ -31,7 +31,7 @@ import { recordConflict } from "./conflicts.js";
import { extractWebSocketToken } from "../utils/ws-auth.js";
import { runAsActor } from "./actor.js";
export const PLUGIN_WS_PREFIX = "/plugin-ws/";
const PLUGIN_WS_PREFIX = "/plugin-ws/";
type RawUpgradeHandler = (
request: IncomingMessage,
@@ -127,7 +127,7 @@ function reject(socket: Duplex, status: number, message: string): void {
* Exported for the test suite and for database.ts, which wires it into both
* the HTTP and the HTTPS server.
*/
export async function handlePluginUpgrade(
async function handlePluginUpgrade(
request: IncomingMessage,
socket: Duplex,
head: Buffer,