feat: give plugins their own settings and one settings screen

This commit is contained in:
LukeGus committed 2026-09-22 18:31:19 -05:00
1 parent cc15559ea1
commit 49f0286a14
66 files changed
+36497 -451

No files matched your search

@@ -34,6 +34,7 @@ import { OpenTabRepository } from "./open-tab-repository.js";
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
import { PluginRepository } from "./plugin-repository.js";
import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
@@ -395,6 +396,13 @@ export function createCurrentPluginStorageRepository(): PluginStorageRepository
);
}
export function createCurrentPluginSettingsRepository(): PluginSettingsRepository {
return new PluginSettingsRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("plugin_settings_repository_write"),
);
}
export function createCurrentPluginMigrationRepository(): PluginMigrationRepository {
return new PluginMigrationRepository(
createCurrentRepositoryContext(),
@@ -1,6 +1,6 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import { hostAccess, hosts } from "../db/schema.js";
import { hostAccess, hosts, pluginSettings } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { rowsAffected } from "./mutation-result.js";
@@ -338,6 +338,25 @@ export class HostRepository {
}
async deleteByUserId(userId: string): Promise<number> {
// Bulk path, so it never passes through deleteAccessForHost: clear the
// host-scope plugin settings for these hosts before the rows go.
const owned = await this.context.drizzle
.select({ id: hosts.id })
.from(hosts)
.where(eq(hosts.userId, userId));
if (owned.length > 0) {
await this.context.drizzle.delete(pluginSettings).where(
and(
eq(pluginSettings.scope, "host"),
inArray(
pluginSettings.scopeId,
owned.map((row) => String(row.id)),
),
),
);
}
const result = await this.context.drizzle
.delete(hosts)
.where(eq(hosts.userId, userId));
@@ -350,6 +369,18 @@ export class HostRepository {
}
async deleteAccessForHost(hostId: number): Promise<number> {
// plugin_settings.scope_id is polymorphic, so it has no foreign key to
// ssh_data and the engine will not cascade it. Cleared alongside the
// access rows, which every host delete path already goes through.
await this.context.drizzle
.delete(pluginSettings)
.where(
and(
eq(pluginSettings.scope, "host"),
eq(pluginSettings.scopeId, String(hostId)),
),
);
const result = await this.context.drizzle
.delete(hostAccess)
.where(eq(hostAccess.hostId, hostId));
@@ -0,0 +1,191 @@
import { and, eq, inArray, isNull } from "drizzle-orm";
import { pluginSettings } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type PluginSettingsRecord = typeof pluginSettings.$inferSelect;
export type PluginSettingsScope = "admin" | "user" | "host";
/**
* Values behind ctx.settings and the /plugins/:id/settings routes.
*
* scope_id is null for admin scope, so every lookup has to use IS NULL rather
* than = NULL: SQL equality against null never matches, which would make an
* admin setting look unset no matter how often it was written.
*/
export class PluginSettingsRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
private scopeMatch(
pluginId: string,
scope: PluginSettingsScope,
scopeId: string | null,
) {
return and(
eq(pluginSettings.pluginId, pluginId),
eq(pluginSettings.scope, scope),
scopeId === null
? isNull(pluginSettings.scopeId)
: eq(pluginSettings.scopeId, scopeId),
);
}
async get(
pluginId: string,
scope: PluginSettingsScope,
scopeId: string | null,
key: string,
): Promise<PluginSettingsRecord | null> {
const rows = await this.context.drizzle
.select()
.from(pluginSettings)
.where(
and(
this.scopeMatch(pluginId, scope, scopeId),
eq(pluginSettings.key, key),
),
)
.limit(1);
return rows[0] ?? null;
}
async getAll(
pluginId: string,
scope: PluginSettingsScope,
scopeId: string | null,
): Promise<PluginSettingsRecord[]> {
return this.context.drizzle
.select()
.from(pluginSettings)
.where(this.scopeMatch(pluginId, scope, scopeId));
}
/**
* Every row for a set of scope ids, in one query.
*
* The host list would otherwise run one query per host, which is the shape
* that made other list endpoints slow at a few hundred hosts.
*/
async getAllForScopeIds(
scope: PluginSettingsScope,
scopeIds: string[],
): Promise<PluginSettingsRecord[]> {
if (scopeIds.length === 0) return [];
return this.context.drizzle
.select()
.from(pluginSettings)
.where(
and(
eq(pluginSettings.scope, scope),
inArray(pluginSettings.scopeId, scopeIds),
),
);
}
async set(
pluginId: string,
scope: PluginSettingsScope,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
now = new Date().toISOString(),
): Promise<void> {
const where = and(
this.scopeMatch(pluginId, scope, scopeId),
eq(pluginSettings.key, key),
)!;
const existing = await this.context.drizzle
.select()
.from(pluginSettings)
.where(where)
.limit(1);
if (existing[0]) {
await updateReturning(
this.context,
pluginSettings,
{ value, encrypted, updatedAt: now },
where,
);
} else {
await insertReturning(this.context, pluginSettings, {
pluginId,
scope,
scopeId,
key,
value,
encrypted,
updatedAt: now,
});
}
await this.afterWrite();
}
async delete(
pluginId: string,
scope: PluginSettingsScope,
scopeId: string | null,
key: string,
): Promise<boolean> {
const result = await this.context.drizzle
.delete(pluginSettings)
.where(
and(
this.scopeMatch(pluginId, scope, scopeId),
eq(pluginSettings.key, key),
),
);
const affected = rowsAffected(result) > 0;
if (affected) await this.afterWrite();
return affected;
}
async deleteByPlugin(pluginId: string): Promise<number> {
const result = await this.context.drizzle
.delete(pluginSettings)
.where(eq(pluginSettings.pluginId, pluginId));
const affected = rowsAffected(result);
if (affected > 0) await this.afterWrite();
return affected;
}
/**
* Removes every plugin's rows for one user or host.
*
* scope_id carries no foreign key, so the user and host delete paths call
* this instead of relying on the engine to cascade.
*/
async deleteByScope(
scope: PluginSettingsScope,
scopeId: string,
): Promise<number> {
const result = await this.context.drizzle
.delete(pluginSettings)
.where(
and(
eq(pluginSettings.scope, scope),
eq(pluginSettings.scopeId, scopeId),
),
);
const affected = rowsAffected(result);
if (affected > 0) await this.afterWrite();
return affected;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,5 +1,5 @@
import { asc, eq, inArray, like, sql } from "drizzle-orm";
import { users } from "../db/schema.js";
import { and, asc, eq, inArray, like, sql } from "drizzle-orm";
import { pluginSettings, users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import {
countValue,
@@ -199,6 +199,15 @@ export class UserRepository {
}
async delete(id: string): Promise<boolean> {
// plugin_settings.scope_id is polymorphic, so it carries no foreign key to
// users and the engine will not cascade it. Removed here rather than in a
// route so every caller that deletes a user is covered.
await this.context.drizzle
.delete(pluginSettings)
.where(
and(eq(pluginSettings.scope, "user"), eq(pluginSettings.scopeId, id)),
);
const result = await this.context.drizzle
.delete(users)
.where(eq(users.id, id));