feat: move snippets, keybinding actions and macros out of core into plugins

This commit is contained in:
LukeGus committed 2026-09-29 11:39:14 -05:00
1 parent d9bba10612
commit e0c30a4e6f
262 files changed
+9091 -10744

No files matched your search

@@ -668,9 +668,6 @@ export function registerHostBulkRoutes(
pin: hostData.pin || false,
sudoPassword: hostData.sudoPassword || null,
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
quickActions: hostData.quickActions
? JSON.stringify(hostData.quickActions)
: null,
...importedStatusCheck(hostData as Record<string, unknown>),
terminalConfig: hostData.terminalConfig
? JSON.stringify(hostData.terminalConfig)
@@ -921,7 +918,6 @@ export function registerHostBulkRoutes(
jumpHosts: hostData.jumpHosts
? JSON.stringify(hostData.jumpHosts)
: null,
quickActions: null,
statusCheckEnabled: true,
statusCheckInterval: null,
terminalConfig: null,
@@ -179,7 +179,6 @@ export type NormalizedImportedHost = Record<string, unknown> & {
pin?: unknown;
sudoPassword?: unknown;
jumpHosts?: unknown;
quickActions?: unknown;
statusCheckEnabled?: unknown;
statusCheckInterval?: unknown;
terminalConfig?: unknown;
@@ -418,11 +417,11 @@ function parseJsonObject(value: unknown): Record<string, unknown> | null {
}
/**
* sshOptions, and terminalConfig in the shape 2.8 clients and exports read: core's own
* keys only (the SSH options and the startup snippet). The terminal's look
* and behavior are the ssh-terminal plugin's host settings, which it puts
* back through its hostPayloadLegacy. A sudo password 2.8 kept in
* terminal_config comes out as sudoPassword, for the sanitizers to handle.
* sshOptions, and terminalConfig in the shape 2.8 clients and exports read:
* core's own keys only, the SSH options. Everything else 2.8 kept there now
* belongs to plugins' host settings, which they put back through their
* hostPayloadLegacy. A sudo password 2.8 kept in terminal_config comes out as
* sudoPassword, for the sanitizers to handle.
*/
export function hostTerminalExport(host: Record<string, unknown>): {
sshOptions: HostSshOptions;
@@ -434,9 +433,6 @@ export function hostTerminalExport(host: Record<string, unknown>): {
host.sshOptions != null ? host.sshOptions : raw,
);
const terminalConfig: Record<string, unknown> = { ...sshOptions };
if (raw && raw.startupSnippetId !== undefined) {
terminalConfig.startupSnippetId = raw.startupSnippetId;
}
const legacySudo = !host.sudoPassword ? raw?.sudoPassword : undefined;
return {
sshOptions,
@@ -477,9 +473,9 @@ export function transformHostResponse(
vncUser: host.vncUser || undefined,
telnetUser: host.telnetUser || undefined,
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
quickActions: host.quickActions
? JSON.parse(host.quickActions as string)
: [],
// Moved into a plugin's host settings; its hostPayloadLegacy puts it
// back for 2.8 clients.
quickActions: undefined,
statusCheckEnabled:
host.statusCheckEnabled !== false && host.statusCheckEnabled !== 0,
statusCheckInterval:
-15
View File
@@ -194,7 +194,6 @@ router.post(
sudoPassword,
pin,
jumpHosts,
quickActions,
statusCheckEnabled,
statusCheckInterval,
terminalConfig,
@@ -296,9 +295,6 @@ router.post(
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
quickActions: Array.isArray(quickActions)
? JSON.stringify(quickActions)
: null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
terminalConfig: terminalConfig
@@ -682,7 +678,6 @@ router.post(
keyPassword: resolvedKeyPassword,
keyType: resolvedKeyType,
jumpHosts: [],
quickActions: [],
statusCheckEnabled: true,
statusCheckInterval: null,
notes: "",
@@ -794,7 +789,6 @@ router.put(
sudoPassword,
pin,
jumpHosts,
quickActions,
statusCheckEnabled,
statusCheckInterval,
terminalConfig,
@@ -897,9 +891,6 @@ router.put(
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
quickActions: Array.isArray(quickActions)
? JSON.stringify(quickActions)
: null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
terminalConfig: terminalConfig
@@ -1768,9 +1759,6 @@ router.get(
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
terminalConfig:
hostTerminalExport(resolvedHost).terminalConfig ?? null,
sshOptions: hostTerminalExport(resolvedHost).sshOptions,
@@ -1903,9 +1891,6 @@ router.get(
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
terminalConfig:
hostTerminalExport(resolvedHost).terminalConfig ?? null,
sshOptions: hostTerminalExport(resolvedHost).sshOptions,
@@ -1,13 +1,31 @@
const VALID_ACTION_TYPES = [
"copy",
"paste",
"sendControlCode",
"sendText",
"runSnippet",
"nextTab",
"previousTab",
"openCommandPalette",
];
import {
CORE_KEYBINDING_ACTIONS,
validateKeybindingActionParams,
type PluginKeybindingActionContribution,
} from "@termix/plugin-sdk/manifest";
export interface DeclaredKeybindingAction extends PluginKeybindingActionContribution {
pluginId: string;
}
let declaredActions: () => DeclaredKeybindingAction[] = () => [];
/** Set by the plugin runtime: every installed plugin's declared actions. */
export function setKeybindingActionSource(
source: () => DeclaredKeybindingAction[],
): void {
declaredActions = source;
}
/** The declaration a saved action type answers to, if a plugin made one. */
export function findKeybindingAction(
type: string,
): DeclaredKeybindingAction | undefined {
return declaredActions().find((action) => action.id === type);
}
const ACTION_TYPE_PATTERN = /^[a-zA-Z][a-zA-Z0-9.-]{0,63}$/;
const MAX_LOOSE_VALUE = 65_536;
export function isValidKeyCombo(combo: unknown): boolean {
return (
@@ -22,22 +40,32 @@ export function isValidKeyCombo(combo: unknown): boolean {
);
}
/**
* A saved action. The shell's own types take no parameters; a type a plugin
* declared is checked against its declaration, whether or not the plugin is
* running. A type nobody declares (its plugin was removed) is only checked
* loosely, so the rest of the list can still be saved, and whichever plugin
* claims it later ignores what it cannot use.
*/
export function isValidKeybindingAction(action: unknown): boolean {
if (!action || typeof action !== "object") return false;
const type = (action as { type?: unknown }).type;
if (typeof type !== "string" || !VALID_ACTION_TYPES.includes(type))
if (!action || typeof action !== "object" || Array.isArray(action)) {
return false;
if (type === "sendText") {
return typeof (action as { text?: unknown }).text === "string";
}
if (type === "sendControlCode") {
const code = (action as { controlCode?: unknown }).controlCode;
return typeof code === "string" && /^[a-zA-Z]$/.test(code);
const record = action as Record<string, unknown>;
const type = record.type;
if (typeof type !== "string" || !ACTION_TYPE_PATTERN.test(type)) {
return false;
}
if (type === "runSnippet") {
return typeof (action as { snippetId?: unknown }).snippetId === "string";
}
return true;
if (CORE_KEYBINDING_ACTIONS.includes(type)) return true;
const declared = findKeybindingAction(type);
if (declared) return validateKeybindingActionParams(record, declared);
return Object.entries(record).every(
([, value]) =>
value === null ||
typeof value === "boolean" ||
typeof value === "number" ||
(typeof value === "string" && value.length <= MAX_LOOSE_VALUE),
);
}
export function isValidKeybinding(entry: unknown): boolean {
@@ -647,7 +647,7 @@ export function registerUserAdminRoutes(
if (outcome.status === "wipe_confirmation_required") {
return res.status(409).json({
error:
"This user has not logged in since the encryption upgrade, so their data cannot be recovered. Set confirmDataWipe to reset anyway and delete their hosts, credentials and snippets.",
"This user has not logged in since the encryption upgrade, so their data cannot be recovered. Set confirmDataWipe to reset anyway and delete their hosts, credentials and other saved data.",
code: "DATA_WIPE_REQUIRED",
});
}
@@ -71,7 +71,7 @@ export async function resetUserPassword(
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
// A plugin holding this user's data (snippets and anything else keyed by
// A plugin holding this user's data (anything keyed by
// refUser()) wipes it on this topic, since the user row itself survives.
pluginEvents.emit(TOPICS.userDataWiped, { userId });
@@ -416,7 +416,7 @@ export function registerUserPasswordResetRoutes(
if (outcome.status === "wipe_confirmation_required") {
return res.status(409).json({
error:
"This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and snippets.",
"This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and other saved data.",
code: "DATA_WIPE_REQUIRED",
});
}
@@ -27,8 +27,6 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
pinAppRail: row?.pinAppRail ?? null,
expandAppRailOnHover: row?.expandAppRailOnHover ?? null,
showPinAppRailButton: row?.showPinAppRailButton ?? null,
foldersCollapsed: row?.foldersCollapsed ?? null,
confirmSnippetExecution: row?.confirmSnippetExecution ?? null,
disableUpdateCheck: row?.disableUpdateCheck ?? null,
confirmTabClose: row?.confirmTabClose ?? null,
hiddenRailTabs: row?.hiddenRailTabs ?? null,
@@ -37,7 +35,6 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
customThemes: row?.customThemes ?? null,
customKeybindings: row?.customKeybindings ?? null,
terminalDefaults: row?.terminalDefaults ?? null,
terminalMacros: row?.terminalMacros ?? null,
});
const connectionDefaultFields = ["terminalDefaults"] as const;
@@ -57,7 +54,7 @@ export function validateDefaultsJson(value: string): boolean {
* /user-preferences:
* get:
* summary: Get preferences for the current user
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are legacy fields, kept here read-only for backward compatibility; the authoritative copy is GET /host-sidebar/preferences. foldersCollapsed is likewise legacy and read-only; its authoritative copy is the snippets plugin's own user settings.
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are legacy fields, kept here read-only for backward compatibility; the authoritative copy is GET /host-sidebar/preferences.
* tags:
* - User Preferences
* responses:
@@ -106,12 +103,6 @@ export function validateDefaultsJson(value: string): boolean {
* showPinAppRailButton:
* type: boolean
* nullable: true
* foldersCollapsed:
* type: boolean
* nullable: true
* confirmSnippetExecution:
* type: boolean
* nullable: true
* disableUpdateCheck:
* type: boolean
* nullable: true
@@ -134,7 +125,7 @@ export function validateDefaultsJson(value: string): boolean {
* customKeybindings:
* type: string
* nullable: true
* description: JSON-encoded array of the user's custom terminal keybindings.
* description: JSON-encoded array of the user's custom keybindings. An action's type is one of the shell's own or one a plugin declares in contributes.keybindingActions.
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -157,7 +148,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* /user-preferences:
* put:
* summary: Update preferences for the current user
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign. foldersCollapsed is no longer accepted either, now that it is a snippets plugin user setting.
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign. Values that moved into a feature's own user settings are ignored too.
* tags:
* - User Preferences
* requestBody:
@@ -189,8 +180,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* type: boolean
* showPinAppRailButton:
* type: boolean
* confirmSnippetExecution:
* type: boolean
* disableUpdateCheck:
* type: boolean
* confirmTabClose:
@@ -202,7 +191,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* description: JSON-encoded array of the user's saved global custom terminal themes.
* customKeybindings:
* type: string
* description: JSON-encoded array of the user's custom terminal keybindings.
* description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type.
* responses:
* 200:
* description: Preferences updated successfully.
@@ -221,14 +210,12 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
pinAppRail,
expandAppRailOnHover,
showPinAppRailButton,
confirmSnippetExecution,
disableUpdateCheck,
confirmTabClose,
hiddenRailTabs,
customThemes,
customKeybindings,
terminalDefaults,
terminalMacros,
} = req.body as {
reopenTabsOnLogin?: boolean;
theme?: string | null;
@@ -241,21 +228,19 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
pinAppRail?: boolean | null;
expandAppRailOnHover?: boolean | null;
showPinAppRailButton?: boolean | null;
confirmSnippetExecution?: boolean | null;
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
customThemes?: string | null;
customKeybindings?: string | null;
terminalDefaults?: string | null;
terminalMacros?: string | null;
};
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
// longer writable here -- they moved to /host-sidebar/preferences as of the
// sidebar redesign. The columns stay in the table (read once as a
// migration seed by that route) but this endpoint silently ignores them if
// a stale client still sends them. foldersCollapsed similarly moved out,
// to the snippets plugin's own user settings.
// a stale client still sends them. The same goes for the preferences that
// moved into a plugin's own user settings.
const updates: UserPreferenceUpdate = {
updatedAt: new Date().toISOString(),
@@ -280,7 +265,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
customThemes,
customKeybindings,
terminalDefaults,
terminalMacros,
})) {
if (value !== undefined && value !== null && typeof value !== "string") {
return res.status(400).json({ error: `${key} must be a string` });
@@ -349,41 +333,12 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
}
}
if (terminalMacros !== undefined && terminalMacros !== null) {
let parsedMacros: unknown;
try {
parsedMacros = JSON.parse(terminalMacros);
} catch {
return res
.status(400)
.json({ error: "terminalMacros must be a JSON-encoded array" });
}
if (
terminalMacros.length > 512 * 1024 ||
!Array.isArray(parsedMacros) ||
parsedMacros.length > 100 ||
!parsedMacros.every(
(macro) =>
!!macro &&
typeof macro === "object" &&
typeof (macro as { id?: unknown }).id === "string" &&
typeof (macro as { name?: unknown }).name === "string" &&
Array.isArray((macro as { steps?: unknown }).steps),
)
) {
return res.status(400).json({
error: "terminalMacros must contain at most 100 valid macros",
});
}
}
const boolFields: Record<string, boolean | null | undefined> = {
commandAutocomplete,
commandPaletteEnabled,
pinAppRail,
expandAppRailOnHover,
showPinAppRailButton,
confirmSnippetExecution,
disableUpdateCheck,
confirmTabClose,
};
@@ -408,8 +363,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
updates.expandAppRailOnHover = expandAppRailOnHover;
if (showPinAppRailButton !== undefined)
updates.showPinAppRailButton = showPinAppRailButton;
if (confirmSnippetExecution !== undefined)
updates.confirmSnippetExecution = confirmSnippetExecution;
if (disableUpdateCheck !== undefined)
updates.disableUpdateCheck = disableUpdateCheck;
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
@@ -418,7 +371,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
updates.customKeybindings = customKeybindings;
if (terminalDefaults !== undefined)
updates.terminalDefaults = terminalDefaults;
if (terminalMacros !== undefined) updates.terminalMacros = terminalMacros;
if (Object.keys(updates).length === 1) {
return res.status(400).json({ error: "No preferences provided" });
-7
View File
@@ -143,13 +143,6 @@ export async function resolveHostById(
host.socks5ProxyChain = [];
}
}
if (typeof host.quickActions === "string" && host.quickActions) {
try {
host.quickActions = JSON.parse(host.quickActions as string);
} catch {
host.quickActions = [];
}
}
if (typeof host.portKnockSequence === "string" && host.portKnockSequence) {
try {
host.portKnockSequence = JSON.parse(host.portKnockSequence as string);
+19
View File
@@ -21,6 +21,8 @@ import { invalidatePluginPermissionCache } from "./permissions.js";
import { setSshAuthTypeOwnerSource } from "../hosts/connect/auth-provider-registry.js";
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
import { recordConflict } from "./conflicts.js";
import { setPluginImpersonationCheck } from "../utils/auth-manager.js";
import { setKeybindingActionSource } from "../database/routes/keybinding-validation.js";
let loader: PluginLoader | null = null;
@@ -34,6 +36,23 @@ export function getPluginRuntime(): { loader: PluginLoader } {
(pluginId) => loader?.get(pluginId)?.state === "active",
);
setPluginInstalledCheck((pluginId) => !!loader?.get(pluginId));
// An admin acting for another user reaches only the plugins that opted in.
setPluginImpersonationCheck((pluginId) => {
const plugin = loader?.get(pluginId);
return (
plugin?.state === "active" &&
plugin.manifest.contributes?.http?.adminImpersonation === true
);
});
// Saved keybindings are checked against every installed plugin's
// declarations, so a binding keeps validating while its plugin is off.
setKeybindingActionSource(() =>
(loader?.list() ?? []).flatMap((plugin) =>
(plugin.manifest.contributes?.keybindingActions ?? []).map(
(action) => ({ ...action, pluginId: plugin.id }),
),
),
);
// Lets a host whose auth type belongs to a disabled plugin name it.
setSshAuthTypeOwnerSource(() =>
(loader?.list() ?? []).flatMap((plugin) =>
+57 -10
View File
@@ -57,8 +57,6 @@ const HOST_REFERENCES = [
entityType: "hosts",
},
{ field: "jumpHosts[].hostId", entityType: "hosts" },
{ field: "quickActions[].snippetId", entityType: "commandSnippet" },
{ field: "terminalConfig.startupSnippetId", entityType: "commandSnippet" },
] as const;
/** Columns that only mean something on the device that holds them. */
@@ -69,6 +67,13 @@ const HOST_LOCAL_FIELDS = [
"hostKeyLastVerified",
];
/**
* Columns kept until 3.0.0 that nothing reads any more: their values moved
* into plugin host settings, which sync with the host's pluginSettings. They
* stay off the wire so a stale local id never reaches the other side.
*/
const HOST_RETIRED_FIELDS = ["quickActions"];
let registered = false;
/** Registers core's entities. Idempotent. */
@@ -148,7 +153,7 @@ export function registerCoreSyncEntities(): void {
table: hosts,
order: 50,
encryptedFields: FieldCrypto.fieldsFor("ssh_data"),
readOnlyFields: HOST_LOCAL_FIELDS,
readOnlyFields: [...HOST_LOCAL_FIELDS, ...HOST_RETIRED_FIELDS],
references: HOST_REFERENCES,
shouldSync: (row) => !row.localOnly && !row.sharedSource,
permissions: {
@@ -198,13 +203,18 @@ export function registerCoreSyncEntities(): void {
order: 90,
singleton: true,
readOnlyFields: ["storageMode"],
references: [
{
field: "customKeybindings[].action.snippetId",
entityType: "commandSnippet",
idType: "string",
},
],
// A keybinding parameter a plugin declares with a syncEntity holds a
// local row id of that entity; the wire carries its syncId.
serialize: (row, resolveSyncId) =>
mapKeybindingReferences(row, async (entityType, value) => {
const id = Number(value);
return Number.isInteger(id) ? resolveSyncId(entityType, id) : null;
}),
deserialize: (row, resolveId) =>
mapKeybindingReferences(row, async (entityType, value) => {
const id = await resolveId(entityType, value);
return id === null ? null : String(id);
}),
});
registerEntity(
@@ -253,6 +263,43 @@ export function registerCoreSyncEntities(): void {
);
}
/**
* Rewrites every keybinding parameter whose declaration names a sync entity,
* keeping the column's storage form. A value that does not map becomes null.
*/
export async function mapKeybindingReferences(
row: SyncRow,
map: (entityType: string, value: string) => Promise<string | null>,
): Promise<SyncRow> {
const stored = row.customKeybindings;
if (typeof stored !== "string" || !stored) return row;
let bindings: unknown;
try {
bindings = JSON.parse(stored);
} catch {
return row;
}
if (!Array.isArray(bindings)) return row;
const { findKeybindingAction } =
await import("../database/routes/keybinding-validation.js");
let changed = false;
for (const binding of bindings) {
const action = (binding as { action?: Record<string, unknown> } | null)
?.action;
if (!action || typeof action.type !== "string") continue;
const params = findKeybindingAction(action.type)?.params ?? {};
for (const [name, param] of Object.entries(params)) {
const value = action[name];
if (!param.syncEntity || typeof value !== "string" || !value) continue;
action[name] = await map(param.syncEntity, value);
changed = true;
}
}
return changed
? { ...row, customKeybindings: JSON.stringify(bindings) }
: row;
}
/** A synced parent link that would make a cycle here is dropped. */
async function dropInvalidParent(userId: string, hostId: number) {
const { createCurrentRepositoryContext } =
@@ -305,10 +305,17 @@ describe("transformHostResponse", () => {
it("parses JSON array fields and defaults them to []", () => {
const result = transformHostResponse({
jumpHosts: '[{"hostId":8}]',
quickActions: null,
portKnockSequence: null,
});
expect(result.jumpHosts).toEqual([{ hostId: 8 }]);
expect(result.quickActions).toEqual([]);
expect(result.portKnockSequence).toEqual([]);
});
it("leaves the 2.8 quick_actions column out, for its plugin to put back", () => {
const result = transformHostResponse({
quickActions: '[{"name":"x","snippetId":1}]',
});
expect(result.quickActions).toBeUndefined();
});
it("passes the stored SSH switch through", () => {
@@ -440,10 +447,7 @@ describe("transformHostResponse terminal fields", () => {
}),
});
expect(host.sshOptions).toEqual({ keepaliveInterval: 20 });
expect(host.terminalConfig).toEqual({
keepaliveInterval: 20,
startupSnippetId: 3,
});
expect(host.terminalConfig).toEqual({ keepaliveInterval: 20 });
});
it("reads the options out of terminal_config before the boot copy", () => {
@@ -1,10 +1,44 @@
import { describe, it, expect } from "vitest";
import { afterEach, beforeEach, describe, it, expect } from "vitest";
import {
isValidKeyCombo,
isValidKeybindingAction,
isValidKeybinding,
setKeybindingActionSource,
} from "../../../database/routes/keybinding-validation.js";
// What installed plugins declare in contributes.keybindingActions.
beforeEach(() => {
setKeybindingActionSource(() => [
{ pluginId: "terminal-fixture", id: "copy" },
{ pluginId: "terminal-fixture", id: "paste" },
{
pluginId: "terminal-fixture",
id: "sendControlCode",
params: {
controlCode: { type: "string", required: true, pattern: "^[a-zA-Z]$" },
},
},
{
pluginId: "terminal-fixture",
id: "sendText",
params: {
text: { type: "string", required: true },
appendEnter: { type: "boolean" },
},
},
{
pluginId: "commands-fixture",
id: "runSnippet",
params: {
snippetId: { type: "string", required: true, pattern: "^[0-9]+$" },
appendEnter: { type: "boolean" },
},
},
]);
});
afterEach(() => setKeybindingActionSource(() => []));
const validCombo = {
key: "c",
isCode: false,
@@ -36,15 +70,26 @@ describe("isValidKeybindingAction", () => {
expect(isValidKeybindingAction({ type: "paste" })).toBe(true);
});
it.each(["nextTab", "previousTab", "openCommandPalette"])(
it.each(["nextTab", "previousTab", "openCommandPalette", "reconnectSession"])(
"accepts the global %s action",
(type) => {
expect(isValidKeybindingAction({ type })).toBe(true);
},
);
it("rejects an unknown action type", () => {
expect(isValidKeybindingAction({ type: "explode" })).toBe(false);
it("keeps a type no installed plugin declares, if it looks sane", () => {
expect(isValidKeybindingAction({ type: "gone.action", arg: "x" })).toBe(
true,
);
expect(isValidKeybindingAction({ type: "gone", nested: { a: 1 } })).toBe(
false,
);
expect(isValidKeybindingAction({ type: "bad type!" })).toBe(false);
expect(isValidKeybindingAction({ type: 5 })).toBe(false);
});
it("refuses a parameter the declaration does not list", () => {
expect(isValidKeybindingAction({ type: "copy", text: "x" })).toBe(false);
});
it("requires text for sendText", () => {
@@ -72,6 +117,9 @@ describe("isValidKeybindingAction", () => {
isValidKeybindingAction({ type: "runSnippet", snippetId: "42" }),
).toBe(true);
expect(isValidKeybindingAction({ type: "runSnippet" })).toBe(false);
expect(
isValidKeybindingAction({ type: "runSnippet", snippetId: "abc" }),
).toBe(false);
});
});
Binary file not shown.
+28
View File
@@ -199,7 +199,9 @@ const HOST_ROWS: Row[] = [
allowLegacyAlgorithms: false,
agentForwarding: true,
environmentVariables: [{ key: "D4", value: "yes" }],
startupSnippetId: 1,
}),
quick_actions: JSON.stringify([{ name: "d4-uptime", snippetId: 1 }]),
mac_address: "AA:BB:CC:DD:EE:04",
wol_broadcast_address: "10.4.0.255",
stats_config: JSON.stringify({
@@ -387,6 +389,32 @@ export const ROWS: Record<string, Row[]> = {
},
]),
command_autocomplete: 1,
confirm_snippet_execution: 1,
folders_collapsed: 0,
terminal_macros: JSON.stringify([
{
id: "d4-macro",
name: "d4 macro",
steps: [{ id: "s1", type: "send", text: "uptime", pressEnter: true }],
},
]),
custom_keybindings: JSON.stringify([
{
id: "d4-kb",
combo: {
key: "u",
isCode: false,
ctrl: true,
alt: true,
shift: false,
meta: false,
},
action: { type: "runSnippet", snippetId: "1", appendEnter: true },
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
]),
},
],
sso_providers: [
@@ -686,3 +686,76 @@ describe("contributes.http.legacyRedirects", () => {
expect(errors).toMatch(/extra/);
});
});
describe("contributes.http.adminImpersonation", () => {
it("accepts a boolean and refuses anything else", () => {
expect(
validateManifest(
base({ contributes: { http: { adminImpersonation: true } } }),
),
).toEqual([]);
expect(
validateManifest(
base({ contributes: { http: { adminImpersonation: "yes" } } }),
).join(),
).toMatch(/adminImpersonation" must be a boolean/);
});
});
describe("contributes.keybindingActions", () => {
const withActions = (keybindingActions: unknown) =>
validateManifest(base({ contributes: { keybindingActions } }));
it("accepts actions with typed parameters", () => {
expect(
withActions([
{ id: "sample.run" },
{
id: "sample.send",
params: {
itemId: {
type: "string",
required: true,
pattern: "^[0-9]+$",
maxLength: 20,
syncEntity: "items",
},
enter: { type: "boolean" },
},
},
]),
).toEqual([]);
});
it("refuses the shell's own actions, duplicates and bad ids", () => {
const errors = withActions([
{ id: "nextTab" },
{ id: "sample.run" },
{ id: "sample.run" },
{ id: "bad id" },
]).join();
expect(errors).toMatch(/one of the shell's own actions/);
expect(errors).toMatch(/duplicates "sample.run"/);
expect(errors).toMatch(/letters, digits, dots and dashes/);
});
it("refuses bad parameters", () => {
const errors = withActions([
{
id: "sample.run",
params: {
type: { type: "string" },
a: { type: "number" },
b: { type: "string", pattern: "(" },
c: { type: "boolean", syncEntity: "items" },
d: { type: "string", extra: 1 },
},
},
]).join();
expect(errors).toMatch(/params.type is not a valid parameter name/);
expect(errors).toMatch(/a.type must be "string" or "boolean"/);
expect(errors).toMatch(/b.pattern must be a regular expression/);
expect(errors).toMatch(/c.syncEntity needs a string parameter/);
expect(errors).toMatch(/d/);
});
});
@@ -9,7 +9,11 @@ import {
resetSyncRegistry,
unregisterByOwner,
} from "../../plugins/sync-registry.js";
import { registerCoreSyncEntities } from "../../sync/entities.js";
import {
mapKeybindingReferences,
registerCoreSyncEntities,
} from "../../sync/entities.js";
import { setKeybindingActionSource } from "../../database/routes/keybinding-validation.js";
beforeEach(() => {
resetSyncRegistry();
@@ -197,14 +201,51 @@ describe("core sync entities", () => {
);
});
it("translates every snippet a host points at", () => {
const fields = (getEntity("hosts")?.references ?? [])
.filter((reference) => reference.entityType === "commandSnippet")
.map((reference) => reference.field);
expect(fields).toEqual([
"quickActions[].snippetId",
"terminalConfig.startupSnippetId",
it("leaves plugin data out of the host row, which travels in pluginSettings", () => {
const hosts = getEntity("hosts");
expect(
(hosts?.references ?? []).map((reference) => reference.entityType),
).not.toContain("commandSnippet");
expect(hosts?.readOnlyFields).toContain("quickActions");
});
it("translates a keybinding parameter that names a sync entity", async () => {
setKeybindingActionSource(() => [
{
pluginId: "fixture",
id: "runThing",
params: {
thingId: { type: "string", syncEntity: "things" },
label: { type: "string" },
},
},
]);
try {
const row = {
customKeybindings: JSON.stringify([
{ id: "a", action: { type: "runThing", thingId: "4", label: "4" } },
{ id: "b", action: { type: "nextTab" } },
]),
};
const out = await mapKeybindingReferences(row, async (entity, value) =>
entity === "things" && value === "4" ? "sync-4" : null,
);
const bindings = JSON.parse(out.customKeybindings as string);
expect(bindings[0].action).toEqual({
type: "runThing",
thingId: "sync-4",
label: "4",
});
expect(bindings[1].action).toEqual({ type: "nextTab" });
expect(
await mapKeybindingReferences(
{ customKeybindings: null },
async () => "x",
),
).toEqual({ customKeybindings: null });
} finally {
setKeybindingActionSource(() => []);
}
});
it("encrypts host and credential secrets", () => {
@@ -326,6 +326,10 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
containerRuntime: "podman",
});
expect(await host("ai")).toMatchObject({ enableAiAssistant: true });
expect(await host("snippets")).toMatchObject({
startupSnippetId: 1,
quickActions: [{ name: "d4-uptime", snippetId: 1 }],
});
expect(await host("wake-on-lan")).toMatchObject({
macAddress: "AA:BB:CC:DD:EE:04",
broadcastAddress: "10.4.0.255",
@@ -473,6 +477,11 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
terminalDefaults: { fontSize: 18, cursorBlink: false },
customThemes: [expect.objectContaining({ id: "d4-theme" })],
commandAutocomplete: true,
macros: [expect.objectContaining({ id: "d4-macro", name: "d4 macro" })],
});
expect(await settings(booted, "snippets", "user")).toMatchObject({
confirmExecution: true,
foldersCollapsed: false,
});
expect(await settings(booted, "step-ca", "admin")).toMatchObject({
caUrl: "https://ca.d4.example",
@@ -0,0 +1,174 @@
/**
* The snippets settings migration: host quick actions, the startup snippet
* and the two snippet user preferences must come through into the snippets
* plugin's settings, and running it twice must change nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const pluginRows: Row[] = [];
const hostRows: Array<Record<string, unknown>> = [];
const preferenceRows: Array<Record<string, unknown>> = [];
let pluginInstalled = true;
const find = (scope: string, scopeId: string | null, key: string) =>
pluginRows.find(
(row) =>
row.pluginId === "snippets" &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
pluginInstalled && id === "snippets" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(scope, scopeId, key);
if (existing) existing.value = value;
else pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async (query: unknown) =>
JSON.stringify(query).includes("user_preferences")
? preferenceRows
: hostRows,
}),
}));
const { runSnippetsSettingsMigration, snippetHostSettingsFromRow } =
await import("../../upgrade/snippets-settings-migration.js");
function value(scope: string, scopeId: string, key: string): unknown {
const row = find(scope, scopeId, key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
beforeEach(() => {
pluginRows.length = 0;
hostRows.length = 0;
preferenceRows.length = 0;
pluginInstalled = true;
});
describe("snippetHostSettingsFromRow", () => {
it("reads quick actions and the startup snippet, dropping broken rows", () => {
expect(
snippetHostSettingsFromRow({
quick_actions: JSON.stringify([
{ name: "Restart", snippetId: 3 },
{ name: "Broken", snippetId: "x" },
{ name: "Str", snippetId: "4" },
]),
terminal_config: JSON.stringify({ fontSize: 14, startupSnippetId: 9 }),
}),
).toEqual({
quickActions: [
{ name: "Restart", snippetId: 3 },
{ name: "Str", snippetId: 4 },
],
startupSnippetId: 9,
});
});
it("reads nothing from a row without either", () => {
expect(
snippetHostSettingsFromRow({
quick_actions: "[]",
terminal_config: JSON.stringify({ fontSize: 14 }),
}),
).toEqual({});
});
});
describe("runSnippetsSettingsMigration", () => {
it("moves host and user values once", async () => {
hostRows.push(
{
id: 1,
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
terminal_config: JSON.stringify({ startupSnippetId: 5 }),
},
{ id: 2, quick_actions: null, terminal_config: "{}" },
);
preferenceRows.push(
{ user_id: "u1", confirm_snippet_execution: 1, folders_collapsed: 0 },
{
user_id: "u2",
confirm_snippet_execution: null,
folders_collapsed: null,
},
);
const first = await runSnippetsSettingsMigration();
expect(first).toEqual({ hostsMoved: 1, hostsSkipped: 0, usersMoved: 1 });
expect(value("host", "1", "quickActions")).toEqual([
{ name: "Up", snippetId: 2 },
]);
expect(value("host", "1", "startupSnippetId")).toBe(5);
expect(value("user", "u1", "confirmExecution")).toBe(true);
expect(value("user", "u1", "foldersCollapsed")).toBe(false);
expect(find("user", "u2", "confirmExecution")).toBeUndefined();
const snapshot = JSON.stringify(pluginRows);
const second = await runSnippetsSettingsMigration();
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 1, usersMoved: 0 });
expect(JSON.stringify(pluginRows)).toBe(snapshot);
});
it("never overwrites what the user already changed in the plugin", async () => {
pluginRows.push({
pluginId: "snippets",
scope: "host",
scopeId: "1",
key: "startupSnippetId",
value: "7",
});
hostRows.push({
id: 1,
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
terminal_config: JSON.stringify({ startupSnippetId: 5 }),
});
await runSnippetsSettingsMigration();
expect(value("host", "1", "startupSnippetId")).toBe(7);
expect(find("host", "1", "quickActions")).toBeUndefined();
});
it("does nothing while the plugin is not installed", async () => {
pluginInstalled = false;
hostRows.push({
id: 1,
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
terminal_config: null,
});
await runSnippetsSettingsMigration();
expect(pluginRows).toHaveLength(0);
});
});
@@ -0,0 +1,104 @@
/**
* The terminal macros migration: a user's saved macros must come through
* into the ssh-terminal plugin's macros setting, and running it twice must
* change nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const pluginRows: Row[] = [];
const preferenceRows: Array<Record<string, unknown>> = [];
const find = (scopeId: string, key: string) =>
pluginRows.find(
(row) =>
row.pluginId === "ssh-terminal" &&
row.scope === "user" &&
row.scopeId === scopeId &&
row.key === key,
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) => (id === "ssh-terminal" ? { id } : null),
}),
createCurrentPluginSettingsRepository: () => ({
get: async (_p: string, _s: string, scopeId: string, key: string) =>
find(scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string,
key: string,
value: string | null,
) => {
const existing = find(scopeId, key);
if (existing) existing.value = value;
else pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({ all: async () => preferenceRows }),
}));
const { runSshTerminalMacrosMigration, macrosFromPreferences } =
await import("../../upgrade/ssh-terminal-macros-migration.js");
const macro = { id: "m1", name: "Deploy", steps: [{ id: "s", type: "send" }] };
beforeEach(() => {
pluginRows.length = 0;
preferenceRows.length = 0;
});
describe("macrosFromPreferences", () => {
it("keeps valid macros and drops the rest", () => {
expect(
macrosFromPreferences(JSON.stringify([macro, { id: 1 }, "x"])),
).toEqual([macro]);
expect(macrosFromPreferences("[]")).toBeNull();
expect(macrosFromPreferences("{bad")).toBeNull();
});
});
describe("runSshTerminalMacrosMigration", () => {
it("moves each user's macros once", async () => {
preferenceRows.push(
{ user_id: "u1", terminal_macros: JSON.stringify([macro]) },
{ user_id: "u2", terminal_macros: "[]" },
);
expect(await runSshTerminalMacrosMigration()).toEqual({ usersMoved: 1 });
expect(JSON.parse(find("u1", "macros")!.value!)).toEqual([macro]);
expect(find("u2", "macros")).toBeUndefined();
const snapshot = JSON.stringify(pluginRows);
expect(await runSshTerminalMacrosMigration()).toEqual({ usersMoved: 0 });
expect(JSON.stringify(pluginRows)).toBe(snapshot);
});
it("leaves a user who already has macros in the plugin alone", async () => {
pluginRows.push({
pluginId: "ssh-terminal",
scope: "user",
scopeId: "u1",
key: "macros",
value: "[]",
});
preferenceRows.push({
user_id: "u1",
terminal_macros: JSON.stringify([macro]),
});
await runSshTerminalMacrosMigration();
expect(find("u1", "macros")!.value).toBe("[]");
});
});
@@ -66,7 +66,8 @@ vi.mock("../../utils/audit-logger.js", () => ({
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
}));
const { AuthManager } = await import("../../utils/auth-manager.js");
const { AuthManager, setPluginImpersonationCheck, allowsAdminImpersonation } =
await import("../../utils/auth-manager.js");
const authManager = AuthManager.getInstance();
const middleware = authManager.createAuthMiddleware();
@@ -216,4 +217,48 @@ describe("AuthManager admin impersonation", () => {
expect(nexted).toBe(true);
expect((req as unknown as { userId: string }).userId).toBe("regular1");
});
it("rejects the header on a plugin route whose manifest did not opt in", async () => {
setPluginImpersonationCheck((pluginId) => pluginId === "opted-in");
try {
const { res, nexted } = await runMiddleware(token("admin1"), {
"x-admin-target-user": "target1",
__url: "/plugin-api/other/items",
});
expect(nexted).toBe(false);
expect(res.statusCode).toBe(403);
expect((res.body as { code?: string }).code).toBe(
"IMPERSONATION_NOT_ALLOWED",
);
} finally {
setPluginImpersonationCheck(() => false);
}
});
it("lets an admin act for a user on a plugin that opted in", async () => {
setPluginImpersonationCheck((pluginId) => pluginId === "opted-in");
try {
const { req, nexted } = await runMiddleware(token("admin1"), {
"x-admin-target-user": "target1",
__url: "/plugin-api/opted-in/items?x=1",
});
expect(nexted).toBe(true);
expect((req as unknown as { userId: string }).userId).toBe("target1");
} finally {
setPluginImpersonationCheck(() => false);
}
});
it("matches the plugin id exactly, not as a prefix", () => {
setPluginImpersonationCheck((pluginId) => pluginId === "opted-in");
try {
expect(allowsAdminImpersonation("/plugin-api/opted-in")).toBe(true);
expect(allowsAdminImpersonation("/plugin-api/opted-in-too/x")).toBe(
false,
);
expect(allowsAdminImpersonation("/users/sessions")).toBe(false);
} finally {
setPluginImpersonationCheck(() => false);
}
});
});
+4
View File
@@ -16,6 +16,8 @@ import { runTunnelsSettingsMigration } from "./tunnels-settings-migration.js";
import { runWebEndpointSettingsMigration } from "./web-endpoint-settings-migration.js";
import { runSshTerminalSettingsMigration } from "./ssh-terminal-settings-migration.js";
import { runSshTerminalLookMigration } from "./ssh-terminal-look-migration.js";
import { runSshTerminalMacrosMigration } from "./ssh-terminal-macros-migration.js";
import { runSnippetsSettingsMigration } from "./snippets-settings-migration.js";
import { runTmuxMonitorSettingsMigration } from "./tmux-monitor-settings-migration.js";
import { runSessionSharingSettingsMigration } from "./session-sharing-settings-migration.js";
import { runSessionRecordingSettingsMigration } from "./session-recording-settings-migration.js";
@@ -41,6 +43,8 @@ const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
["runWebEndpointSettingsMigration", runWebEndpointSettingsMigration],
["runSshTerminalSettingsMigration", runSshTerminalSettingsMigration],
["runSshTerminalLookMigration", runSshTerminalLookMigration],
["runSshTerminalMacrosMigration", runSshTerminalMacrosMigration],
["runSnippetsSettingsMigration", runSnippetsSettingsMigration],
["runTmuxMonitorSettingsMigration", runTmuxMonitorSettingsMigration],
["runSessionSharingSettingsMigration", runSessionSharingSettingsMigration],
[
@@ -0,0 +1,185 @@
/**
* Moves what core kept for snippets into the snippets plugin's settings.
*
* Per host: ssh_data.quick_actions becomes the quickActions host setting,
* and terminal_config.startupSnippetId the startupSnippetId one. Per user:
* user_preferences.confirm_snippet_execution and folders_collapsed become the
* confirmExecution and foldersCollapsed user settings.
*
* The columns keep their values until 3.0.0 drops them.
*
* Idempotent: a host that already has either host setting, and a user key
* already set, are skipped, so this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
legacyFlag,
selectLegacyRows,
} from "../utils/crypto-migration/raw-rows.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
const PLUGIN_ID = "snippets";
const HOST_KEYS = ["quickActions", "startupSnippetId"];
function parseJson(value: unknown): unknown {
if (typeof value !== "string" || !value) return value ?? undefined;
try {
let parsed = JSON.parse(value);
if (typeof parsed === "string") parsed = JSON.parse(parsed);
return parsed;
} catch {
return undefined;
}
}
function snippetId(value: unknown): number | null {
const id = typeof value === "string" ? Number(value) : value;
return typeof id === "number" && Number.isInteger(id) && id > 0 ? id : null;
}
/** The snippets host settings one 2.8 ssh_data row carried. */
export function snippetHostSettingsFromRow(row: {
quick_actions?: unknown;
terminal_config?: unknown;
}): Record<string, unknown> {
const values: Record<string, unknown> = {};
const actions = parseJson(row.quick_actions);
if (Array.isArray(actions)) {
const quickActions = actions.flatMap((entry) => {
const id = snippetId((entry as { snippetId?: unknown })?.snippetId);
if (id === null) return [];
const name = (entry as { name?: unknown }).name;
return [{ name: typeof name === "string" ? name : "", snippetId: id }];
});
if (quickActions.length > 0) values.quickActions = quickActions;
}
const config = parseJson(row.terminal_config) as
{ startupSnippetId?: unknown } | undefined;
const startup = snippetId(config?.startupSnippetId);
if (startup !== null) values.startupSnippetId = startup;
return values;
}
export interface SnippetsSettingsMigrationResult {
hostsMoved: number;
hostsSkipped: number;
usersMoved: number;
}
export async function runSnippetsSettingsMigration(): Promise<SnippetsSettingsMigrationResult> {
const result: SnippetsSettingsMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
usersMoved: 0,
};
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
try {
const rows = await selectLegacyRows<{
id: number;
quick_actions: unknown;
terminal_config: unknown;
}>(sql`
SELECT id, quick_actions, terminal_config FROM ssh_data
WHERE quick_actions IS NOT NULL OR terminal_config IS NOT NULL
`);
for (const row of rows) {
const values = snippetHostSettingsFromRow(row);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
let done = false;
for (const key of HOST_KEYS) {
if (await pluginSettings.get(PLUGIN_ID, "host", scopeId, key)) {
done = true;
}
}
if (done) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
key,
JSON.stringify(value),
);
}
result.hostsMoved++;
}
} catch (error) {
databaseLogger.warn("Snippets host settings migration failed", {
operation: "snippets_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
try {
const rows = await selectLegacyRows<{
user_id: string;
confirm_snippet_execution: unknown;
folders_collapsed: unknown;
}>(sql`
SELECT user_id, confirm_snippet_execution, folders_collapsed
FROM user_preferences
`);
for (const row of rows) {
const values: Record<string, boolean> = {};
if (row.confirm_snippet_execution != null) {
values.confirmExecution = legacyFlag(
row.confirm_snippet_execution,
false,
);
}
if (row.folders_collapsed != null) {
values.foldersCollapsed = legacyFlag(row.folders_collapsed, true);
}
let moved = false;
for (const [key, value] of Object.entries(values)) {
const existing = await pluginSettings.get(
PLUGIN_ID,
"user",
row.user_id,
key,
);
if (existing && existing.value !== null) continue;
await pluginSettings.set(
PLUGIN_ID,
"user",
row.user_id,
key,
JSON.stringify(value),
);
moved = true;
}
if (moved) result.usersMoved++;
}
} catch (error) {
databaseLogger.warn("Snippets user settings migration failed", {
operation: "snippets_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
if (result.hostsMoved > 0 || result.usersMoved > 0) {
databaseLogger.info(
`Moved the snippet settings of ${result.hostsMoved} host(s) and ${result.usersMoved} user(s) into plugin settings`,
{ operation: "snippets_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,98 @@
/**
* Moves user_preferences.terminal_macros into the ssh-terminal plugin's
* macros user setting. Macros a browser kept on its own (storage mode
* "local") are moved by the plugin's frontend the first time the panel opens.
*
* The column keeps its value until 3.0.0 drops it.
*
* Idempotent: a user whose macros setting is already set is skipped.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
const PLUGIN_ID = "ssh-terminal";
const MAX_MACROS = 100;
/** The saved macros in a 2.8 terminal_macros value, or null for none. */
export function macrosFromPreferences(value: unknown): unknown[] | null {
if (typeof value !== "string" || !value) return null;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return null;
}
if (!Array.isArray(parsed)) return null;
const macros = parsed
.filter(
(macro) =>
!!macro &&
typeof macro === "object" &&
typeof (macro as { id?: unknown }).id === "string" &&
typeof (macro as { name?: unknown }).name === "string" &&
Array.isArray((macro as { steps?: unknown }).steps),
)
.slice(0, MAX_MACROS);
return macros.length > 0 ? macros : null;
}
export async function runSshTerminalMacrosMigration(): Promise<{
usersMoved: number;
}> {
const result = { usersMoved: 0 };
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
try {
const rows = await selectLegacyRows<{
user_id: string;
terminal_macros: unknown;
}>(sql`
SELECT user_id, terminal_macros FROM user_preferences
WHERE terminal_macros IS NOT NULL
`);
for (const row of rows) {
const macros = macrosFromPreferences(row.terminal_macros);
if (!macros) continue;
const existing = await pluginSettings.get(
PLUGIN_ID,
"user",
row.user_id,
"macros",
);
if (existing && existing.value !== null) continue;
await pluginSettings.set(
PLUGIN_ID,
"user",
row.user_id,
"macros",
JSON.stringify(macros),
);
result.usersMoved++;
}
} catch (error) {
databaseLogger.warn("Terminal macros migration failed", {
operation: "ssh_terminal_macros_migration",
error: error instanceof Error ? error.message : String(error),
});
}
if (result.usersMoved > 0) {
databaseLogger.info(
`Moved the terminal macros of ${result.usersMoved} user(s) into plugin settings`,
{ operation: "ssh_terminal_macros_migration" },
);
}
return result;
}
+25 -11
View File
@@ -70,13 +70,30 @@ interface RequestWithHeaders extends Request {
const ADMIN_TARGET_USER_HEADER = "x-admin-target-user";
// Data-plane routes an admin may hit on behalf of another user. Everything
// else (TOTP, sessions, tunnels, file manager, ...) rejects the header.
const IMPERSONATION_PATH_ALLOWLIST = [
/^\/host\/db\//,
/^\/credentials(\/|$)/,
/^\/plugin-api\/snippets(\/|$)/,
];
// Core data-plane routes an admin may hit on behalf of another user.
// Everything else (TOTP, sessions, ...) rejects the header, and so does every
// plugin route unless its manifest sets contributes.http.adminImpersonation.
const IMPERSONATION_PATH_ALLOWLIST = [/^\/host\/db\//, /^\/credentials(\/|$)/];
const PLUGIN_API_PATH = /^\/plugin-api\/([a-z][a-z0-9-]*)(\/|$)/;
let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
/** Set by the plugin runtime: whether a running plugin opted in. */
export function setPluginImpersonationCheck(
check: (pluginId: string) => boolean,
): void {
pluginAllowsImpersonation = check;
}
/** Whether an admin may send X-Admin-Target-User to this path. */
export function allowsAdminImpersonation(path: string): boolean {
if (IMPERSONATION_PATH_ALLOWLIST.some((pattern) => pattern.test(path))) {
return true;
}
const plugin = PLUGIN_API_PATH.exec(path);
return !!plugin && pluginAllowsImpersonation(plugin[1]);
}
class AuthManager {
private static instance: AuthManager;
@@ -836,10 +853,7 @@ class AuthManager {
}
const path = (req.originalUrl || req.url || "").split("?")[0];
const allowed = IMPERSONATION_PATH_ALLOWLIST.some((pattern) =>
pattern.test(path),
);
if (!allowed) {
if (!allowsAdminImpersonation(path)) {
res.status(403).json({
error: "Impersonation is not allowed for this route",
code: "IMPERSONATION_NOT_ALLOWED",
-4
View File
@@ -67,10 +67,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
name: "RBAC",
description: "Role-based access control for host sharing",
},
{
name: "Snippets",
description: "Command snippet management",
},
{
name: "Terminal",
description: "Terminal command history",