mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: collapse plugin runtime to one tier behind a plugin SDK
This commit is contained in:
1 parent
6e058f6e9e
commit
4758016f41
96 files changed
+34631
-8551
No files matched your search
@@ -689,6 +689,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
source TEXT NOT NULL DEFAULT 'community',
|
||||
registry_id TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'disabled',
|
||||
last_error TEXT,
|
||||
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
auto_update INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -701,7 +702,9 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
plugin_id TEXT NOT NULL,
|
||||
capability TEXT NOT NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
granted_by TEXT NOT NULL,
|
||||
-- Nullable: a bundled grant is made by the install, not by a user.
|
||||
granted_by TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'admin',
|
||||
UNIQUE (plugin_id, capability),
|
||||
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
||||
@@ -987,6 +990,63 @@ const addColumnIfNotExists = (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Drops the NOT NULL on plugin_permission_grants.granted_by.
|
||||
*
|
||||
* A bundled grant is made by the install rather than by a person, so there is
|
||||
* no user id to record. SQLite cannot relax a constraint in place, so the
|
||||
* table is rebuilt; it only ever holds a handful of rows.
|
||||
*/
|
||||
const relaxPluginGrantGrantedBy = () => {
|
||||
try {
|
||||
const sql = sqlite
|
||||
.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'plugin_permission_grants'`,
|
||||
)
|
||||
.get() as { sql?: string } | undefined;
|
||||
|
||||
if (!sql?.sql || !/granted_by\s+TEXT\s+NOT\s+NULL/i.test(sql.sql)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sqlite.exec(`
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
CREATE TABLE plugin_permission_grants_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
capability TEXT NOT NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
granted_by TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'admin',
|
||||
UNIQUE (plugin_id, capability),
|
||||
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO plugin_permission_grants_new
|
||||
(id, plugin_id, capability, granted_at, granted_by, source)
|
||||
SELECT id, plugin_id, capability, granted_at, granted_by, 'admin'
|
||||
FROM plugin_permission_grants;
|
||||
DROP TABLE plugin_permission_grants;
|
||||
ALTER TABLE plugin_permission_grants_new RENAME TO plugin_permission_grants;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
`);
|
||||
|
||||
databaseLogger.info(
|
||||
"Relaxed plugin_permission_grants.granted_by to allow bundled grants",
|
||||
{ operation: "schema_migration" },
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
`Failed to relax plugin_permission_grants.granted_by: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ operation: "schema_migration" },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
addColumnIfNotExists(
|
||||
"session_recordings",
|
||||
@@ -2836,6 +2896,16 @@ const migrateSchema = () => {
|
||||
}
|
||||
addColumnIfNotExists("homepage_items", "sync_id", "TEXT");
|
||||
|
||||
// Plugin runtime: lastError reports why a plugin is blocked or failed, and
|
||||
// grants record whether a capability came from an admin or from bundling.
|
||||
addColumnIfNotExists("plugins", "last_error", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"plugin_permission_grants",
|
||||
"source",
|
||||
"TEXT NOT NULL DEFAULT 'admin'",
|
||||
);
|
||||
relaxPluginGrantGrantedBy();
|
||||
|
||||
const syncIdTables = [
|
||||
"ssh_data",
|
||||
"ssh_credentials",
|
||||
|
||||
@@ -2142,7 +2142,10 @@ export const plugins = mysqlTable(
|
||||
tier: text("tier").notNull().default("available"),
|
||||
source: text("source").notNull().default("community"),
|
||||
registryId: varchar("registry_id", { length: 255 }),
|
||||
/** enabled | disabled | blocked | failed */
|
||||
state: text("state").notNull().default("disabled"),
|
||||
/** Why the plugin is blocked or failed, for the admin UI. */
|
||||
lastError: text("last_error"),
|
||||
installedAt: text("installed_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
@@ -2168,9 +2171,16 @@ export const pluginPermissionGrants = mysqlTable(
|
||||
grantedAt: text("granted_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
grantedBy: varchar("granted_by", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
/**
|
||||
* Who granted it. Null for a bundled grant, which no user made: shipping
|
||||
* in the install is the consent. Nullable also stops a user deletion from
|
||||
* cascading a bundled plugin's capabilities away.
|
||||
*/
|
||||
grantedBy: varchar("granted_by", { length: 255 }).references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
/** admin | bundled */
|
||||
source: text("source").notNull().default("admin"),
|
||||
},
|
||||
// A plugin's grants are always read together, and re-granting the same
|
||||
// capability should update the existing row rather than duplicate it.
|
||||
|
||||
@@ -2143,7 +2143,10 @@ export const plugins = pgTable(
|
||||
tier: text("tier").notNull().default("available"),
|
||||
source: text("source").notNull().default("community"),
|
||||
registryId: varchar("registry_id", { length: 255 }),
|
||||
/** enabled | disabled | blocked | failed */
|
||||
state: text("state").notNull().default("disabled"),
|
||||
/** Why the plugin is blocked or failed, for the admin UI. */
|
||||
lastError: text("last_error"),
|
||||
installedAt: text("installed_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -2169,9 +2172,16 @@ export const pluginPermissionGrants = pgTable(
|
||||
grantedAt: text("granted_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
grantedBy: varchar("granted_by", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
/**
|
||||
* Who granted it. Null for a bundled grant, which no user made: shipping
|
||||
* in the install is the consent. Nullable also stops a user deletion from
|
||||
* cascading a bundled plugin's capabilities away.
|
||||
*/
|
||||
grantedBy: varchar("granted_by", { length: 255 }).references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
/** admin | bundled */
|
||||
source: text("source").notNull().default("admin"),
|
||||
},
|
||||
// A plugin's grants are always read together, and re-granting the same
|
||||
// capability should update the existing row rather than duplicate it.
|
||||
|
||||
@@ -2141,7 +2141,10 @@ export const plugins = sqliteTable(
|
||||
tier: text("tier").notNull().default("available"),
|
||||
source: text("source").notNull().default("community"),
|
||||
registryId: text("registry_id"),
|
||||
/** enabled | disabled | blocked | failed */
|
||||
state: text("state").notNull().default("disabled"),
|
||||
/** Why the plugin is blocked or failed, for the admin UI. */
|
||||
lastError: text("last_error"),
|
||||
installedAt: text("installed_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -2167,9 +2170,16 @@ export const pluginPermissionGrants = sqliteTable(
|
||||
grantedAt: text("granted_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
grantedBy: text("granted_by")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
/**
|
||||
* Who granted it. Null for a bundled grant, which no user made: shipping
|
||||
* in the install is the consent. Nullable also stops a user deletion from
|
||||
* cascading a bundled plugin's capabilities away.
|
||||
*/
|
||||
grantedBy: text("granted_by").references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
/** admin | bundled */
|
||||
source: text("source").notNull().default("admin"),
|
||||
},
|
||||
// A plugin's grants are always read together, and re-granting the same
|
||||
// capability should update the existing row rather than duplicate it.
|
||||
|
||||
@@ -10,7 +10,9 @@ export type PluginPermissionGrantRecord =
|
||||
export interface PluginPermissionGrantCreateInput {
|
||||
pluginId: string;
|
||||
capability: string;
|
||||
grantedBy: string;
|
||||
/** Null for a bundled grant, which no user made. */
|
||||
grantedBy: string | null;
|
||||
source?: "admin" | "bundled";
|
||||
}
|
||||
|
||||
export class PluginPermissionGrantRepository {
|
||||
@@ -55,6 +57,7 @@ export class PluginPermissionGrantRepository {
|
||||
pluginId: input.pluginId,
|
||||
capability: input.capability,
|
||||
grantedBy: input.grantedBy,
|
||||
source: input.source ?? "admin",
|
||||
grantedAt: now,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface PluginCreateInput {
|
||||
source?: string;
|
||||
registryId?: string | null;
|
||||
state?: string;
|
||||
lastError?: string | null;
|
||||
autoUpdate?: boolean;
|
||||
manifestJson: string;
|
||||
}
|
||||
@@ -29,6 +30,7 @@ export interface PluginUpdateInput {
|
||||
source?: string;
|
||||
registryId?: string | null;
|
||||
state?: string;
|
||||
lastError?: string | null;
|
||||
autoUpdate?: boolean;
|
||||
manifestJson?: string;
|
||||
}
|
||||
@@ -64,6 +66,7 @@ export class PluginRepository {
|
||||
source: input.source ?? "community",
|
||||
registryId: input.registryId ?? null,
|
||||
state: input.state ?? "disabled",
|
||||
lastError: input.lastError ?? null,
|
||||
autoUpdate: input.autoUpdate ?? false,
|
||||
manifestJson: input.manifestJson,
|
||||
installedAt: now,
|
||||
@@ -95,6 +98,8 @@ export class PluginRepository {
|
||||
? existing.registryId
|
||||
: input.registryId,
|
||||
state: input.state ?? existing.state,
|
||||
lastError:
|
||||
input.lastError === undefined ? existing.lastError : input.lastError,
|
||||
autoUpdate: input.autoUpdate ?? existing.autoUpdate,
|
||||
manifestJson: input.manifestJson ?? existing.manifestJson,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -31,61 +31,88 @@ const requireManagePlugins = permissionManager.requirePermission(
|
||||
* summary: List installed plugins and their runtime state
|
||||
* description: >
|
||||
* Returns every plugin known to the database, merged with the loader's
|
||||
* live state so the UI can tell "enabled but crashed" from "disabled".
|
||||
* live state so the UI can tell "enabled but failed" from "disabled".
|
||||
* Open to any authenticated user, not just admins: the app shell calls
|
||||
* this on every session to decide which plugin-contributed tabs and
|
||||
* rail items to register, so gating it behind admin.plugins.manage
|
||||
* would break the shell for non-admin users. Only the mutating routes
|
||||
* below (enable/disable, grant/revoke) require that permission.
|
||||
* would break the shell for non-admin users.
|
||||
*
|
||||
* Non-admins get only what the shell needs. What a plugin is allowed to
|
||||
* do, what has been granted to it and why it failed are operational
|
||||
* details, so capabilities, grants and lastError are included only for
|
||||
* holders of admin.plugins.manage.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of plugins.
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (_req: Request, res: Response) => {
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const records = await createCurrentPluginRepository().listAll();
|
||||
const { loader } = getPluginRuntime();
|
||||
const live = new Map(loader.list().map((p) => [p.id, p]));
|
||||
const grantRepository = createCurrentPluginPermissionGrantRepository();
|
||||
|
||||
const plugins = await Promise.all(
|
||||
records.map(async (record) => {
|
||||
const loaded = live.get(record.id);
|
||||
let contributes: unknown = null;
|
||||
let permissions: string[] = [];
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
contributes?: unknown;
|
||||
permissions?: unknown;
|
||||
};
|
||||
contributes = manifest?.contributes ?? null;
|
||||
permissions = Array.isArray(manifest?.permissions)
|
||||
? (manifest.permissions as string[])
|
||||
: [];
|
||||
} catch {
|
||||
contributes = null;
|
||||
}
|
||||
|
||||
const grants = await grantRepository.listByPlugin(record.id);
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
tier: record.tier,
|
||||
source: record.source,
|
||||
enabled: record.state === "enabled",
|
||||
runtimeState: loaded?.state ?? "stopped",
|
||||
lastError: loaded?.lastError ?? null,
|
||||
contributes,
|
||||
permissions,
|
||||
grantedCapabilities: grants.map((grant) => grant.capability),
|
||||
};
|
||||
}),
|
||||
const canManage = await permissionManager.hasPermission(
|
||||
userId,
|
||||
"admin.plugins.manage",
|
||||
);
|
||||
|
||||
// One query for every plugin rather than one per plugin.
|
||||
const grantsByPlugin = new Map<string, string[]>();
|
||||
if (canManage) {
|
||||
const grantRepository = createCurrentPluginPermissionGrantRepository();
|
||||
await Promise.all(
|
||||
records.map(async (record) => {
|
||||
const grants = await grantRepository.listByPlugin(record.id);
|
||||
grantsByPlugin.set(
|
||||
record.id,
|
||||
grants.map((grant) => grant.capability),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const plugins = records.map((record) => {
|
||||
const loaded = live.get(record.id);
|
||||
|
||||
let contributes: unknown = null;
|
||||
let capabilities: string[] = [];
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
contributes?: unknown;
|
||||
capabilities?: unknown;
|
||||
};
|
||||
contributes = manifest?.contributes ?? null;
|
||||
capabilities = Array.isArray(manifest?.capabilities)
|
||||
? (manifest.capabilities as string[])
|
||||
: [];
|
||||
} catch {
|
||||
contributes = null;
|
||||
}
|
||||
|
||||
const summary = {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
enabled: record.state === "enabled",
|
||||
state: loaded?.state ?? record.state,
|
||||
contributes,
|
||||
};
|
||||
|
||||
if (!canManage) return summary;
|
||||
|
||||
return {
|
||||
...summary,
|
||||
tier: record.tier,
|
||||
source: record.source,
|
||||
capabilities,
|
||||
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
||||
lastError: loaded?.lastError ?? record.lastError ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
res.json(plugins);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
@@ -135,7 +162,6 @@ router.patch(
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const pluginId = String(req.params.id);
|
||||
const { enabled } = req.body ?? {};
|
||||
|
||||
@@ -157,12 +183,13 @@ router.patch(
|
||||
// acts on it rather than silently reverting.
|
||||
await repository.update(pluginId, {
|
||||
state: enabled ? "enabled" : "disabled",
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const { activatePlugin, deactivatePlugin } =
|
||||
await import("../../plugins/index.js");
|
||||
|
||||
if (enabled) await activatePlugin(pluginId, userId);
|
||||
if (enabled) await activatePlugin(pluginId);
|
||||
else await deactivatePlugin(pluginId);
|
||||
|
||||
databaseLogger.info(
|
||||
@@ -182,6 +209,89 @@ router.patch(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/retry:
|
||||
* post:
|
||||
* summary: Start a plugin again after it failed
|
||||
* description: >
|
||||
* Clears the plugin's error budget and activates it. Use after fixing
|
||||
* whatever made it fail, such as installing a missing dependency or
|
||||
* freeing a port it needs.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The plugin's state after the attempt.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such plugin, or it is not on disk.
|
||||
* 500:
|
||||
* description: The plugin failed to start again.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/retry",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
|
||||
try {
|
||||
const repository = createCurrentPluginRepository();
|
||||
const record = await repository.findById(pluginId);
|
||||
if (!record) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { loader } = getPluginRuntime();
|
||||
if (!loader.get(pluginId)) {
|
||||
res.status(404).json({ error: "Plugin is not present on disk" });
|
||||
return;
|
||||
}
|
||||
|
||||
await repository.update(pluginId, {
|
||||
state: "enabled",
|
||||
lastError: null,
|
||||
});
|
||||
await loader.retry(pluginId);
|
||||
|
||||
databaseLogger.info(`Plugin ${pluginId} restarted`, {
|
||||
operation: "plugin_retry",
|
||||
pluginId,
|
||||
});
|
||||
|
||||
res.json({ id: pluginId, state: loader.get(pluginId)?.state });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
databaseLogger.error(
|
||||
`Failed to retry plugin ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(message),
|
||||
{ operation: "plugin_retry" },
|
||||
);
|
||||
|
||||
try {
|
||||
await createCurrentPluginRepository().update(pluginId, {
|
||||
state: "failed",
|
||||
lastError: message,
|
||||
});
|
||||
} catch {
|
||||
// Reporting the failure must not mask it.
|
||||
}
|
||||
|
||||
res.status(500).json({ error: "Failed to start the plugin" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/grants:
|
||||
@@ -244,10 +354,10 @@ router.post(
|
||||
let declared: string[] = [];
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
permissions?: unknown;
|
||||
capabilities?: unknown;
|
||||
};
|
||||
declared = Array.isArray(manifest?.permissions)
|
||||
? (manifest.permissions as string[])
|
||||
declared = Array.isArray(manifest?.capabilities)
|
||||
? (manifest.capabilities as string[])
|
||||
: [];
|
||||
} catch {
|
||||
declared = [];
|
||||
|
||||
Reference in new issue
Block a user