feat: drop the 2.8 columns, moves and aliases kept for a downgrade

This commit is contained in:
LukeGus committed 2026-10-05 10:21:11 -05:00
1 parent d3d8269cb7
commit 61f46d2517
196 files changed
+29909 -10511

No files matched your search

+5 -2
View File
@@ -69,8 +69,11 @@ export async function verifyPasswordLogin(request: {
throw new LoginMethodError("Invalid username or password", 401);
}
if (user.isOidc && (!user.passwordHash || user.passwordHash.trim() === "")) {
authLogger.warn("OIDC-only user attempted traditional login", {
if (
user.isExternal &&
(!user.passwordHash || user.passwordHash.trim() === "")
) {
authLogger.warn("External-only user attempted password login", {
operation: "user_login",
username,
userId: user.id,
+4 -4
View File
@@ -1,10 +1,10 @@
/**
* Whether a user signs in through an external login (SSO, LDAP or any login
* plugin) rather than only a password. Read from the is_oidc column, which
* keeps its 2.8 name until 26.10.0 replaces it with user_external_identities.
* plugin) rather than only a password: they have a user_external_identities
* row. UserRepository fills isExternal.
*/
export function isExternalAccount(user: {
isOidc?: boolean | number | null;
isExternal?: boolean | null;
}): boolean {
return !!user.isOidc;
return !!user.isExternal;
}
+3 -9
View File
@@ -175,7 +175,7 @@ async function unlockUser(
): Promise<void> {
const authManager = AuthManager.getInstance();
if (identity.kind === "external" || user.isOidc) {
if (identity.kind === "external" || user.isExternal) {
try {
await authManager.authenticateExternalUser(user.id, deviceType);
} catch (error) {
@@ -280,11 +280,7 @@ export async function runLogin(
);
}
const ssoClaims = {
ssoProviderId: identity.ssoProviderId ?? null,
oidcSub: identity.oidcSub ?? null,
oidcSid: identity.oidcSid ?? null,
};
const ssoClaims = { externalSession: identity.externalSession ?? null };
if (
factors.required.length > 0 &&
@@ -499,9 +495,7 @@ export async function verifySecondFactorAndRespond(
const session = await issueSession(req, user, {
methodId: pending?.methodId ?? "password",
rememberMe,
ssoProviderId: pending?.ssoProviderId ?? null,
oidcSub: pending?.oidcSub ?? null,
oidcSid: pending?.oidcSid ?? null,
externalSession: pending?.externalSession ?? null,
});
consumePendingLogin(lookup.token);
+14 -74
View File
@@ -30,12 +30,15 @@ type ExternalIdentity = Extract<VerifiedIdentity, { kind: "external" }>;
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
/** SSO auto-provisioning: the admin setting, or OIDC_ALLOW_REGISTRATION. */
/** The admin setting that turns external auto-provisioning on. */
export const EXTERNAL_AUTO_PROVISION_KEY = "external_auto_provision";
/** SSO auto-provisioning: the admin setting, or EXTERNAL_ALLOW_REGISTRATION. */
async function isExternalProvisioningAllowed(): Promise<boolean> {
try {
if (
await createCurrentSettingsRepository().getBoolean(
"oidc_auto_provision",
EXTERNAL_AUTO_PROVISION_KEY,
false,
)
) {
@@ -44,9 +47,12 @@ async function isExternalProvisioningAllowed(): Promise<boolean> {
} catch {
// fall through to the environment
}
return (
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === "true"
);
// OIDC_ALLOW_REGISTRATION is the 2.9 name, still read for one release.
const allow =
process.env.EXTERNAL_ALLOW_REGISTRATION ??
process.env.OIDC_ALLOW_REGISTRATION ??
"";
return allow.trim().toLowerCase() === "true";
}
/**
@@ -150,74 +156,12 @@ async function applyRoleSync(
async function findLinkedUser(
identity: ExternalIdentity,
): Promise<UserRecord | null> {
const identities = createCurrentUserAuthRepository();
const users = createCurrentUserRepository();
const linked = await identities.findIdentity(
const linked = await createCurrentUserAuthRepository().findIdentity(
identity.provider,
identity.subject,
);
if (linked) {
const user = await users.findById(linked.userId);
if (user) return user;
}
// Accounts from before user_external_identities matched on the raw
// identifier column; link them the first time they sign in. The column is
// not scoped to a provider, so only an SSO account that has no link yet,
// from the same provider when both say which, can be claimed this way.
if (identity.legacyIdentifier) {
const legacy = await users.findByExternalIdentifier(
identity.legacyIdentifier,
);
if (legacy && (await mayClaimLegacyAccount(legacy, identity))) {
await identities.linkIdentity({
userId: legacy.id,
providerId: identity.provider,
subject: identity.subject,
email: identity.email,
});
return legacy;
}
}
return null;
}
async function mayClaimLegacyAccount(
legacy: UserRecord,
identity: ExternalIdentity,
): Promise<boolean> {
if (!legacy.isOidc) return false;
if (
legacy.ssoProviderId != null &&
identity.ssoProviderId != null &&
legacy.ssoProviderId !== identity.ssoProviderId
) {
return false;
}
const links = await createCurrentUserAuthRepository().listIdentitiesForUser(
legacy.id,
);
// An unscoped legacy subject may already have been migrated under a
// different provider. Creating another account loses access to the old
// data; claiming it automatically could cross an identity-provider boundary.
if (
legacy.ssoProviderId == null &&
identity.ssoProviderId != null &&
identity.legacyIdentifier === identity.subject &&
links.some(
(link) =>
link.subject === identity.subject &&
link.providerId !== identity.provider,
)
) {
throw new LoginMethodError(
"A legacy SSO account has an unresolved provider mapping. Ask an administrator to verify and repair its identity mapping before signing in.",
409,
"legacy_identity_conflict",
);
}
return links.length === 0;
if (!linked) return null;
return createCurrentUserRepository().findById(linked.userId);
}
/**
@@ -249,10 +193,6 @@ async function createUser(
username: await freeUsername(identity.name || identity.subject, null),
passwordHash: "",
isAdmin: !!identity.isAdmin,
isOidc: true,
oidcIdentifier:
identity.legacyIdentifier ?? `${identity.provider}:${identity.subject}`,
ssoProviderId: identity.ssoProviderId ?? null,
});
try {
+3 -8
View File
@@ -17,6 +17,7 @@ import {
createCurrentUserAuthRepository,
} from "../database/repositories/factory.js";
import type { UserRecord } from "../database/repositories/user-repository.js";
import type { ExternalSessionRef } from "./types.js";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
@@ -64,9 +65,7 @@ export interface IssueSessionOptions {
rememberMe: boolean;
/** Username the rate limiter counted attempts under, to clear them. */
rateLimitUsername?: string;
ssoProviderId?: number | null;
oidcSub?: string | null;
oidcSid?: string | null;
externalSession?: ExternalSessionRef | null;
/** SSO logins keep desktop and mobile apps signed in for 30 days. */
longLivedForApps?: boolean;
}
@@ -93,9 +92,7 @@ export async function issueSession(
rememberMe: options.rememberMe,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
oidcSub: options.oidcSub ?? null,
oidcSid: options.oidcSid ?? null,
ssoProviderId: options.ssoProviderId ?? null,
externalSession: options.externalSession ?? null,
});
if (options.rateLimitUsername) {
@@ -141,8 +138,6 @@ export async function issueSession(
username: user.username,
userId: user.id,
is_external: isExternalAccount(user),
// 2.8 name, kept until 26.10.0.
is_oidc: !!user.isOidc,
// Any second factor; the name is what 2.8 clients read.
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
user.id,
+13 -11
View File
@@ -2,19 +2,23 @@ import type { PluginVerifiedIdentity } from "@termix/plugin-sdk/backend";
export { LoginMethodError } from "@termix/plugin-sdk/backend";
/** The logout claims a login carried, stored as sessions.external_session_ref. */
export interface ExternalSessionRef {
providerId?: number | null;
sub?: string | null;
sid?: string | null;
}
/** Extra fields core's own methods may set. Plugins never see these. */
interface CoreIdentityExtras {
/**
* The identifier string the user row carried before user_external_identities
* existed ("ldap:<provider>:<id>", "github:<provider>:<id>" or a bare OIDC
* subject). Found users get their identity row linked lazily; new users keep
* the column filled so older code that reads it still works.
* The identifier a 2.8 install kept for the user ("ldap:<provider>:<id>",
* "github:<provider>:<id>" or a bare OIDC subject). Only checked against
* the allowed-users list.
*/
legacyIdentifier?: string;
/** SSO provider row the session came from, for back-channel logout. */
ssoProviderId?: number | null;
oidcSub?: string | null;
oidcSid?: string | null;
/** Kept on the session for an identity provider's back-channel logout. */
externalSession?: ExternalSessionRef | null;
/** Provider group to role mapping, applied on every login. */
roleSync?: { desired: string[]; managed: string[] };
/** Shown when the user's data key cannot be unlocked by this method. */
@@ -30,8 +34,6 @@ export interface PendingLogin {
userId: string;
methodId: string;
rememberMe: boolean;
ssoProviderId?: number | null;
oidcSub?: string | null;
oidcSid?: string | null;
externalSession?: ExternalSessionRef | null;
createdAt: number;
}
+3 -11
View File
@@ -65,15 +65,7 @@ export async function runCoreBootMigrations(): Promise<void> {
await import("./utils/crypto-migration/private-shared-ssh-auth-migration.js");
await runPrivateSharedSshAuthMigration();
const { runExternalIdentityMigration } =
await import("./upgrade/external-identity-migration.js");
await runExternalIdentityMigration();
const { runHostStatusConfigMigration } =
await import("./utils/crypto-migration/host-status-config-migration.js");
await runHostStatusConfigMigration();
const { runSshOptionsMigration } =
await import("./utils/crypto-migration/ssh-options-migration.js");
await runSshOptionsMigration();
const { runExternalAutoProvisionMigration } =
await import("./upgrade/external-auto-provision-migration.js");
await runExternalAutoProvisionMigration();
}
+13 -39
View File
@@ -630,15 +630,15 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
return res.status(404).json({ error: "User not found" });
}
const isOidcUser = !!user.isOidc;
const isExternalUser = !!user.isExternal;
if (!DataCrypto.getUserDataKey(userId)) {
if (isOidcUser) {
const oidcUnlocked = await authManager.authenticateExternalUser(
if (isExternalUser) {
const externalUnlocked = await authManager.authenticateExternalUser(
userId,
deviceInfo.type,
);
if (!oidcUnlocked) {
if (!externalUnlocked) {
return res.status(403).json({
error: "Failed to unlock user data with SSO credentials",
});
@@ -699,17 +699,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_oidc INTEGER NOT NULL DEFAULT 0,
oidc_identifier TEXT,
client_id TEXT,
client_secret TEXT,
issuer_url TEXT,
authorization_url TEXT,
token_url TEXT,
identifier_path TEXT,
name_path TEXT,
scopes TEXT DEFAULT 'openid email profile'
is_admin INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE settings (
@@ -740,9 +730,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
jump_hosts TEXT,
status_check_enabled INTEGER NOT NULL DEFAULT 1,
status_check_interval INTEGER,
terminal_config TEXT,
ssh_options TEXT,
quick_actions TEXT,
notes TEXT,
use_socks5 INTEGER,
socks5_host TEXT,
@@ -805,31 +793,21 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
const userRecord = user;
const insertUser = exportDb.prepare(`
INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, username, password_hash, is_admin)
VALUES (?, ?, ?, ?)
`);
insertUser.run(
userRecord.id,
userRecord.username,
"[EXPORTED_USER_NO_PASSWORD]",
userRecord.isAdmin ? 1 : 0,
userRecord.isOidc ? 1 : 0,
userRecord.oidcIdentifier || null,
userRecord.clientId || null,
userRecord.clientSecret || null,
userRecord.issuerUrl || null,
userRecord.authorizationUrl || null,
userRecord.tokenUrl || null,
userRecord.identifierPath || null,
userRecord.namePath || null,
userRecord.scopes || null,
);
const sshHosts =
await createCurrentHostRepository().listDecryptedByUserId(userId);
const insertHost = exportDb.prepare(`
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, credential_id, override_credential_username, jump_hosts, status_check_enabled, status_check_interval, terminal_config, ssh_options, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, credential_id, override_credential_username, jump_hosts, status_check_enabled, status_check_interval, ssh_options, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const decrypted of sshHosts) {
@@ -856,9 +834,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
decrypted.jumpHosts || null,
decrypted.statusCheckEnabled === false ? 0 : 1,
decrypted.statusCheckInterval ?? null,
decrypted.terminalConfig || null,
decrypted.sshOptions || null,
decrypted.quickActions || null,
decrypted.notes || null,
decrypted.useSocks5 ? 1 : 0,
decrypted.socks5Host || null,
@@ -1074,15 +1050,15 @@ app.post(
return res.status(404).json({ error: "User not found" });
}
const isOidcUser = !!userRecord.isOidc;
const isExternalUser = !!userRecord.isExternal;
if (!DataCrypto.getUserDataKey(userId)) {
if (isOidcUser) {
const oidcUnlocked = await authManager.authenticateExternalUser(
if (isExternalUser) {
const externalUnlocked = await authManager.authenticateExternalUser(
userId,
deviceInfo.type,
);
if (!oidcUnlocked) {
if (!externalUnlocked) {
return res.status(403).json({
error: "Failed to unlock user data with SSO credentials",
});
@@ -1198,7 +1174,6 @@ app.post(
),
jumpHosts: host.jump_hosts,
...legacyStatusCheck(host),
terminalConfig: host.terminal_config,
// Exports from before 2.9.0 carry these in terminal_config.
sshOptions:
host.ssh_options ??
@@ -1206,7 +1181,6 @@ app.post(
terminalConfig: host.terminal_config,
}) ??
null,
quickActions: host.quick_actions,
notes: host.notes,
useSocks5: Boolean(host.use_socks5),
socks5Host: host.socks5_host,
+267 -120
View File
@@ -24,6 +24,16 @@ import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js";
import { SYSTEM_ROLE_DEFAULTS } from "../../utils/permission-catalog.js";
import { connectRemoteDatabase } from "./connect.js";
import { runRemoteMigrations } from "./migrate.js";
import { assertNotPre29Database } from "./upgrade-guard.js";
import {
LEGACY_USERNAME_PLACEHOLDER_UPDATES,
legacySudoMoves,
RETIRED_COLUMNS,
RETIRED_SETTINGS,
REMOTE_RETIRED_COLUMNS,
type LegacySudoRow,
} from "./retired-columns.js";
import { sql, type SQL } from "drizzle-orm";
import type { PortableDatabase } from "../repositories/database-context.js";
const dataDir = process.env.DATA_DIR || "./db/data";
@@ -217,22 +227,21 @@ async function initializeCompleteDatabase(): Promise<void> {
db = drizzle(sqlite, { schema });
await assertNotPre29Database(
(name) =>
!!sqlite
.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
)
.get(name),
);
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_oidc INTEGER NOT NULL DEFAULT 0,
oidc_identifier TEXT,
client_id TEXT,
client_secret TEXT,
issuer_url TEXT,
authorization_url TEXT,
token_url TEXT,
identifier_path TEXT,
name_path TEXT,
scopes TEXT DEFAULT 'openid email profile',
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
donation_modal_dismissed INTEGER NOT NULL DEFAULT 0
);
@@ -308,7 +317,6 @@ async function initializeCompleteDatabase(): Promise<void> {
force_keyboard_interactive TEXT,
status_check_enabled INTEGER NOT NULL DEFAULT 1,
status_check_interval INTEGER,
terminal_config TEXT,
notes TEXT,
use_socks5 INTEGER,
socks5_host TEXT,
@@ -357,12 +365,10 @@ async function initializeCompleteDatabase(): Promise<void> {
name TEXT NOT NULL,
color TEXT,
icon TEXT,
credential_id INTEGER,
sort_order INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS recent_activity (
@@ -672,6 +678,14 @@ async function initializeCompleteDatabase(): Promise<void> {
});
}
// 2.9.1 added this fix, so a 2.9.0 install has not run it yet. It reads the
// column migrateSchema drops.
if (columnNames("users").includes("oidc_identifier")) {
const { runSsoLegacyIdentityMigration } =
await import("../../upgrade/sso-legacy-identity-migration.js");
await runSsoLegacyIdentityMigration();
}
migrateSchema();
vacuumIfFreelistBloated();
@@ -782,6 +796,135 @@ const relaxPluginGrantGrantedBy = () => {
}
};
const columnNames = (table: string): string[] =>
(
sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{
name: string;
}>
).map((col) => col.name);
/**
* ssh_folders.credential_id became the folder's auth default. It carries a
* foreign key, which ALTER TABLE DROP COLUMN refuses, so the table is rebuilt.
*/
const dropFolderCredentialColumn = () => {
const columns = columnNames("ssh_folders");
if (!columns.includes("credential_id")) return;
const kept = [
"id",
"user_id",
"name",
"color",
"icon",
"sort_order",
"sync_id",
"local_only",
"created_at",
"updated_at",
].filter((name) => columns.includes(name));
const list = kept.map((name) => `"${name}"`).join(", ");
sqlite.exec("PRAGMA foreign_keys = OFF");
try {
sqlite.exec(`
BEGIN;
CREATE TABLE ssh_folders_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
icon TEXT,
sort_order INTEGER,
sync_id TEXT,
local_only INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
INSERT INTO ssh_folders_new (${list}) SELECT ${list} FROM ssh_folders;
DROP TABLE ssh_folders;
ALTER TABLE ssh_folders_new RENAME TO ssh_folders;
CREATE UNIQUE INDEX IF NOT EXISTS idx_ssh_folders_sync_id ON ssh_folders(sync_id);
COMMIT;
`);
} catch (error) {
if (sqlite.inTransaction) sqlite.exec("ROLLBACK");
databaseLogger.warn("Failed to drop ssh_folders.credential_id", {
operation: "schema_migration",
error: getErrorMessage(error),
});
} finally {
sqlite.exec("PRAGMA foreign_keys = ON");
}
};
const moveLegacySudoPasswords = () => {
if (!columnNames("ssh_data").includes("terminal_config")) return;
try {
const rows = sqlite
.prepare(
"SELECT id, terminal_config, sudo_password FROM ssh_data WHERE terminal_config LIKE '%sudoPassword%'",
)
.all() as LegacySudoRow[];
const update = sqlite.prepare(
"UPDATE ssh_data SET sudo_password = ? WHERE id = ?",
);
for (const move of legacySudoMoves(rows)) {
update.run(move.sudoPassword, move.id);
}
} catch (error) {
databaseLogger.warn("Failed to move legacy sudo passwords", {
operation: "schema_migration",
error: getErrorMessage(error),
});
}
};
const dropRetiredColumns = () => {
moveLegacySudoPasswords();
for (const [table, retired] of Object.entries(RETIRED_COLUMNS)) {
const columns = new Set(columnNames(table));
for (const column of retired) {
if (!columns.has(column)) continue;
try {
sqlite.exec(`ALTER TABLE ${table} DROP COLUMN "${column}"`);
} catch (error) {
databaseLogger.warn(`Failed to drop ${table}.${column}`, {
operation: "schema_migration",
error: getErrorMessage(error),
});
}
}
}
dropFolderCredentialColumn();
// 2.8 spelled $external.username as $oidc.preferred_username.
for (const statement of LEGACY_USERNAME_PLACEHOLDER_UPDATES) {
try {
sqlite.exec(statement);
} catch (error) {
databaseLogger.warn("Failed to rename a legacy username placeholder", {
operation: "schema_migration",
error: getErrorMessage(error),
});
}
}
try {
sqlite
.prepare(
`DELETE FROM settings WHERE key IN (${RETIRED_SETTINGS.map(() => "?").join(", ")})`,
)
.run(...RETIRED_SETTINGS);
} catch (error) {
databaseLogger.warn("Failed to delete retired settings", {
operation: "schema_migration",
error: getErrorMessage(error),
});
}
};
const migrateSchema = () => {
repairSnippetsNoteColumn(sqlite);
addColumnIfNotExists("user_preferences", "theme", "TEXT");
@@ -789,7 +932,6 @@ const migrateSchema = () => {
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
addColumnIfNotExists("user_preferences", "language", "TEXT");
addColumnIfNotExists("user_preferences", "storage_mode", "TEXT");
addColumnIfNotExists("user_preferences", "command_autocomplete", "INTEGER");
addColumnIfNotExists("user_preferences", "command_palette_enabled", "INTEGER");
addColumnIfNotExists("user_preferences", "show_host_tags", "INTEGER");
addColumnIfNotExists("user_preferences", "host_tray_on_click", "INTEGER");
@@ -805,31 +947,15 @@ const migrateSchema = () => {
"INTEGER",
);
addColumnIfNotExists("user_preferences", "folders_collapsed", "INTEGER");
addColumnIfNotExists("user_preferences", "confirm_snippet_execution", "INTEGER");
addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER");
addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER");
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
addColumnIfNotExists("user_preferences", "custom_themes", "TEXT");
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
addColumnIfNotExists("user_preferences", "terminal_defaults", "TEXT");
addColumnIfNotExists("user_preferences", "terminal_macros", "TEXT");
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("users", "oidc_identifier", "TEXT");
addColumnIfNotExists("users", "client_id", "TEXT");
addColumnIfNotExists("users", "client_secret", "TEXT");
addColumnIfNotExists("users", "issuer_url", "TEXT");
addColumnIfNotExists("users", "authorization_url", "TEXT");
addColumnIfNotExists("users", "token_url", "TEXT");
addColumnIfNotExists("users", "identifier_path", "TEXT");
addColumnIfNotExists("users", "name_path", "TEXT");
addColumnIfNotExists("users", "scopes", "TEXT");
const hadRegisteredAtColumn = (() => {
try {
sqlite.prepare(`SELECT "registered_at" FROM users LIMIT 1`).get();
@@ -878,9 +1004,7 @@ const migrateSchema = () => {
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("sessions", "oidc_sub", "TEXT");
addColumnIfNotExists("sessions", "oidc_sid", "TEXT");
addColumnIfNotExists("sessions", "sso_provider_id", "INTEGER");
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
addColumnIfNotExists("ssh_data", "name", "TEXT");
addColumnIfNotExists("ssh_data", "folder", "TEXT");
@@ -926,8 +1050,6 @@ const migrateSchema = () => {
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists("ssh_data", "status_check_interval", "INTEGER");
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
addColumnIfNotExists("ssh_data", "domain", "TEXT");
@@ -1145,19 +1267,6 @@ const migrateSchema = () => {
});
}
try {
sqlite.prepare("SELECT credential_id FROM ssh_folders LIMIT 1").get();
} catch {
try {
sqlite.exec("ALTER TABLE ssh_folders ADD COLUMN credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL");
} catch (alterError) {
databaseLogger.warn("Failed to add credential_id column to ssh_folders", {
operation: "schema_migration",
error: alterError,
});
}
}
try {
sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get();
} catch {
@@ -1177,7 +1286,6 @@ const migrateSchema = () => {
{ column: "override_credential_username", sql: "ALTER TABLE ssh_data ADD COLUMN override_credential_username INTEGER" },
{ column: "share_ssh_auth", sql: "ALTER TABLE ssh_data ADD COLUMN share_ssh_auth INTEGER NOT NULL DEFAULT 0" },
{ column: "jump_hosts", sql: "ALTER TABLE ssh_data ADD COLUMN jump_hosts TEXT" },
{ column: "quick_actions", sql: "ALTER TABLE ssh_data ADD COLUMN quick_actions TEXT" },
{ column: "socks5_proxy_chain", sql: "ALTER TABLE ssh_data ADD COLUMN socks5_proxy_chain TEXT" },
{ column: "host_key_fingerprint", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_fingerprint TEXT" },
{ column: "host_key_type", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_type TEXT" },
@@ -1459,76 +1567,6 @@ const migrateSchema = () => {
});
}
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
try {
const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
cid: number;
name: string;
type: string;
notnull: number;
dflt_value: string | null;
pk: number;
}>;
const legacyNotNullColumns = new Set([
"client_id",
"client_secret",
"issuer_url",
"authorization_url",
"token_url",
"identifier_path",
"name_path",
"scopes",
]);
const hasStaleNotNull = usersTableInfo.some(
(col) => legacyNotNullColumns.has(col.name) && col.notnull === 1,
);
if (hasStaleNotNull) {
const tempTableName = "users_temp_migration";
const columnDefs = usersTableInfo
.map((col) => {
const parts = [`"${col.name}"`, col.type || "TEXT"];
if (col.pk === 1) parts.push("PRIMARY KEY");
if (col.notnull === 1 && !legacyNotNullColumns.has(col.name)) {
parts.push("NOT NULL");
}
if (col.dflt_value !== null) {
parts.push(`DEFAULT ${col.dflt_value}`);
}
return parts.join(" ");
})
.join(",\n ");
const allColumns = usersTableInfo.map((col) => `"${col.name}"`).join(", ");
sqlite.exec(`PRAGMA foreign_keys = OFF`);
sqlite.exec(`
CREATE TABLE ${tempTableName} (
${columnDefs}
);
INSERT INTO ${tempTableName} SELECT ${allColumns} FROM users;
DROP TABLE users;
ALTER TABLE ${tempTableName} RENAME TO users;
`);
sqlite.exec(`PRAGMA foreign_keys = ON`);
databaseLogger.info(
"Successfully migrated users table to remove legacy OIDC NOT NULL constraints",
{
operation: "schema_migration_users_oidc_nullable",
},
);
}
} catch (migrationError) {
databaseLogger.warn("Failed to migrate users table legacy OIDC columns", {
operation: "schema_migration",
error: migrationError,
});
}
// --- alerts begin ---
// alert_rules, alert_rule_channels and alert_firings were only ever created
// here, never declared in schema.ts, so they never existed on Postgres or
@@ -1686,6 +1724,8 @@ const migrateSchema = () => {
// they referenced, which defeats the point of keeping them.
migrateAuditRetention(sqlite);
dropRetiredColumns();
// Runs last so every table and column added above already exists.
createPerformanceIndexes(sqlite);
@@ -1832,6 +1872,94 @@ async function initializeDatabase(): Promise<void> {
* synchronously, which better-sqlite3 allows and no remote driver does, so the
* table is loaded once here before anything asks for it.
*/
function resultRows<T>(result: unknown): T[] {
// node-postgres returns { rows }, mysql2 returns [rows, fields].
if (Array.isArray(result)) return result[0] as T[];
return ((result as { rows?: unknown[] }).rows ?? []) as T[];
}
async function moveRemoteLegacySudoPasswords(remote: {
execute: (query: SQL) => Promise<unknown>;
}): Promise<void> {
let rows: LegacySudoRow[];
try {
rows = resultRows<LegacySudoRow>(
await remote.execute(
sql`SELECT id, terminal_config, sudo_password FROM ssh_data WHERE terminal_config LIKE '%sudoPassword%'`,
),
);
} catch {
// A database created after the column left never had it.
return;
}
for (const move of legacySudoMoves(rows)) {
await remote.execute(
sql`UPDATE ssh_data SET sudo_password = ${move.sudoPassword} WHERE id = ${move.id}`,
);
}
}
/**
* The columns 2.9.0 left in place without a drizzle migration, so drizzle no
* longer knows them. Dropped by hand, only where they still exist.
*/
async function dropRemoteRetired(
dialect: Exclude<DatabaseDialect, "sqlite">,
remote: { execute: (query: SQL) => Promise<unknown> },
): Promise<void> {
const tables = Object.keys(REMOTE_RETIRED_COLUMNS);
const schema =
dialect === "postgres" ? sql`current_schema()` : sql`DATABASE()`;
let present: Array<{ table_name: string; column_name: string }> = [];
try {
present = resultRows(
await remote.execute(
sql`SELECT table_name AS table_name, column_name AS column_name FROM information_schema.columns WHERE table_schema = ${schema} AND table_name IN (${sql.join(
tables.map((table) => sql`${table}`),
sql`, `,
)})`,
),
);
} catch (error) {
databaseLogger.warn("Could not list retired columns", {
operation: "schema_migration",
error: getErrorMessage(error),
});
return;
}
const existing = new Set(
present.map((row) => `${row.table_name}.${row.column_name}`),
);
for (const [table, columns] of Object.entries(REMOTE_RETIRED_COLUMNS)) {
for (const column of columns) {
if (!existing.has(`${table}.${column}`)) continue;
try {
await remote.execute(
sql`ALTER TABLE ${sql.identifier(table)} DROP COLUMN ${sql.identifier(column)}`,
);
} catch (error) {
databaseLogger.warn(`Failed to drop ${table}.${column}`, {
operation: "schema_migration",
error: getErrorMessage(error),
});
}
}
}
try {
await remote.execute(
sql`DELETE FROM settings WHERE ${sql.identifier("key")} IN (${sql.join(
RETIRED_SETTINGS.map((key) => sql`${key}`),
sql`, `,
)})`,
);
} catch (error) {
databaseLogger.warn("Failed to delete retired settings", {
operation: "schema_migration",
error: getErrorMessage(error),
});
}
}
async function initializeRemoteDatabase(
dialect: Exclude<DatabaseDialect, "sqlite">,
): Promise<void> {
@@ -1841,7 +1969,26 @@ async function initializeRemoteDatabase(
});
db = await connectRemoteDatabase(dialect);
const remote = db as unknown as { execute: (query: SQL) => Promise<unknown> };
await assertNotPre29Database(async (name) => {
try {
await remote.execute(sql`SELECT 1 FROM ${sql.identifier(name)} WHERE 1 = 0`);
return true;
} catch {
return false;
}
});
await moveRemoteLegacySudoPasswords(remote);
try {
await remote.execute(sql`SELECT oidc_identifier FROM users WHERE 1 = 0`);
const { runSsoLegacyIdentityMigration } =
await import("../../upgrade/sso-legacy-identity-migration.js");
await runSsoLegacyIdentityMigration();
} catch {
// The column is gone: the move ran before it was dropped.
}
await runRemoteMigrations(dialect, db);
await dropRemoteRetired(dialect, remote);
// Imported here rather than at the top: factory.ts imports getDb from this
// module, and a static import would close the cycle at module-load time.
+177
View File
@@ -0,0 +1,177 @@
/**
* What 26.10.0 removes that 2.9.0 kept so a downgrade to 2.8 lost nothing.
* Every 2.9 boot copied it into plugin settings, and the upgrade guard only
* opens a database 2.9 has seen.
*
* Kept on purpose: columns a move only reads once the owner's data key opens
* at a password login (users.totp_*, the rdp, vnc and telnet logins and
* domain), so a user who never signed in to 2.9 still gets them, and
* ssh_data.vault_profile_id, whose foreign key SQLite cannot drop in place.
*/
/** Columns drizzle never knew were removed, dropped by hand on every engine. */
export const REMOTE_RETIRED_COLUMNS: Record<string, string[]> = {
ssh_data: [
"allow_session_sharing",
"autostart_key",
"autostart_key_password",
"autostart_password",
"default_path",
"docker_config",
"enable_ai_assistant",
"enable_command_history",
"enable_docker",
"enable_file_manager",
"enable_proxmox",
"enable_proxmox_stats",
"enable_rdp",
"enable_session_logging",
"enable_telnet",
"enable_terminal",
"enable_terminal_toolbar",
"enable_tmux_monitor",
"enable_tunnel",
"enable_vnc",
"enable_web_ui",
"guacamole_config",
"ignore_cert",
"mac_address",
"proxmox_config",
"proxmox_stats_config",
"rdp_ignore_cert",
"rdp_port",
"rdp_security",
"scp_legacy",
"security",
"show_docker_in_sidebar",
"show_file_manager_in_sidebar",
"show_server_stats_in_sidebar",
"show_terminal_in_sidebar",
"show_tunnel_in_sidebar",
"stats_config",
"telnet_port",
"tunnel_connections",
"use_warpgate",
"vnc_port",
"web_ui_config",
"wol_broadcast_address",
],
user_preferences: [
"ai_assistant_enabled",
"ai_read_only_commands",
"rdp_defaults",
],
};
/** Everything SQLite drops; the first part the drizzle migrations drop too. */
export const RETIRED_COLUMNS: Record<string, string[]> = {
users: [
"is_oidc",
"oidc_identifier",
"sso_provider_id",
"client_id",
"client_secret",
"issuer_url",
"authorization_url",
"token_url",
"identifier_path",
"name_path",
"scopes",
],
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
user_preferences: [
"command_autocomplete",
"terminal_defaults",
"custom_themes",
"confirm_snippet_execution",
"terminal_macros",
...REMOTE_RETIRED_COLUMNS.user_preferences,
],
ssh_data: [
"terminal_config",
"quick_actions",
...REMOTE_RETIRED_COLUMNS.ssh_data,
],
};
/** Settings rows the plugins copied, and the markers of the copies. */
export const RETIRED_SETTINGS = [
"host_defaults",
"analytics_enabled",
"analytics_instance_id",
"terminal_session_timeout_minutes",
"terminal_session_persistence_enabled",
"command_history_enabled",
"touch_input_settings",
"terminal_image_storage_mode",
"terminal_image_local_dir",
"terminal_image_host_path",
"terminal_image_ttl_ms",
"terminal_image_max_count",
"terminal_image_max_storage_bytes",
"session_sharing_globally_enabled",
"guac_enabled",
"guac_url",
"step_ca_url",
"step_ca_fingerprint",
"step_ca_provisioner",
"step_ca_private_endpoint_allowlist",
"acme_ssl_settings",
"global_metrics_interval",
"metrics_history_retention_days",
"tailscale_api_base_url",
"tailscale_api_key",
"ai_globally_enabled",
"ai_private_endpoint_allowlist",
"oidc_silent_login_default",
"host_status_config_migrated",
];
/**
* 2.8 editors kept a host's sudo password inside terminal_config, and 2.9
* still read it from there. Before the column goes it is copied into
* sudo_password, as plaintext, which the field encryption reads as it is
* and encrypts on the next save.
*/
export function legacySudoPassword(terminalConfig: unknown): string | null {
let parsed = terminalConfig;
try {
while (typeof parsed === "string") parsed = JSON.parse(parsed);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const value = (parsed as { sudoPassword?: unknown }).sudoPassword;
return typeof value === "string" && value ? value : null;
}
export interface LegacySudoRow {
id: number;
terminal_config: unknown;
sudo_password: unknown;
}
/** The hosts whose sudo password still only lives in terminal_config. */
export function legacySudoMoves(
rows: LegacySudoRow[],
): Array<{ id: number; sudoPassword: string }> {
return rows.flatMap((row) => {
if (row.sudo_password) return [];
const sudoPassword = legacySudoPassword(row.terminal_config);
return sudoPassword ? [{ id: Number(row.id), sudoPassword }] : [];
});
}
/**
* Rewrites the 2.8 `$oidc.preferred_username` placeholder to
* `$external.username` wherever a host username is saved. Plain SQL that
* runs the same on SQLite, Postgres and MySQL; the drizzle migrations carry
* a copy.
*/
export const LEGACY_USERNAME_PLACEHOLDER_UPDATES = [
"UPDATE ssh_data SET username = REPLACE(username, '$oidc.preferred_username', '$external.username') WHERE username LIKE '%$oidc.preferred_username%'",
"UPDATE ssh_credentials SET username = REPLACE(username, '$oidc.preferred_username', '$external.username') WHERE username LIKE '%$oidc.preferred_username%'",
"UPDATE host_defaults SET value = REPLACE(value, '$oidc.preferred_username', '$external.username') WHERE value LIKE '%$oidc.preferred_username%'",
];
+2 -26
View File
@@ -26,19 +26,6 @@ export const users = mysqlTable("users", {
username: text("username").notNull(),
passwordHash: text("password_hash").notNull(),
isAdmin: boolean("is_admin").notNull().default(false),
isOidc: boolean("is_oidc").notNull().default(false),
oidcIdentifier: text("oidc_identifier"),
ssoProviderId: int("sso_provider_id"),
clientId: text("client_id"),
clientSecret: text("client_secret"),
issuerUrl: text("issuer_url"),
authorizationUrl: text("authorization_url"),
tokenUrl: text("token_url"),
identifierPath: text("identifier_path"),
namePath: text("name_path"),
scopes: text().default("openid email profile"),
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
donationModalDismissed: boolean("donation_modal_dismissed")
.notNull()
@@ -60,9 +47,8 @@ export const sessions = mysqlTable(
jwtToken: text("jwt_token").notNull(),
deviceType: text("device_type").notNull(),
deviceInfo: text("device_info").notNull(),
oidcSub: text("oidc_sub"),
oidcSid: text("oidc_sid"),
ssoProviderId: int("sso_provider_id"),
// JSON of the identity provider's logout claims, for back-channel logout.
externalSessionRef: text("external_session_ref"),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
@@ -196,11 +182,9 @@ export const hosts = mysqlTable(
.notNull()
.default(true),
statusCheckInterval: int("status_check_interval"),
terminalConfig: text("terminal_config"),
// SSH connection options core's connect pipeline reads (keepalive,
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
quickActions: text("quick_actions"),
notes: text("notes"),
enableSsh: boolean("enable_ssh").notNull().default(true),
@@ -377,9 +361,6 @@ export const sshFolders = mysqlTable(
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
credentialId: int("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
// Manual drag-to-reorder position among sibling folders. Null falls back
// to name sort, same convention as hosts.sortOrder.
sortOrder: int("sort_order"),
@@ -712,7 +693,6 @@ export const userPreferences = mysqlTable("user_preferences", {
accentColor: text("accent_color"),
language: text("language"),
storageMode: text("storage_mode"),
commandAutocomplete: boolean("command_autocomplete"),
commandPaletteEnabled: boolean("command_palette_enabled"),
showHostTags: boolean("show_host_tags"),
hostTrayOnClick: boolean("host_tray_on_click"),
@@ -720,16 +700,12 @@ export const userPreferences = mysqlTable("user_preferences", {
expandAppRailOnHover: boolean("expand_app_rail_on_hover"),
showPinAppRailButton: boolean("show_pin_app_rail_button"),
foldersCollapsed: boolean("folders_collapsed"),
confirmSnippetExecution: boolean("confirm_snippet_execution"),
disableUpdateCheck: boolean("disable_update_check"),
confirmTabClose: boolean("confirm_tab_close"),
hiddenRailTabs: text("hidden_rail_tabs"),
compactHostView: boolean("compact_host_view"),
statusColorScheme: text("status_color_scheme"),
customThemes: text("custom_themes"),
customKeybindings: text("custom_keybindings"),
terminalDefaults: text("terminal_defaults"),
terminalMacros: text("terminal_macros"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
+2 -26
View File
@@ -26,19 +26,6 @@ export const users = pgTable("users", {
username: text("username").notNull(),
passwordHash: text("password_hash").notNull(),
isAdmin: boolean("is_admin").notNull().default(false),
isOidc: boolean("is_oidc").notNull().default(false),
oidcIdentifier: text("oidc_identifier"),
ssoProviderId: integer("sso_provider_id"),
clientId: text("client_id"),
clientSecret: text("client_secret"),
issuerUrl: text("issuer_url"),
authorizationUrl: text("authorization_url"),
tokenUrl: text("token_url"),
identifierPath: text("identifier_path"),
namePath: text("name_path"),
scopes: text().default("openid email profile"),
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
donationModalDismissed: boolean("donation_modal_dismissed")
.notNull()
@@ -60,9 +47,8 @@ export const sessions = pgTable(
jwtToken: text("jwt_token").notNull(),
deviceType: text("device_type").notNull(),
deviceInfo: text("device_info").notNull(),
oidcSub: text("oidc_sub"),
oidcSid: text("oidc_sid"),
ssoProviderId: integer("sso_provider_id"),
// JSON of the identity provider's logout claims, for back-channel logout.
externalSessionRef: text("external_session_ref"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -196,11 +182,9 @@ export const hosts = pgTable(
.notNull()
.default(true),
statusCheckInterval: integer("status_check_interval"),
terminalConfig: text("terminal_config"),
// SSH connection options core's connect pipeline reads (keepalive,
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
quickActions: text("quick_actions"),
notes: text("notes"),
enableSsh: boolean("enable_ssh").notNull().default(true),
@@ -377,9 +361,6 @@ export const sshFolders = pgTable(
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
credentialId: integer("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
// Manual drag-to-reorder position among sibling folders. Null falls back
// to name sort, same convention as hosts.sortOrder.
sortOrder: integer("sort_order"),
@@ -712,7 +693,6 @@ export const userPreferences = pgTable("user_preferences", {
accentColor: text("accent_color"),
language: text("language"),
storageMode: text("storage_mode"),
commandAutocomplete: boolean("command_autocomplete"),
commandPaletteEnabled: boolean("command_palette_enabled"),
showHostTags: boolean("show_host_tags"),
hostTrayOnClick: boolean("host_tray_on_click"),
@@ -720,16 +700,12 @@ export const userPreferences = pgTable("user_preferences", {
expandAppRailOnHover: boolean("expand_app_rail_on_hover"),
showPinAppRailButton: boolean("show_pin_app_rail_button"),
foldersCollapsed: boolean("folders_collapsed"),
confirmSnippetExecution: boolean("confirm_snippet_execution"),
disableUpdateCheck: boolean("disable_update_check"),
confirmTabClose: boolean("confirm_tab_close"),
hiddenRailTabs: text("hidden_rail_tabs"),
compactHostView: boolean("compact_host_view"),
statusColorScheme: text("status_color_scheme"),
customThemes: text("custom_themes"),
customKeybindings: text("custom_keybindings"),
terminalDefaults: text("terminal_defaults"),
terminalMacros: text("terminal_macros"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
+2 -26
View File
@@ -14,19 +14,6 @@ export const users = sqliteTable("users", {
username: text("username").notNull(),
passwordHash: text("password_hash").notNull(),
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
isOidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
oidcIdentifier: text("oidc_identifier"),
ssoProviderId: integer("sso_provider_id"),
clientId: text("client_id"),
clientSecret: text("client_secret"),
issuerUrl: text("issuer_url"),
authorizationUrl: text("authorization_url"),
tokenUrl: text("token_url"),
identifierPath: text("identifier_path"),
namePath: text("name_path"),
scopes: text().default("openid email profile"),
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
donationModalDismissed: integer("donation_modal_dismissed", {
mode: "boolean",
@@ -50,9 +37,8 @@ export const sessions = sqliteTable(
jwtToken: text("jwt_token").notNull(),
deviceType: text("device_type").notNull(),
deviceInfo: text("device_info").notNull(),
oidcSub: text("oidc_sub"),
oidcSid: text("oidc_sid"),
ssoProviderId: integer("sso_provider_id"),
// JSON of the identity provider's logout claims, for back-channel logout.
externalSessionRef: text("external_session_ref"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -188,11 +174,9 @@ export const hosts = sqliteTable(
.notNull()
.default(true),
statusCheckInterval: integer("status_check_interval"),
terminalConfig: text("terminal_config"),
// SSH connection options core's connect pipeline reads (keepalive,
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
quickActions: text("quick_actions"),
notes: text("notes"),
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
@@ -369,9 +353,6 @@ export const sshFolders = sqliteTable(
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
credentialId: integer("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
// Manual drag-to-reorder position among sibling folders. Null falls back
// to name sort, same convention as hosts.sortOrder.
sortOrder: integer("sort_order"),
@@ -704,7 +685,6 @@ export const userPreferences = sqliteTable("user_preferences", {
accentColor: text("accent_color"),
language: text("language"),
storageMode: text("storage_mode"),
commandAutocomplete: integer("command_autocomplete", { mode: "boolean" }),
commandPaletteEnabled: integer("command_palette_enabled", { mode: "boolean" }),
showHostTags: integer("show_host_tags", { mode: "boolean" }),
hostTrayOnClick: integer("host_tray_on_click", { mode: "boolean" }),
@@ -716,16 +696,12 @@ export const userPreferences = sqliteTable("user_preferences", {
mode: "boolean",
}),
foldersCollapsed: integer("folders_collapsed", { mode: "boolean" }),
confirmSnippetExecution: integer("confirm_snippet_execution", { mode: "boolean" }),
disableUpdateCheck: integer("disable_update_check", { mode: "boolean" }),
confirmTabClose: integer("confirm_tab_close", { mode: "boolean" }),
hiddenRailTabs: text("hidden_rail_tabs"),
compactHostView: integer("compact_host_view", { mode: "boolean" }),
statusColorScheme: text("status_color_scheme"),
customThemes: text("custom_themes"),
customKeybindings: text("custom_keybindings"),
terminalDefaults: text("terminal_defaults"),
terminalMacros: text("terminal_macros"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
+17
View File
@@ -0,0 +1,17 @@
/**
* 26.10.0 dropped the columns 2.9.0 kept for a downgrade to 2.8, and the
* one-time 2.8 moves that copied them into plugins went with them. A 2.8
* database has to pass through 2.9 first, or that data is lost.
*
* A 2.9 database has the plugins table; a 2.8 one has hosts but no plugins.
*/
export const PRE_29_DATABASE_ERROR =
"This database is from Termix 2.8 or older. Upgrade to Termix 2.9.1 and start it once so it can move your data, then upgrade to this version.";
export async function assertNotPre29Database(
hasTable: (name: string) => boolean | Promise<boolean>,
): Promise<void> {
if ((await hasTable("ssh_data")) && !(await hasTable("plugins"))) {
throw new Error(PRE_29_DATABASE_ERROR);
}
}
@@ -112,7 +112,6 @@ export class HostFolderRepository {
name: string,
color: string | null | undefined,
icon: string | null | undefined,
credentialId?: number | null,
now = new Date().toISOString(),
): Promise<{ folder: HostFolderRecord; created: boolean }> {
const existing = await this.findFolder(userId, name);
@@ -123,8 +122,6 @@ export class HostFolderRepository {
{
color,
icon,
credentialId:
credentialId === undefined ? existing.credentialId : credentialId,
updatedAt: now,
},
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
@@ -140,7 +137,6 @@ export class HostFolderRepository {
name,
color,
icon,
credentialId: credentialId ?? null,
createdAt: now,
updatedAt: now,
});
@@ -1,5 +1,5 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
import { hosts, sshCredentials } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
@@ -459,34 +459,6 @@ export class HostResolutionRepository {
return this.decryptOne("ssh_credentials", rows[0], decryptUserId);
}
/**
* Resolve the nearest assigned credential for a folder path, walking up
* through parent folders (e.g. "Switches / Floor1" falls back to
* "Switches" if the child folder has no credential of its own).
*/
async findFolderCredentialId(
userId: string,
folderPath: string,
): Promise<number | null> {
const segments = folderPath.split(" / ").filter(Boolean);
if (segments.length === 0) return null;
const paths = segments.map((_, i) => segments.slice(0, i + 1).join(" / "));
const rows = await this.context.drizzle
.select({ name: sshFolders.name, credentialId: sshFolders.credentialId })
.from(sshFolders)
.where(
and(eq(sshFolders.userId, userId), inArray(sshFolders.name, paths)),
);
const byName = new Map(rows.map((row) => [row.name, row.credentialId]));
for (let i = paths.length - 1; i >= 0; i--) {
const credentialId = byName.get(paths[i]);
if (credentialId) return credentialId;
}
return null;
}
private decryptOne<T extends Record<string, unknown>>(
tableName: "ssh_data" | "ssh_credentials",
record: T | undefined,
@@ -1,4 +1,4 @@
import { and, asc, eq, inArray, like, sql } from "drizzle-orm";
import { and, asc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm";
import { pluginSettings, users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import {
@@ -8,11 +8,22 @@ import {
} from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type UserRecord = typeof users.$inferSelect;
/** isExternal: the user has at least one external sign-in identity. */
export type UserRecord = typeof users.$inferSelect & { isExternal: boolean };
export type NewUserRecord = typeof users.$inferInsert;
export type UserUpdate = Partial<Omit<NewUserRecord, "id">>;
export type NewFirstLocalUserRecord = Omit<NewUserRecord, "isAdmin">;
// Qualified by hand: drizzle prints a column in a subquery without its table,
// which would bind users.id to the identity row's own id.
const userColumns = {
...getTableColumns(users),
isExternal:
sql<boolean>`EXISTS (SELECT 1 FROM ${sql.identifier("user_external_identities")} WHERE ${sql.identifier("user_external_identities")}.${sql.identifier("user_id")} = ${sql.identifier("users")}.${sql.identifier("id")})`.mapWith(
(value: unknown) => value === true || Number(value) === 1,
),
};
export class UserRepository {
constructor(
private readonly context: DatabaseContext,
@@ -20,7 +31,7 @@ export class UserRepository {
) {}
async listAll(): Promise<UserRecord[]> {
return this.context.drizzle.select().from(users);
return this.context.drizzle.select(userColumns).from(users);
}
/**
@@ -42,7 +53,7 @@ export class UserRepository {
const [rows, totalResult] = await Promise.all([
this.context.drizzle
.select()
.select(userColumns)
.from(users)
.where(where)
// Case-insensitive: a plain sort puts every capitalised name ahead of
@@ -61,7 +72,7 @@ export class UserRepository {
async findById(id: string): Promise<UserRecord | null> {
const rows = await this.context.drizzle
.select()
.select(userColumns)
.from(users)
.where(eq(users.id, id))
.limit(1);
@@ -71,7 +82,7 @@ export class UserRepository {
async findByUsername(username: string): Promise<UserRecord | null> {
const rows = await this.context.drizzle
.select()
.select(userColumns)
.from(users)
.where(eq(users.username, username))
.limit(1);
@@ -79,18 +90,6 @@ export class UserRepository {
return rows[0] ?? null;
}
async findByExternalIdentifier(
oidcIdentifier: string,
): Promise<UserRecord | null> {
const rows = await this.context.drizzle
.select()
.from(users)
.where(eq(users.oidcIdentifier, oidcIdentifier))
.limit(1);
return rows[0] ?? null;
}
async listByIds(ids: string[]): Promise<UserRecord[]> {
const uniqueIds = Array.from(new Set(ids.filter(Boolean)));
if (uniqueIds.length === 0) {
@@ -98,7 +97,7 @@ export class UserRepository {
}
return this.context.drizzle
.select()
.select(userColumns)
.from(users)
.where(inArray(users.id, uniqueIds));
}
@@ -106,7 +105,9 @@ export class UserRepository {
async create(user: NewUserRecord): Promise<UserRecord> {
const rows = await insertReturning(this.context, users, user);
await this.afterWrite();
return rows[0];
return (
(await this.findById(rows[0].id)) ?? { ...rows[0], isExternal: false }
);
}
async createFirstLocalUser(
@@ -118,7 +119,7 @@ export class UserRepository {
}));
await this.afterWrite();
return result;
return { ...result, user: { ...result.user, isExternal: false } };
}
async createFirstSsoUser(
@@ -130,7 +131,7 @@ export class UserRepository {
}));
await this.afterWrite();
return result;
return { ...result, user: { ...result.user, isExternal: true } };
}
/**
@@ -146,7 +147,7 @@ export class UserRepository {
*/
private async createCheckingIfFirst(
build: (isFirstUser: boolean) => NewUserRecord,
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
): Promise<{ user: typeof users.$inferSelect; isFirstUser: boolean }> {
if (this.context.dialect === "sqlite") {
/* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect
is checked directly above, and better-sqlite3 rejects an async
@@ -195,7 +196,7 @@ export class UserRepository {
);
await this.afterWrite();
return rows[0] ?? null;
return rows[0] ? this.findById(rows[0].id) : null;
}
async delete(id: string): Promise<boolean> {
@@ -710,9 +710,6 @@ export function registerHostBulkRoutes(
sudoPassword: hostData.sudoPassword || null,
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
...importedStatusCheck(hostData as Record<string, unknown>),
terminalConfig: hostData.terminalConfig
? JSON.stringify(hostData.terminalConfig)
: null,
// A 2.8 export carries these inside terminalConfig.
sshOptions:
sshOptionsForWrite({
@@ -975,7 +972,6 @@ export function registerHostBulkRoutes(
: null,
statusCheckEnabled: true,
statusCheckInterval: null,
terminalConfig: null,
sshOptions: null,
forceKeyboardInteractive: "false",
notes: null,
@@ -12,7 +12,10 @@ import {
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
import { recompute } from "../../hosts/defaults/recompute.js";
import { setFolderCredentialDefault } from "../../hosts/defaults/service.js";
import {
folderCredentialIds,
setFolderCredentialDefault,
} from "../../hosts/defaults/service.js";
type HostFolderRoutesDeps = {
authenticateJWT: RequestHandler;
@@ -143,10 +146,17 @@ export function registerHostFolderRoutes(
}
try {
const folders =
await createCurrentHostFolderRepository().listFolders(userId);
const [folders, credentials] = await Promise.all([
createCurrentHostFolderRepository().listFolders(userId),
folderCredentialIds(userId),
]);
res.json(folders);
res.json(
folders.map((folder) => ({
...folder,
credentialId: credentials.get(folder.id) ?? null,
})),
);
} catch (err) {
sshLogger.error("Failed to fetch folders", err, {
operation: "fetch_folders",
@@ -238,7 +248,6 @@ export function registerHostFolderRoutes(
name,
color,
icon,
normalizedCredentialId,
);
if (typeof localOnly === "boolean") {
await createCurrentHostFolderRepository().setLocalOnly(
@@ -62,6 +62,7 @@ export const OWNER_PRIVATE_AUTH_FIELDS = {
],
} as const;
/** Keys a 2.8 client's terminalConfig may carry that only the owner may set. */
export const OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS = [
"sudoPassword",
"agentSocketPath",
@@ -263,18 +264,10 @@ export function stripSensitiveFields(
host: Record<string, unknown>,
): Record<string, unknown> {
const result = { ...host };
const terminalConfigForSudo =
host.terminalConfig &&
typeof host.terminalConfig === "object" &&
!Array.isArray(host.terminalConfig)
? (host.terminalConfig as Record<string, unknown>)
: undefined;
result.hasKey = !!host.key;
result.hasKeyPassword = !!host.keyPassword;
result.hasPassword = !!host.password;
// 2.8 editors kept the sudo password inside terminal_config.
result.hasSudoPassword =
!!host.sudoPassword || !!terminalConfigForSudo?.sudoPassword;
result.hasSudoPassword = !!host.sudoPassword;
for (const field of SENSITIVE_FIELDS) {
delete result[field];
}
@@ -283,11 +276,11 @@ export function stripSensitiveFields(
typeof result.terminalConfig === "object" &&
!Array.isArray(result.terminalConfig)
) {
const terminalConfig = {
...(result.terminalConfig as Record<string, unknown>),
};
delete terminalConfig.sudoPassword;
result.terminalConfig = terminalConfig;
const { sudoPassword: _sudo, ...rest } = result.terminalConfig as Record<
string,
unknown
>;
result.terminalConfig = rest;
}
return result;
}
@@ -397,44 +390,22 @@ export function parseSharedSource(
}
}
function parseJsonObject(value: unknown): Record<string, unknown> | null {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
if (typeof value !== "string" || !value) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: null;
} catch {
return null;
}
}
/**
* 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.
* hostPayloadLegacy.
*/
export function hostTerminalExport(host: Record<string, unknown>): {
sshOptions: HostSshOptions;
terminalConfig?: Record<string, unknown>;
sudoPassword?: unknown;
} {
const raw = parseJsonObject(host.terminalConfig);
const sshOptions = parseSshOptions(
host.sshOptions != null ? host.sshOptions : raw,
);
const sshOptions = parseSshOptions(host.sshOptions);
const terminalConfig: Record<string, unknown> = { ...sshOptions };
const legacySudo = !host.sudoPassword ? raw?.sudoPassword : undefined;
return {
sshOptions,
terminalConfig:
Object.keys(terminalConfig).length > 0 ? terminalConfig : undefined,
...(legacySudo ? { sudoPassword: legacySudo } : {}),
};
}
@@ -1,14 +1,11 @@
/**
* How a host update writes terminal_config and ssh_options, kept apart from
* the route so it can be tested on its own.
* How a host update writes ssh_options, kept apart from the route so it can
* be tested on its own.
*/
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
import { parseSshOptions } from "../../hosts/ssh-options.js";
import {
OWNER_PRIVATE_SSH_OPTION_FIELDS,
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
} from "./host-normalizers.js";
import { OWNER_PRIVATE_SSH_OPTION_FIELDS } from "./host-normalizers.js";
export function parseTerminalConfig(
value: unknown,
@@ -27,63 +24,41 @@ export function parseTerminalConfig(
}
/**
* terminal_config is merged into what is stored rather than replaced. The
* 2.8 keys the editor no longer sends (the terminal's look, which the
* ssh-terminal plugin copies into its host settings, and a sudo password 2.8
* editors kept there) stay until 26.10.0 drops the column. A shared editor
* never changes the owner's private keys in either JSON. Returns an error
* for a terminalConfig that cannot be read.
* A 2.8 client's terminalConfig only feeds sshOptions, which the route has
* already taken from it. A shared editor never changes the owner's private
* SSH options. Returns an error for a terminalConfig that cannot be read.
*/
export async function mergeStoredTerminalFields(
export async function mergeStoredSshOptions(
sshDataObj: Record<string, unknown>,
hostData: Record<string, unknown>,
hostId: number,
ownerId: string,
isOwner: boolean,
): Promise<string | null> {
const sentTerminalConfig =
hostData.terminalConfig !== undefined && hostData.terminalConfig !== null;
const incoming = sentTerminalConfig
? parseTerminalConfig(hostData.terminalConfig)
: null;
if (sentTerminalConfig && !incoming) return "Invalid terminal config";
if (
hostData.terminalConfig !== undefined &&
hostData.terminalConfig !== null &&
!parseTerminalConfig(hostData.terminalConfig)
) {
return "Invalid terminal config";
}
delete sshDataObj.terminalConfig;
const sentSudoPassword = isOwner && hostData.sudoPassword !== undefined;
if (!incoming && !sentSudoPassword && sshDataObj.sshOptions === undefined) {
return null;
}
if (isOwner || sshDataObj.sshOptions === undefined) return null;
const stored = await createCurrentHostResolutionRepository().findHostById(
hostId,
ownerId,
);
const storedConfig = parseTerminalConfig(stored?.terminalConfig) ?? {};
if (incoming || (sentSudoPassword && "sudoPassword" in storedConfig)) {
const merged = { ...storedConfig, ...(incoming ?? {}) };
if (!isOwner) {
for (const field of OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS) {
if (field in storedConfig) merged[field] = storedConfig[field];
else delete merged[field];
}
const options = parseSshOptions(sshDataObj.sshOptions);
const storedOptions = parseSshOptions(stored?.sshOptions);
for (const field of OWNER_PRIVATE_SSH_OPTION_FIELDS) {
if (storedOptions[field] !== undefined) {
options[field] = storedOptions[field];
} else {
delete options[field];
}
// A sudo password saved on its own lives in the encrypted column.
if (sentSudoPassword) delete merged.sudoPassword;
sshDataObj.terminalConfig = JSON.stringify(merged);
}
if (!isOwner && sshDataObj.sshOptions !== undefined) {
const options = parseSshOptions(sshDataObj.sshOptions);
const storedOptions = parseSshOptions(stored?.sshOptions ?? storedConfig);
for (const field of OWNER_PRIVATE_SSH_OPTION_FIELDS) {
if (storedOptions[field] !== undefined) {
options[field] = storedOptions[field];
} else {
delete options[field];
}
}
sshDataObj.sshOptions = JSON.stringify(options);
}
sshDataObj.sshOptions = JSON.stringify(options);
return null;
}
+5 -24
View File
@@ -98,7 +98,7 @@ import {
listHostProtocols,
} from "../../hosts/protocol-auth/registry.js";
import {
mergeStoredTerminalFields,
mergeStoredSshOptions,
parseTerminalConfig,
} from "./host-terminal-fields.js";
@@ -316,11 +316,6 @@ router.post(
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
: JSON.stringify(terminalConfig)
: null,
sshOptions: sshOptionsForWrite({ sshOptions, terminalConfig }) ?? null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
notes: notes || null,
@@ -889,11 +884,6 @@ router.put(
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
: JSON.stringify(terminalConfig)
: null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
notes: notes || null,
useSocks5: useSocks5 ? 1 : 0,
@@ -1081,7 +1071,7 @@ router.put(
}
}
const terminalFieldsError = await mergeStoredTerminalFields(
const terminalFieldsError = await mergeStoredSshOptions(
sshDataObj,
hostData,
Number(hostId),
@@ -1661,11 +1651,7 @@ router.get(
}
const resolved = (await resolveHostCredentials(host, userId)) || host;
let value = resolved[field];
if (!value && field === "sudoPassword") {
value = hostTerminalExport(resolved).sudoPassword || null;
}
const value = resolved[field];
if (!value) {
return res.status(404).json({ error: "No password set" });
@@ -1775,10 +1761,7 @@ router.get(
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername:
!!resolvedHost.overrideCredentialUsername,
sudoPassword:
resolvedHost.sudoPassword ||
hostTerminalExport(resolvedHost).sudoPassword ||
null,
sudoPassword: resolvedHost.sudoPassword || null,
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
@@ -1911,9 +1894,7 @@ router.get(
!!resolvedHost.overrideCredentialUsername,
sudoPassword: shareMode
? null
: resolvedHost.sudoPassword ||
hostTerminalExport(resolvedHost).sudoPassword ||
null,
: resolvedHost.sudoPassword || null,
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
@@ -123,7 +123,6 @@ export function registerUserAdminRoutes(
username: u.username,
is_admin: u.isAdmin,
is_external: isExternalAccount(u),
is_oidc: u.isOidc,
password_hash: u.passwordHash ? "set" : null,
// Management-only details stay admin-eyes-only; regular users hit
// this route to pick sharing targets.
@@ -470,15 +469,6 @@ export function registerUserAdminRoutes(
username,
passwordHash: password_hash,
isAdmin: false,
isOidc: false,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
});
try {
@@ -631,7 +621,7 @@ export function registerUserAdminRoutes(
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
if (targetUser.isOidc && !targetUser.passwordHash) {
if (targetUser.isExternal && !targetUser.passwordHash) {
return res.status(400).json({
error: "This user authenticates through an external provider",
});
@@ -58,7 +58,7 @@ export function registerUserExternalAccountRoutes(
* /users/link-external-to-password:
* post:
* summary: Merge an external account into a password account
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). A 2.8 SSO account whose only sign-in is the matching legacy OIDC link is accepted as the target too, to merge a duplicate made by 2.9.0. The 2.8 path /users/link-oidc-to-password and its oidcUserId field are accepted too.
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). A 2.8 SSO account whose only sign-in is the matching legacy OIDC link is accepted as the target too, to merge a duplicate made by 2.9.0.
* tags:
* - Users
* requestBody:
@@ -86,7 +86,7 @@ export function registerUserExternalAccountRoutes(
*/
const link = async (req: Request, res: Response) => {
const adminUserId = (req as AuthenticatedRequest).userId;
const externalUserId = req.body?.externalUserId ?? req.body?.oidcUserId;
const externalUserId = req.body?.externalUserId;
const targetUsername = req.body?.targetUsername;
if (
@@ -143,11 +143,6 @@ export function registerUserExternalAccountRoutes(
adminUserId,
});
await userRepository.update(targetUser.id, {
isOidc: true,
oidcIdentifier:
externalUser.oidcIdentifier ?? targetUser.oidcIdentifier,
});
await identities.moveIdentities(externalUserId, targetUser.id);
await authManager.revokeAllUserSessions(externalUserId);
@@ -201,7 +196,7 @@ export function registerUserExternalAccountRoutes(
* /users/unlink-external-from-password:
* post:
* summary: Remove external sign-in from a password account
* description: Removes every external sign-in identity from an account that also has a password (admin only). The 2.8 path /users/unlink-oidc-from-password is accepted too.
* description: Removes every external sign-in identity from an account that also has a password (admin only).
* tags:
* - Users
* requestBody:
@@ -261,10 +256,6 @@ export function registerUserExternalAccountRoutes(
});
}
await userRepository.update(targetUser.id, {
isOidc: false,
oidcIdentifier: null,
});
await createCurrentUserAuthRepository().unlinkIdentitiesForUser(
targetUser.id,
);
@@ -307,7 +298,4 @@ export function registerUserExternalAccountRoutes(
router.post("/link-external-to-password", authenticateJWT, link);
router.post("/unlink-external-from-password", authenticateJWT, unlink);
// 2.8 paths, kept until 26.10.0.
router.post("/link-oidc-to-password", authenticateJWT, link);
router.post("/unlink-oidc-from-password", authenticateJWT, unlink);
}
@@ -173,7 +173,7 @@ export function registerUserPasswordResetRoutes(
});
}
if (user.isOidc) {
if (user.isExternal) {
return res.json({
message:
"If the user exists, a password reset code has been generated. Check docker logs for the code.",
+8 -22
View File
@@ -55,6 +55,7 @@ import {
isNativeAppRequest,
syncSharedCredentialsForUserRoles,
} from "../../auth/session-issuer.js";
import { EXTERNAL_AUTO_PROVISION_KEY } from "../../auth/provisioning.js";
const authManager = AuthManager.getInstance();
@@ -184,15 +185,6 @@ router.post("/create", async (req, res) => {
id,
username,
passwordHash: password_hash,
isOidc: false,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
});
try {
@@ -361,7 +353,7 @@ router.post("/proxy-login", async (req, res) => {
return res.status(403).json({ error: "Proxy user must already exist" });
}
if (
userRecord.isOidc ||
userRecord.isExternal ||
(await createCurrentUserAuthRepository().hasSecondFactor(userRecord.id))
) {
return res.status(409).json({
@@ -600,8 +592,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
}
const hasPassword = user.passwordHash && user.passwordHash.trim() !== "";
const isDualAuth =
hasPassword && isExternalAccount(user) && !!user.oidcIdentifier;
const isDualAuth = hasPassword && isExternalAccount(user);
const showDonationModal = shouldShowDonationModal(
user.registeredAt,
@@ -613,8 +604,6 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
username: user.username,
is_admin: !!user.isAdmin,
is_external: isExternalAccount(user),
// 2.8 name, kept until 26.10.0.
is_oidc: !!user.isOidc,
is_dual_auth: isDualAuth,
// Any second factor; the name is what 2.8 clients read.
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
@@ -944,7 +933,7 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => {
* /users/external-auto-provision:
* get:
* summary: Get the external account auto-create setting
* description: Whether a new account is created the first time someone signs in through an external login (SSO, LDAP or any login plugin). The 2.8 path /users/oidc-auto-provision is accepted too.
* description: Whether a new account is created the first time someone signs in through an external login (SSO, LDAP or any login plugin).
* tags:
* - Users
* responses:
@@ -955,7 +944,7 @@ const getExternalAutoProvision: RequestHandler = async (_req, res) => {
try {
res.json({
enabled: await createCurrentSettingsRepository().getBoolean(
"oidc_auto_provision",
EXTERNAL_AUTO_PROVISION_KEY,
false,
),
});
@@ -972,7 +961,7 @@ const getExternalAutoProvision: RequestHandler = async (_req, res) => {
* /users/external-auto-provision:
* patch:
* summary: Set the external account auto-create setting
* description: Enables or disables creating an account on first external sign-in. The 2.8 path /users/oidc-auto-provision is accepted too.
* description: Enables or disables creating an account on first external sign-in.
* tags:
* - Users
* requestBody:
@@ -1006,7 +995,7 @@ const setExternalAutoProvision: RequestHandler = async (req, res) => {
return res.status(400).json({ error: "Invalid value for enabled" });
}
await createCurrentSettingsRepository().set(
"oidc_auto_provision",
EXTERNAL_AUTO_PROVISION_KEY,
enabled ? "true" : "false",
);
res.json({ enabled });
@@ -1022,9 +1011,6 @@ router.patch(
authenticateJWT,
setExternalAutoProvision,
);
// 2.8 paths, kept until 26.10.0.
router.get("/oidc-auto-provision", getExternalAutoProvision);
router.patch("/oidc-auto-provision", authenticateJWT, setExternalAutoProvision);
/**
* @openapi
@@ -1317,7 +1303,7 @@ router.delete("/delete-account", authenticateJWT, async (req, res) => {
return res.status(404).json({ error: "User not found" });
}
if (userRecord.isOidc) {
if (userRecord.isExternal) {
return res.status(403).json({
error:
"Cannot delete external authentication accounts through this endpoint",
+10 -28
View File
@@ -34,14 +34,13 @@ export function pickResolvedPassword(
return undefined;
}
const USERNAME_PLACEHOLDER =
/\$(?:external\.username|oidc\.preferred_username)/g;
const USERNAME_PLACEHOLDER = /\$external\.username/g;
/**
* Expands `$external.username` (or its 2.8 spelling `$oidc.preferred_username`)
* in an SSH username to the name the connecting user signed in with through
* SSO or LDAP. Returns the username unchanged if it has no placeholder or the
* user has no external sign-in.
* Expands `$external.username` in an SSH username to the name the connecting
* user signed in with through SSO or LDAP: the subject of their first
* external identity. Returns the username unchanged if it has no placeholder
* or the user has no external sign-in.
*/
export async function expandExternalUsername(
username: string | undefined,
@@ -52,31 +51,14 @@ export async function expandExternalUsername(
}
try {
const { createCurrentUserRepository } =
const { createCurrentUserAuthRepository } =
await import("../database/repositories/factory.js");
const user = await createCurrentUserRepository().findById(userId);
let externalName = user?.oidcIdentifier;
const identities =
await createCurrentUserAuthRepository().listIdentitiesForUser(userId);
const externalName = [...identities].sort((a, b) => a.id - b.id)[0]
?.subject;
if (!externalName) return username;
const match = /^ldap:(\d+):(.+)$/.exec(externalName);
if (match) {
// Only strip the prefix for a real LDAP identity, to prevent spoofing
// through an SSO subject that happens to look like one.
const { createCurrentUserAuthRepository } =
await import("../database/repositories/factory.js");
const identities =
await createCurrentUserAuthRepository().listIdentitiesForUser(userId);
if (
identities.some(
(identity) =>
identity.providerId === `ldap:${match[1]}` &&
identity.subject === match[2],
)
) {
externalName = match[2];
}
}
return username.replace(USERNAME_PLACEHOLDER, () => externalName);
} catch {
return username;
+29
View File
@@ -258,6 +258,35 @@ export async function resolveForEditor(input: {
return resolveAll(catalog, levels, input.hostId ?? null);
}
/** Each folder's credential, from its auth default, keyed by folder id. */
export async function folderCredentialIds(
userId: string,
): Promise<Map<number, number>> {
const result = new Map<number, number>();
const rows = await createCurrentHostDefaultsRepository().listOwnedBy(userId);
for (const row of rows) {
if (
row.level !== "folder" ||
row.folderId == null ||
row.namespace !== CORE_NAMESPACE ||
row.key !== "auth"
) {
continue;
}
const auth = decodeDefaultValue(row.value) as {
authType?: string;
credentialId?: unknown;
} | null;
if (
auth?.authType === "credential" &&
typeof auth.credentialId === "number"
) {
result.set(row.folderId, auth.credentialId);
}
}
return result;
}
/**
* A folder's credential is its auth default: hosts in the folder that follow
* their defaults sign in with it. Null clears it.
+3 -46
View File
@@ -106,37 +106,11 @@ export async function resolveHostById(
host.jumpHosts = [];
}
}
if (typeof host.terminalConfig === "string" && host.terminalConfig) {
try {
host.terminalConfig = JSON.parse(host.terminalConfig as string);
} catch {
host.terminalConfig = undefined;
}
}
if (
host.terminalConfig &&
typeof host.terminalConfig === "object" &&
!Array.isArray(host.terminalConfig)
) {
// 2.8 editors kept the sudo password inside terminal_config. It is only
// ever handed out as sudoPassword, and only to the owner.
const { sudoPassword: legacySudo, ...rest } = host.terminalConfig as Record<
string,
unknown
>;
if (ownerEquivalent && !host.sudoPassword && legacySudo) {
host.sudoPassword = legacySudo;
}
host.terminalConfig = rest;
}
// Stored as the text "true"/"false"; the string "false" is truthy and would
// make the password provider skip password auth.
host.forceKeyboardInteractive =
String(host.forceKeyboardInteractive) === "true";
// A row the boot copy has not reached yet still has them in terminal_config.
host.sshOptions = parseSshOptions(
host.sshOptions != null ? host.sshOptions : host.terminalConfig,
);
host.sshOptions = parseSshOptions(host.sshOptions);
if (typeof host.socks5ProxyChain === "string" && host.socks5ProxyChain) {
try {
host.socks5ProxyChain = JSON.parse(host.socks5ProxyChain as string);
@@ -157,25 +131,8 @@ export async function resolveHostById(
sharedAuthResolution = await resolveRecipientSshAuth(host, hostId, userId);
if (!sharedAuthResolution) return null;
} else {
let effectiveCredentialId = host.credentialId as number | null | undefined;
if (
!effectiveCredentialId &&
host.authType === "credential" &&
host.folder
) {
try {
effectiveCredentialId = await repository.findFolderCredentialId(
ownerId,
host.folder as string,
);
} catch (e) {
sshLogger.warn("Failed to resolve folder credential for host", {
operation: "host_resolver_folder_credential",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
}
const effectiveCredentialId = host.credentialId as
number | null | undefined;
if (effectiveCredentialId) {
try {
+1 -2
View File
@@ -50,8 +50,7 @@ function optionalString(value: unknown): string | null | undefined {
/**
* The SSH options in a value (a JSON string or an object), typed and with
* anything else dropped. A terminal_config value works too, which is how the
* 2.8 keys are read.
* anything else dropped. A 2.8 client's terminalConfig works too.
*/
export function parseSshOptions(value: unknown): HostSshOptions {
const source = asObject(value);
+8 -7
View File
@@ -601,12 +601,13 @@ export function toCoreIdentity(
returnTo: identity.returnTo,
rememberMe: identity.rememberMe,
legacyIdentifier: identity.legacy?.identifier,
ssoProviderId:
identity.logoutClaims?.providerId ??
identity.legacy?.providerRowId ??
null,
oidcSub: identity.logoutClaims?.sub ?? null,
oidcSid: identity.logoutClaims?.sid ?? null,
externalSession: identity.logoutClaims
? {
providerId: identity.logoutClaims.providerId ?? null,
sub: identity.logoutClaims.sub ?? null,
sid: identity.logoutClaims.sid ?? null,
}
: null,
roleSync: identity.roles
? {
desired: [...identity.roles.desired],
@@ -864,7 +865,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
const { AuthManager } = await import("../utils/auth-manager.js");
const revoked =
await AuthManager.getInstance().revokeSessionsByExternalSession({
ssoProviderId: match.providerId ?? null,
providerId: match.providerId ?? null,
sub: match.sub ?? null,
sid: match.sid ?? null,
});
+1 -10
View File
@@ -58,15 +58,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
id,
username: "local",
passwordHash: "",
isOidc: false,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
});
try {
@@ -191,7 +182,7 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
createCurrentUserAuthRepository().listUserIdsWithSecondFactors(),
]);
const conflictingUser = users.some(
(user) => user.isOidc || secondFactorUsers.has(user.id),
(user) => user.isExternal || secondFactorUsers.has(user.id),
);
if (process.env.OIDC_CLIENT_ID || conflictingUser) {
throw new Error(
+3 -4
View File
@@ -67,11 +67,10 @@ const HOST_LOCAL_FIELDS = [
];
/**
* Columns kept until 26.10.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.
* Columns 26.10.0 dropped. A 2.9 desktop still sends them; they never reach
* the table.
*/
const HOST_RETIRED_FIELDS = ["quickActions"];
const HOST_RETIRED_FIELDS = ["quickActions", "terminalConfig"];
let registered = false;
+17 -10
View File
@@ -5,9 +5,6 @@ export interface FakeUser {
username: string;
passwordHash: string;
isAdmin: boolean;
isOidc: boolean;
oidcIdentifier?: string | null;
ssoProviderId?: number | null;
}
/** In-memory stand-ins for the repositories the login pipeline touches. */
@@ -37,18 +34,28 @@ export function createAuthState() {
export type AuthState = ReturnType<typeof createAuthState>;
/** The user as UserRepository returns it, with isExternal filled in. */
function withIdentities(state: AuthState, user: FakeUser | undefined) {
if (!user) return null;
return {
...user,
isExternal: state.identities.some((row) => row.userId === user.id),
};
}
export function fakeFactory(state: AuthState) {
return {
createCurrentUserRepository: () => ({
findById: async (id: string) => state.users.get(id) ?? null,
findById: async (id: string) =>
withIdentities(state, state.users.get(id)),
findByUsername: async (username: string) =>
[...state.users.values()].find((u) => u.username === username) ?? null,
findByExternalIdentifier: async (identifier: string) =>
[...state.users.values()].find(
(u) => u.oidcIdentifier === identifier,
) ?? null,
withIdentities(
state,
[...state.users.values()].find((u) => u.username === username),
),
countAll: async () => state.users.size,
listAll: async () => [...state.users.values()],
listAll: async () =>
[...state.users.values()].map((u) => withIdentities(state, u)),
createFirstSsoUser: async (user: FakeUser) => {
const isFirstUser = state.users.size === 0;
const created = {
+9 -85
View File
@@ -119,7 +119,6 @@ function addUser(
username: "alice",
passwordHash: bcrypt.hashSync(PASSWORD, 4),
isAdmin: false,
isOidc: false,
...overrides,
};
h.state.users.set(user.id, user);
@@ -461,7 +460,7 @@ describe("external identities", () => {
email: "a@example.com",
name: "Alice",
legacyIdentifier: "sub-1",
ssoProviderId: 3,
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
};
// The sso plugin's method, which marks itself external.
@@ -487,7 +486,6 @@ describe("external identities", () => {
expect(user).toMatchObject({
username: "Alice",
isAdmin: true,
isOidc: true,
});
expect(h.state.identities).toEqual([
expect.objectContaining({
@@ -511,7 +509,7 @@ describe("external identities", () => {
}),
).rejects.toMatchObject({ code: "registration_disabled" });
h.state.settings.set("oidc_auto_provision", "true");
h.state.settings.set("external_auto_provision", "true");
await expect(
runLogin(
fakeRequest() as never,
@@ -521,45 +519,11 @@ describe("external identities", () => {
).rejects.toMatchObject({ code: "user_not_allowed" });
});
it.each(["true", "false"])(
"does not duplicate an unscoped migrated account with provisioning %s",
async (enabled) => {
addUser({
id: "old",
username: "Alice",
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-1",
ssoProviderId: null,
});
const { runExternalIdentityMigration } =
await import("../../upgrade/external-identity-migration.js");
await runExternalIdentityMigration();
h.state.settings.set("oidc_auto_provision", enabled);
await expect(
runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
}),
).rejects.toMatchObject({ code: "legacy_identity_conflict" });
expect([...h.state.users.keys()]).toEqual(["old"]);
expect(h.state.identities).toHaveLength(1);
expect(h.state.identities[0]).toMatchObject({
userId: "old",
providerId: "legacy-oidc",
subject: "sub-1",
});
},
);
it("keeps equal subjects on explicitly different providers separate", async () => {
addUser({
id: "other",
username: "Other Alice",
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-1",
ssoProviderId: 9,
});
h.state.identities.push({
id: 1,
@@ -568,7 +532,7 @@ describe("external identities", () => {
subject: "sub-1",
email: null,
});
h.state.settings.set("oidc_auto_provision", "true");
h.state.settings.set("external_auto_provision", "true");
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
@@ -584,10 +548,7 @@ describe("external identities", () => {
addUser({
id: "old",
username: "Alice",
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-1",
ssoProviderId: null,
});
h.state.identities.push({
id: 1,
@@ -605,7 +566,7 @@ describe("external identities", () => {
});
it("re-checks the allowed list for existing users", async () => {
addUser({ id: "u9", username: "Alice", isOidc: true, passwordHash: "" });
addUser({ id: "u9", username: "Alice", passwordHash: "" });
h.state.identities.push({
id: 1,
userId: "u9",
@@ -622,42 +583,10 @@ describe("external identities", () => {
).rejects.toMatchObject({ code: "user_not_allowed" });
});
it("finds an account from before the identity table by its old identifier", async () => {
addUser({
id: "old",
username: "ldap-bob",
isOidc: true,
passwordHash: "",
oidcIdentifier: "ldap:4:bob",
});
const result = await runLogin(
fakeRequest() as never,
{
kind: "external",
provider: "4",
subject: "bob",
name: "Bob",
legacyIdentifier: "ldap:4:bob",
ssoProviderId: 4,
},
{ methodId: "ldap", rememberMe: false },
);
expect(result.kind).toBe("session");
expect(h.state.users.size).toBe(1);
expect(h.state.identities).toEqual([
expect.objectContaining({
userId: "old",
providerId: "4",
subject: "bob",
}),
]);
});
it("keeps admin in step with the provider's admin group", async () => {
addUser({
id: "u9",
username: "Alice",
isOidc: true,
passwordHash: "",
isAdmin: true,
});
@@ -680,7 +609,6 @@ describe("external identities", () => {
addUser({
id: "u9",
username: "Alice",
isOidc: true,
passwordHash: "",
});
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
@@ -703,7 +631,6 @@ describe("external identities", () => {
addUser({
id: "u9",
username: "Alice",
isOidc: true,
passwordHash: "",
});
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
@@ -729,9 +656,7 @@ describe("redirect logins", () => {
subject: "sub-1",
name: "Alice",
returnTo: "https://termix.example",
ssoProviderId: 3,
oidcSub: "sub-1",
oidcSid: "sid-1",
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
};
it("sets the cookie and redirects with success, carrying the SSO claims", async () => {
@@ -748,9 +673,7 @@ describe("redirect logins", () => {
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
oidcSub: "sub-1",
oidcSid: "sid-1",
ssoProviderId: 3,
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
}),
);
});
@@ -774,7 +697,6 @@ describe("redirect logins", () => {
addUser({
id: "u9",
username: "Alice",
isOidc: true,
passwordHash: "",
});
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
@@ -812,7 +734,9 @@ describe("redirect logins", () => {
expect(verify.statusCode).toBe(200);
expect(h.manager.generateJWTToken).toHaveBeenLastCalledWith(
"u9",
expect.objectContaining({ oidcSid: "sid-1", ssoProviderId: 3 }),
expect.objectContaining({
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
}),
);
});
@@ -113,8 +113,8 @@ describe("generated schemas", () => {
expect(auditFks).toHaveLength(1);
expect(auditFks[0].onDelete).toBe("set null");
const folderFks = read(schema.sshFolders).foreignKeys;
expect(folderFks.map((fk) => fk.onDelete).sort()).toEqual([
const hostFks = read(schema.hosts).foreignKeys;
expect([...new Set(hostFks.map((fk) => fk.onDelete))].sort()).toEqual([
"cascade",
"set null",
]);
@@ -0,0 +1,199 @@
import fs from "fs";
import os from "os";
import path from "path";
import Database from "better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* A database 2.9 left behind still has the 2.8 columns it kept for a
* downgrade. Opening it in 26.10.0 drops them, keeps what still matters
* (a sudo password kept in terminal_config, an SSO sign-in, the hosts and
* folders themselves) and removes the settings rows the plugins copied.
*/
describe("dropping the columns 2.9.0 kept", () => {
let dataDir: string;
beforeEach(() => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-retire-"));
vi.resetModules();
process.env.DATA_DIR = dataDir;
process.env.DB_FILE_ENCRYPTION = "false";
process.env.ALLOW_EMPTY_DATA_DIR = "true";
});
afterEach(() => {
delete process.env.DATA_DIR;
delete process.env.DB_FILE_ENCRYPTION;
delete process.env.ALLOW_EMPTY_DATA_DIR;
fs.rmSync(dataDir, { recursive: true, force: true });
});
function write29Database(): void {
const seed = new Database(":memory:");
seed.exec(`
CREATE TABLE plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL,
tier TEXT NOT NULL DEFAULT 'available',
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,
manifest_json TEXT NOT NULL
);
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_oidc INTEGER NOT NULL DEFAULT 0,
oidc_identifier TEXT,
client_secret TEXT
);
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE ssh_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
auth_type TEXT NOT NULL,
username TEXT
);
CREATE TABLE ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
auth_type TEXT NOT NULL DEFAULT 'password',
sudo_password TEXT,
terminal_config TEXT,
quick_actions TEXT,
tunnel_connections TEXT,
enable_docker INTEGER NOT NULL DEFAULT 0,
stats_config TEXT,
rdp_user TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE ssh_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE user_preferences (
user_id TEXT PRIMARY KEY,
theme TEXT,
terminal_defaults TEXT,
rdp_defaults TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash, is_oidc, oidc_identifier)
VALUES ('u1', 'alice', 'hash', 1, 'sub-1');
INSERT INTO ssh_credentials (id, user_id, name, auth_type)
VALUES (5, 'u1', 'cred', 'password');
INSERT INTO ssh_data (id, user_id, name, ip, port, username,
terminal_config, tunnel_connections, enable_docker, rdp_user)
VALUES
(1, 'u1', 'web', '10.0.0.1', 22, '$oidc.preferred_username',
'{"sudoPassword":"s3cret","theme":"nord"}', '[]', 1, 'admin'),
(2, 'u1', 'db', '10.0.0.2', 22, 'root', NULL, NULL, 0, NULL);
INSERT INTO ssh_folders (id, user_id, name, color, credential_id)
VALUES (3, 'u1', 'prod', '#fff', 5);
INSERT INTO user_preferences (user_id, theme, terminal_defaults, rdp_defaults)
VALUES ('u1', 'dark', '{}', '{}');
INSERT INTO settings (key, value) VALUES
('guac_url', 'guacd:4822'),
('analytics_enabled', 'true'),
('allow_registration', 'false');
`);
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
seed.close();
}
const columns = (sqlite: Database.Database, table: string) =>
(sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{
name: string;
}>).map((col) => col.name);
it("drops the columns and rows and keeps the data that still matters", async () => {
write29Database();
const db = await import("../../../database/db/index.js");
await db.initializeDatabase();
const sqlite = db.getSqlite();
expect(columns(sqlite, "users")).not.toContain("oidc_identifier");
expect(columns(sqlite, "users")).not.toContain("is_oidc");
expect(columns(sqlite, "ssh_data")).not.toEqual(
expect.arrayContaining(["terminal_config"]),
);
for (const gone of [
"terminal_config",
"quick_actions",
"tunnel_connections",
"enable_docker",
"stats_config",
]) {
expect(columns(sqlite, "ssh_data")).not.toContain(gone);
}
// A login moved only once its owner signs in stays until then.
expect(columns(sqlite, "ssh_data")).toContain("rdp_user");
expect(columns(sqlite, "ssh_folders")).not.toContain("credential_id");
expect(columns(sqlite, "user_preferences")).not.toContain("rdp_defaults");
expect(columns(sqlite, "user_preferences")).not.toContain(
"terminal_defaults",
);
const hosts = sqlite
.prepare("SELECT id, username, sudo_password FROM ssh_data ORDER BY id")
.all();
expect(hosts).toEqual([
{ id: 1, username: "$external.username", sudo_password: "s3cret" },
{ id: 2, username: "root", sudo_password: null },
]);
expect(
sqlite.prepare("SELECT name, color FROM ssh_folders").all(),
).toEqual([{ name: "prod", color: "#fff" }]);
expect(
sqlite.prepare("SELECT theme FROM user_preferences").get(),
).toEqual({ theme: "dark" });
const keys = (
sqlite.prepare("SELECT key FROM settings").all() as Array<{
key: string;
}>
).map((row) => row.key);
expect(keys).toContain("allow_registration");
expect(keys).not.toContain("guac_url");
expect(keys).not.toContain("analytics_enabled");
});
it("refuses a 2.8 database", async () => {
const seed = new Database(":memory:");
seed.exec(`
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL,
password_hash TEXT NOT NULL);
CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, user_id TEXT NOT NULL);
`);
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
seed.close();
const db = await import("../../../database/db/index.js");
await expect(db.initializeDatabase()).rejects.toThrow(/2\.8 or older/);
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
legacySudoMoves,
legacySudoPassword,
} from "../../../database/db/retired-columns.js";
describe("legacySudoPassword", () => {
it("reads the sudo password out of a terminal_config value", () => {
expect(legacySudoPassword('{"sudoPassword":"s3cret"}')).toBe("s3cret");
expect(legacySudoPassword({ sudoPassword: "s3cret" })).toBe("s3cret");
expect(legacySudoPassword(JSON.stringify('{"sudoPassword":"x"}'))).toBe(
"x",
);
});
it("finds nothing in anything else", () => {
expect(legacySudoPassword(null)).toBeNull();
expect(legacySudoPassword("{")).toBeNull();
expect(legacySudoPassword('{"sudoPassword":""}')).toBeNull();
expect(legacySudoPassword("[1]")).toBeNull();
});
});
describe("legacySudoMoves", () => {
it("moves only hosts with no sudo password of their own", () => {
expect(
legacySudoMoves([
{ id: 1, terminal_config: '{"sudoPassword":"a"}', sudo_password: null },
{ id: 2, terminal_config: '{"sudoPassword":"b"}', sudo_password: "own" },
{ id: 3, terminal_config: '{"theme":"nord"}', sudo_password: null },
]),
).toEqual([{ id: 1, sudoPassword: "a" }]);
});
});
@@ -33,10 +33,28 @@ describe("share_ssh_auth backfill", () => {
fs.rmSync(dataDir, { recursive: true, force: true });
});
/** A 2.6.0 database: hosts and shares exist, the column does not. */
/**
* Hosts and shares exist, the column does not. The plugins table is there
* because 26.10.0 only opens a database 2.9 has already upgraded.
*/
function writePreUpgradeDatabase(): void {
const seed = new Database(":memory:");
seed.exec(`
CREATE TABLE plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL,
tier TEXT NOT NULL DEFAULT 'available',
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,
manifest_json TEXT NOT NULL
);
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import {
assertNotPre29Database,
PRE_29_DATABASE_ERROR,
} from "../../../database/db/upgrade-guard.js";
const tables = (...names: string[]) => (name: string) => names.includes(name);
describe("assertNotPre29Database", () => {
it("refuses a 2.8 database, which has hosts but no plugins table", async () => {
await expect(assertNotPre29Database(tables("ssh_data"))).rejects.toThrow(
PRE_29_DATABASE_ERROR,
);
});
it("opens a database 2.9 has upgraded", async () => {
await expect(
assertNotPre29Database(tables("ssh_data", "plugins")),
).resolves.toBeUndefined();
});
it("opens a new database", async () => {
await expect(assertNotPre29Database(tables())).resolves.toBeUndefined();
});
});
@@ -89,7 +89,6 @@ describe("HostFolderRepository", () => {
"prod",
"#abcdef",
"folder",
undefined,
"2026-02-01T00:00:00.000Z",
),
).resolves.toMatchObject({
@@ -102,7 +101,6 @@ describe("HostFolderRepository", () => {
"new",
null,
null,
null,
"2026-03-01T00:00:00.000Z",
),
).resolves.toMatchObject({
@@ -112,28 +110,6 @@ describe("HostFolderRepository", () => {
expect(writes).toBe(2);
});
it("assigns a credential to a folder and resolves it for nested paths", async () => {
const { repository } = await createRepository();
await expect(
repository.upsertMetadata(
"user-1",
"prod",
undefined,
undefined,
1,
"2026-02-01T00:00:00.000Z",
),
).resolves.toMatchObject({
created: false,
folder: { credentialId: 1 },
});
const folders = await repository.listFolders("user-1");
const prodFolder = folders.find((f) => f.name === "prod");
expect(prodFolder?.credentialId).toBe(1);
});
it("lists and deletes hosts and folder records in a folder tree", async () => {
let writes = 0;
const { repository } = await createRepository(() => {
@@ -44,11 +44,6 @@ describe("HostResolutionRepository", () => {
(1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password', 7, '[{"autoStart":true}]'),
(2, 'user-1', 'db', '10.0.0.2', 22, 'admin', 'none', NULL, NULL),
(3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'none', NULL, '[{"autoStart":false}]');
INSERT INTO ssh_folders (user_id, name, credential_id)
VALUES
('user-1', 'switches', 7),
('user-1', 'switches / floor1', NULL),
('user-1', 'no-cred', NULL);
INSERT INTO host_access (
host_id, user_id, granted_by, permission_level
)
@@ -330,26 +325,6 @@ describe("HostResolutionRepository", () => {
).resolves.toBeNull();
});
it("resolves a folder's assigned credential, walking up to parent folders", async () => {
const repository = await createRepository();
await expect(
repository.findFolderCredentialId("user-1", "switches"),
).resolves.toBe(7);
await expect(
repository.findFolderCredentialId("user-1", "switches / floor1"),
).resolves.toBe(7);
await expect(
repository.findFolderCredentialId("user-1", "no-cred"),
).resolves.toBeNull();
await expect(
repository.findFolderCredentialId("user-1", "unknown"),
).resolves.toBeNull();
await expect(
repository.findFolderCredentialId("user-1", ""),
).resolves.toBeNull();
});
describe("listCredentialsByIdsForUser", () => {
it("returns the owner's credentials keyed by id", async () => {
const repository = await createRepository();
@@ -19,11 +19,11 @@ describe("RbacAccessRepository", () => {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash, is_admin, is_oidc)
INSERT INTO users (id, username, password_hash, is_admin)
VALUES
('admin', 'admin', 'hash', 1, 0),
('user-1', 'alice', 'hash', 0, 0),
('owner-1', 'owner', 'hash', 0, 0);
('admin', 'admin', 'hash', 1),
('user-1', 'alice', 'hash', 0),
('owner-1', 'owner', 'hash', 0);
INSERT INTO roles (id, name, display_name, is_system)
VALUES (7, 'ops', 'Operations', 0);
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
@@ -18,8 +18,8 @@ describe("RoleRepository", () => {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash, is_admin, is_oidc)
VALUES ('admin', 'admin', 'hash', 1, 0), ('user-1', 'user', 'hash', 0, 0);
INSERT INTO users (id, username, password_hash, is_admin)
VALUES ('admin', 'admin', 'hash', 1), ('user-1', 'user', 'hash', 0);
`);
return new RoleRepository(context, onWrite);
@@ -34,19 +34,19 @@ describe("UserPreferenceRepository", () => {
reopenTabsOnLogin: true,
theme: "dark",
storageMode: "local",
commandAutocomplete: true,
commandPaletteEnabled: true,
});
expect(created).toMatchObject({
userId: "user-1",
reopenTabsOnLogin: true,
theme: "dark",
storageMode: "local",
commandAutocomplete: true,
commandPaletteEnabled: true,
});
const updated = await repo.upsert("user-1", {
theme: "light",
commandAutocomplete: false,
commandPaletteEnabled: false,
updatedAt: "2026-06-27T00:00:00.000Z",
});
expect(updated).toMatchObject({
@@ -54,7 +54,7 @@ describe("UserPreferenceRepository", () => {
reopenTabsOnLogin: true,
theme: "light",
storageMode: "local",
commandAutocomplete: false,
commandPaletteEnabled: false,
});
});
@@ -56,7 +56,6 @@ describe("UserRepository and SessionRepository", () => {
username: "admin",
passwordHash: "hash",
isAdmin: true,
isOidc: false,
});
expect(await repo.users.countAdmins()).toBe(1);
@@ -69,13 +68,15 @@ describe("UserRepository and SessionRepository", () => {
).toEqual(["user-1"]);
expect(await repo.users.listByIds([])).toEqual([]);
const updated = await repo.users.update("user-1", {
oidcIdentifier: "oidc:admin",
});
expect(updated?.oidcIdentifier).toBe("oidc:admin");
expect((await repo.users.findByExternalIdentifier("oidc:admin"))?.id).toBe(
"user-1",
);
const updated = await repo.users.update("user-1", { isAdmin: false });
expect(updated).toMatchObject({ isAdmin: false, isExternal: false });
await adapter!.exec(`
INSERT INTO user_external_identities (user_id, provider_id, subject)
VALUES ('user-1', '3', 'sub-1');
`);
expect((await repo.users.findById("user-1"))?.isExternal).toBe(true);
expect((await repo.users.listAll())[0].isExternal).toBe(true);
expect(await repo.users.delete("user-1")).toBe(true);
expect(await repo.users.findById("user-1")).toBeNull();
@@ -88,13 +89,11 @@ describe("UserRepository and SessionRepository", () => {
id: "user-1",
username: "first",
passwordHash: "hash",
isOidc: false,
});
const second = await repo.users.createFirstLocalUser({
id: "user-2",
username: "second",
passwordHash: "hash",
isOidc: false,
});
expect(first.isFirstUser).toBe(true);
@@ -112,27 +111,18 @@ describe("UserRepository and SessionRepository", () => {
username: "first",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:provider:first",
ssoProviderId: 1,
});
const providerAdmin = await repo.users.createFirstSsoUser({
id: "user-2",
username: "provider-admin",
passwordHash: "",
isAdmin: true,
isOidc: true,
oidcIdentifier: "ldap:provider:admin",
ssoProviderId: 1,
});
const regular = await repo.users.createFirstSsoUser({
id: "user-3",
username: "regular",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:provider:regular",
ssoProviderId: 1,
});
expect(first.isFirstUser).toBe(true);
@@ -157,7 +147,6 @@ describe("UserRepository and SessionRepository", () => {
username: "admin",
passwordHash: "hash",
isAdmin: true,
isOidc: false,
});
await repo.users.update("user-1", { isAdmin: false });
await repo.users.delete("user-1");
@@ -172,7 +161,6 @@ describe("UserRepository and SessionRepository", () => {
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
@@ -208,7 +196,6 @@ describe("UserRepository and SessionRepository", () => {
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
id: "session-1",
@@ -250,7 +237,6 @@ describe("UserRepository and SessionRepository", () => {
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
id: "session-1",
@@ -281,7 +267,6 @@ describe("UserRepository and SessionRepository", () => {
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
for (const id of ["keep", "drop-1", "drop-2"]) {
@@ -308,7 +293,6 @@ describe("UserRepository and SessionRepository", () => {
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
@@ -96,9 +96,8 @@ it.each([false, true])(
overwrite ? 19 : 71,
expect.objectContaining({ pluginSettings: host.pluginSettings }),
);
expect(JSON.parse(saved.terminalConfig)).toEqual({
macOptionIsMeta: false,
});
// The 2.8 terminalConfig reaches the plugin, never a host column.
expect(saved).not.toHaveProperty("terminalConfig");
},
);
@@ -284,7 +284,7 @@ describe("stripSensitiveFields", () => {
expect(result.hasKey).toBe(false);
});
it("detects sudo password stored only in nested terminalConfig", () => {
it("strips a sudo password nested in terminalConfig", () => {
const result = stripSensitiveFields({
name: "web",
terminalConfig: {
@@ -292,7 +292,7 @@ describe("stripSensitiveFields", () => {
sudoPassword: "nested-only-sudo",
},
});
expect(result.hasSudoPassword).toBe(true);
expect(result.hasSudoPassword).toBe(false);
expect(
(result.terminalConfig as Record<string, unknown>).sudoPassword,
).toBeUndefined();
@@ -483,25 +483,6 @@ describe("transformHostResponse terminal fields", () => {
expect(host.terminalConfig).toEqual({ keepaliveInterval: 20 });
});
it("reads the options out of terminal_config before the boot copy", () => {
const host = row({
sshOptions: null,
terminalConfig: JSON.stringify({ agentForwarding: true }),
});
expect(host.sshOptions).toEqual({ agentForwarding: true });
});
it("surfaces a 2.8 sudo password for the sanitizers to strip", () => {
const host = row({
sudoPassword: null,
terminalConfig: JSON.stringify({ sudoPassword: "legacy" }),
});
const stripped = stripSensitiveFields(host);
expect(stripped.hasSudoPassword).toBe(true);
expect(stripped).not.toHaveProperty("sudoPassword");
expect(JSON.stringify(stripped)).not.toContain("legacy");
});
it("hides the owner's agent socket from a shared recipient", () => {
const shared = sanitizeHostForRecipient(
row({
@@ -10,17 +10,11 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
}));
const { mergeStoredTerminalFields, parseTerminalConfig } =
const { mergeStoredSshOptions, parseTerminalConfig } =
await import("../../../database/routes/host-terminal-fields.js");
beforeEach(() => {
state.stored = {
terminalConfig: JSON.stringify({
theme: "nord",
sudoPassword: "legacy-sudo",
agentSocketPath: "/owner/agent",
startupSnippetId: 1,
}),
sshOptions: JSON.stringify({ agentSocketPath: "/owner/agent" }),
};
});
@@ -31,10 +25,10 @@ async function merge(
sshOptions?: string,
) {
const row: Record<string, unknown> = {
terminalConfig: "replaced wholesale",
terminalConfig: "from the payload",
...(sshOptions !== undefined ? { sshOptions } : {}),
};
const error = await mergeStoredTerminalFields(row, hostData, 1, "o", isOwner);
const error = await mergeStoredSshOptions(row, hostData, 1, "o", isOwner);
return { error, row };
}
@@ -47,43 +41,32 @@ describe("parseTerminalConfig", () => {
});
});
describe("mergeStoredTerminalFields", () => {
it("merges an editor save into the stored 2.8 keys instead of dropping them", async () => {
describe("mergeStoredSshOptions", () => {
it("never writes terminalConfig, which has no column", async () => {
const { error, row } = await merge({
terminalConfig: { startupSnippetId: 4 },
});
expect(error).toBeNull();
expect(JSON.parse(row.terminalConfig as string)).toEqual({
theme: "nord",
sudoPassword: "legacy-sudo",
agentSocketPath: "/owner/agent",
startupSnippetId: 4,
});
});
it("leaves terminal_config alone when the payload has none", async () => {
const { row } = await merge({});
expect(row).not.toHaveProperty("terminalConfig");
});
it("drops the legacy sudo copy once the owner saves one on its own", async () => {
const { row } = await merge({ sudoPassword: "" });
expect(JSON.parse(row.terminalConfig as string)).not.toHaveProperty(
"sudoPassword",
it("leaves an owner's SSH options as sent", async () => {
const { row } = await merge(
{},
true,
JSON.stringify({ agentSocketPath: "/x" }),
);
expect(JSON.parse(row.sshOptions as string)).toEqual({
agentSocketPath: "/x",
});
});
it("keeps the owner's private keys through a shared editor's save", async () => {
it("keeps the owner's private SSH options through a shared editor's save", async () => {
const { row } = await merge(
{ terminalConfig: { startupSnippetId: 2 } },
{},
false,
JSON.stringify({ keepaliveInterval: 9 }),
JSON.stringify({ keepaliveInterval: 9, agentSocketPath: "/editor" }),
);
expect(JSON.parse(row.terminalConfig as string)).toMatchObject({
sudoPassword: "legacy-sudo",
agentSocketPath: "/owner/agent",
startupSnippetId: 2,
});
expect(JSON.parse(row.sshOptions as string)).toEqual({
keepaliveInterval: 9,
agentSocketPath: "/owner/agent",
@@ -13,10 +13,7 @@ interface User {
id: string;
username: string;
isAdmin: boolean;
isOidc: boolean;
passwordHash: string;
oidcIdentifier?: string | null;
clientId?: string;
}
const h = vi.hoisted(() => ({
@@ -30,14 +27,21 @@ const h = vi.hoisted(() => ({
revoked: [] as string[],
}));
function withIdentities(user: User | undefined) {
if (!user) return null;
return {
...user,
isExternal: h.identities.some((row) => row.userId === user.id),
};
}
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => h.users.get(id) ?? null,
findById: async (id: string) => withIdentities(h.users.get(id)),
findByUsername: async (username: string) =>
[...h.users.values()].find((user) => user.username === username) ?? null,
update: async (id: string, changes: Partial<User>) => {
Object.assign(h.users.get(id)!, changes);
},
withIdentities(
[...h.users.values()].find((user) => user.username === username),
),
}),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async (userId: string) =>
@@ -84,7 +88,6 @@ beforeEach(async () => {
id: "admin",
username: "admin",
isAdmin: true,
isOidc: false,
passwordHash: "x",
},
],
@@ -94,9 +97,7 @@ beforeEach(async () => {
id: "sso",
username: "alice-sso",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "ldap:4:alice",
},
],
[
@@ -105,7 +106,6 @@ beforeEach(async () => {
id: "local",
username: "alice",
isAdmin: false,
isOidc: false,
passwordHash: "hash",
},
],
@@ -149,16 +149,12 @@ function post(path: string, body: unknown, user = "admin") {
}
describe("linking an external account to a password account", () => {
it("moves the identities and the old identifier, then removes the external account", async () => {
const response = await post("/link-oidc-to-password", {
oidcUserId: "sso",
it("moves the identities, then removes the external account", async () => {
const response = await post("/link-external-to-password", {
externalUserId: "sso",
targetUsername: "alice",
});
expect(response.status).toBe(200);
expect(h.users.get("local")).toMatchObject({
isOidc: true,
oidcIdentifier: "ldap:4:alice",
});
expect(h.identities).toEqual([
{ userId: "local", providerId: "ldap:4", subject: "alice" },
]);
@@ -166,13 +162,13 @@ describe("linking an external account to a password account", () => {
expect(h.deleted).toEqual(["sso"]);
});
it("takes the new path and field name", async () => {
const response = await post("/link-external-to-password", {
externalUserId: "sso",
it("no longer takes the 2.8 path and field name", async () => {
const response = await post("/link-oidc-to-password", {
oidcUserId: "sso",
targetUsername: "alice",
});
expect(response.status).toBe(200);
expect(h.identities[0].userId).toBe("local");
expect(response.status).toBe(404);
expect(h.identities[0].userId).toBe("sso");
});
it("refuses a target that already signs in externally", async () => {
@@ -186,8 +182,8 @@ describe("linking an external account to a password account", () => {
it("is for admins only", async () => {
const response = await post(
"/link-oidc-to-password",
{ oidcUserId: "sso", targetUsername: "alice" },
"/link-external-to-password",
{ externalUserId: "sso", targetUsername: "alice" },
"local",
);
expect(response.status).toBe(403);
@@ -195,8 +191,8 @@ describe("linking an external account to a password account", () => {
});
it("refuses a target that is not a password account", async () => {
const response = await post("/link-oidc-to-password", {
oidcUserId: "sso",
const response = await post("/link-external-to-password", {
externalUserId: "sso",
targetUsername: "alice-sso",
});
expect(response.status).toBe(400);
@@ -207,17 +203,13 @@ describe("linking an external account to a password account", () => {
id: "orig",
username: "bob",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-9",
});
h.users.set("dup", {
id: "dup",
username: "bob-1",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: null,
});
h.identities.push(
{ userId: "orig", providerId: "legacy-oidc", subject: "sub-9" },
@@ -231,7 +223,6 @@ describe("linking an external account to a password account", () => {
expect(response.status).toBe(200);
expect(h.deleted).toEqual(["dup"]);
expect(h.users.get("orig")!.oidcIdentifier).toBe("sub-9");
expect(
h.identities
.filter((row) => row.userId === "orig")
@@ -244,9 +235,7 @@ describe("linking an external account to a password account", () => {
id: "other",
username: "carol",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-x",
});
h.identities.push({
userId: "other",
@@ -264,22 +253,6 @@ describe("linking an external account to a password account", () => {
describe("unlinking", () => {
it("drops the identities so the provider no longer signs in as the user", async () => {
await post("/link-oidc-to-password", {
oidcUserId: "sso",
targetUsername: "alice",
});
const response = await post("/unlink-oidc-from-password", {
userId: "local",
});
expect(response.status).toBe(200);
expect(h.users.get("local")).toMatchObject({
isOidc: false,
oidcIdentifier: null,
});
expect(h.identities).toEqual([]);
});
it("takes the new path", async () => {
await post("/link-external-to-password", {
externalUserId: "sso",
targetUsername: "alice",
@@ -291,8 +264,15 @@ describe("unlinking", () => {
expect(h.identities).toEqual([]);
});
it("refuses to leave an account with no way in", async () => {
it("no longer takes the 2.8 path", async () => {
const response = await post("/unlink-oidc-from-password", {
userId: "local",
});
expect(response.status).toBe(404);
});
it("refuses to leave an account with no way in", async () => {
const response = await post("/unlink-external-from-password", {
userId: "sso",
});
expect(response.status).toBe(400);
@@ -52,6 +52,14 @@ describe("pickResolvedPassword", () => {
});
});
function identities(rows: Array<{ id: number; subject: string }>) {
vi.doMock("../../database/repositories/factory.js", () => ({
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async () => rows,
}),
}));
}
describe("expandExternalUsername", () => {
beforeEach(() => {
vi.resetModules();
@@ -62,29 +70,19 @@ describe("expandExternalUsername", () => {
expect(await expandExternalUsername(undefined, "user-1")).toBeUndefined();
});
it("expands the placeholder with the user's OIDC identifier", async () => {
vi.doMock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async () => ({ oidcIdentifier: "jdoe" }),
}),
}));
it("expands the placeholder with the subject of the user's first identity", async () => {
identities([
{ id: 4, subject: "later" },
{ id: 2, subject: "jdoe" },
]);
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
expect(await expand("$external.username", "user-1")).toBe("jdoe");
expect(
await expand("$external.username-$oidc.preferred_username", "user-1"),
).toBe("jdoe-jdoe");
expect(await expand("$external.username-x", "user-1")).toBe("jdoe-x");
});
it("leaves the placeholder as-is when the user has no OIDC identifier", async () => {
vi.doMock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async () => ({ oidcIdentifier: null }),
}),
}));
it("no longer reads the 2.8 spelling", async () => {
identities([{ id: 1, subject: "jdoe" }]);
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
@@ -92,81 +90,25 @@ describe("expandExternalUsername", () => {
);
});
it("leaves the placeholder as-is when the user has no external sign-in", async () => {
identities([]);
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$external.username", "user-1")).toBe(
"$external.username",
);
});
it("returns the username unchanged when the DB lookup throws", async () => {
vi.doMock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => {
createCurrentUserAuthRepository: () => {
throw new Error("DB unavailable");
},
}));
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
"$oidc.preferred_username",
);
});
it("strips the ldap:{providerId}: prefix for genuine LDAP users", async () => {
vi.doMock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async () => ({
oidcIdentifier: "ldap:1:jdoe",
ssoProviderId: 1,
}),
}),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async () => [
{ providerId: "ldap:1", subject: "jdoe" },
],
}),
}));
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
});
it("does not strip a spoofed ldap: identifier from a non-LDAP provider", async () => {
vi.doMock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async () => ({
oidcIdentifier: "ldap:1:admin",
ssoProviderId: 1,
}),
}),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async () => [
{ providerId: "1", subject: "ldap:1:admin" },
],
}),
}));
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
"ldap:1:admin",
);
});
it("does not strip when the embedded provider id is not the user's provider", async () => {
vi.doMock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async () => ({
oidcIdentifier: "ldap:1:admin",
ssoProviderId: 5,
}),
}),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async () => [
{ providerId: "ldap:5", subject: "admin" },
],
}),
}));
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
"ldap:1:admin",
expect(await expand("$external.username", "user-1")).toBe(
"$external.username",
);
});
});
@@ -1,12 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
vi.mock("../../../database/repositories/factory.js", () => ({}));
const rows = vi.hoisted(() => ({
owned: [] as Array<Record<string, unknown>>,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostDefaultsRepository: () => ({
listOwnedBy: async () => rows.owned,
}),
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => [],
}));
const { validateLevelChange } =
const { folderCredentialIds, validateLevelChange } =
await import("../../../hosts/defaults/service.js");
const { buildCatalog } = await import("../../../hosts/defaults/catalog.js");
@@ -113,3 +121,39 @@ describe("validateLevelChange", () => {
expect(change.errors["core.jumpHosts"]).toBeDefined();
});
});
describe("folderCredentialIds", () => {
it("reads each folder's credential from its auth default", async () => {
rows.owned = [
{
level: "folder",
folderId: 4,
namespace: "core",
key: "auth",
value: JSON.stringify({ authType: "credential", credentialId: 7 }),
},
{
level: "folder",
folderId: 5,
namespace: "core",
key: "auth",
value: JSON.stringify({ authType: "none" }),
},
{
level: "user",
folderId: null,
namespace: "core",
key: "auth",
value: JSON.stringify({ authType: "credential", credentialId: 9 }),
},
{
level: "folder",
folderId: 6,
namespace: "fx",
key: "auth",
value: JSON.stringify({ authType: "credential", credentialId: 2 }),
},
];
expect([...(await folderCredentialIds("u1"))]).toEqual([[4, 7]]);
});
});
+5 -69
View File
@@ -7,7 +7,6 @@ const state = vi.hoisted(() => ({
overrideCredentialId: null as number | null,
credentials: new Map<string, Record<string, unknown>>(),
auditCalls: [] as Record<string, unknown>[],
folderCredentialId: null as number | null,
sharedSecret: null as Record<string, unknown> | null,
activeHostAccessId: null as number | null,
snapshotForUserCalls: [] as Record<string, unknown>[],
@@ -20,7 +19,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
findHostById: async () => (state.host ? { ...state.host } : null),
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
state.credentials.get(`${credentialId}:${userId}`) ?? null,
findFolderCredentialId: async () => state.folderCredentialId,
}),
createCurrentSharedHostAuthOverrideRepository: () => ({
findCredentialId: async () => state.overrideCredentialId,
@@ -114,7 +112,6 @@ function baseHost(overrides: Record<string, unknown> = {}) {
sudoPassword: "owner-sudo",
jumpHosts: null,
tunnelConnections: null,
terminalConfig: null,
socks5ProxyChain: null,
quickActions: null,
overrideCredentialUsername: false,
@@ -129,7 +126,6 @@ beforeEach(() => {
state.overrideCredentialId = null;
state.credentials.clear();
state.auditCalls = [];
state.folderCredentialId = null;
state.sharedSecret = null;
state.activeHostAccessId = null;
state.snapshotForUserCalls = [];
@@ -171,35 +167,6 @@ describe("resolveHostById", () => {
expect(host.sudoPassword).toBe("owner-sudo");
});
it("falls back to the host's folder-assigned credential when none is set on the host", async () => {
state.host = baseHost({
authType: "credential",
credentialId: null,
folder: "switches",
username: "",
password: null,
});
state.folderCredentialId = 11;
state.credentials.set("11:owner", {
id: 11,
username: "folder-user",
authType: "password",
password: "folder-pass",
privateKey: null,
key: null,
keyPassword: null,
keyType: null,
});
const host = (await resolveHostById(42, "owner")) as Record<
string,
unknown
>;
expect(host.password).toBe("folder-pass");
expect(host.username).toBe("folder-user");
expect(host.authType).toBe("password");
});
it("prefers the host's own credential over its folder's credential", async () => {
state.host = baseHost({
authType: "credential",
@@ -208,7 +175,6 @@ describe("resolveHostById", () => {
username: "",
password: null,
});
state.folderCredentialId = 11;
state.credentials.set("9:owner", {
id: 9,
username: "host-user",
@@ -281,7 +247,7 @@ describe("resolveHostById", () => {
state.host = baseHost({
authType: "agent",
password: null,
terminalConfig: JSON.stringify({
sshOptions: JSON.stringify({
agentSocketPath: "/run/user/1000/ssh-agent.sock",
}),
});
@@ -294,7 +260,7 @@ describe("resolveHostById", () => {
authType: "agent",
password: null,
shareSshAuth: true,
terminalConfig: JSON.stringify({
sshOptions: JSON.stringify({
agentSocketPath: "/run/user/1000/ssh-agent.sock",
}),
});
@@ -307,9 +273,6 @@ describe("resolveHostById", () => {
expect(host.sshOptions).toEqual({
agentSocketPath: "/run/user/1000/ssh-agent.sock",
});
expect(host.terminalConfig).toEqual({
agentSocketPath: "/run/user/1000/ssh-agent.sock",
});
});
it("uses the recipient's credential instead of the owner's authentication", async () => {
@@ -436,10 +399,7 @@ describe("resolveHostById", () => {
password: "stale-owner-password",
key: "stale-owner-key",
credentialId: null,
terminalConfig: JSON.stringify({
theme: "termix",
sudoPassword: "owner-sudo",
}),
sudoPassword: "owner-sudo",
});
const host = (await resolveHostById(42, "recipient")) as Record<
string,
@@ -448,37 +408,13 @@ describe("resolveHostById", () => {
expect(host.password).toBeNull();
expect(host.key).toBeNull();
expect(host.credentialId).toBeNull();
// The owner's legacy sudo password never reaches a recipient.
expect(host.terminalConfig).toEqual({ theme: "termix" });
// The owner's sudo password never reaches a recipient.
expect(host.sudoPassword).toBeNull();
});
it("hands the owner a 2.8 sudo password and SSH options kept in terminal_config", async () => {
state.host = baseHost({
sudoPassword: null,
sshOptions: null,
terminalConfig: JSON.stringify({
sudoPassword: "legacy-sudo",
keepaliveInterval: 12,
theme: "nord",
}),
});
const host = (await resolveHostById(42, "owner")) as Record<
string,
unknown
>;
expect(host.sudoPassword).toBe("legacy-sudo");
expect(host.sshOptions).toEqual({ keepaliveInterval: 12 });
expect(host.terminalConfig).toEqual({
keepaliveInterval: 12,
theme: "nord",
});
});
it("prefers the ssh_options column once it is filled", async () => {
it("reads the ssh_options column", async () => {
state.host = baseHost({
sshOptions: JSON.stringify({ keepaliveCountMax: 3 }),
terminalConfig: JSON.stringify({ keepaliveInterval: 12 }),
});
const host = (await resolveHostById(42, "owner")) as Record<
string,
@@ -199,7 +199,7 @@ beforeEach(() => {
});
describe("a plugin's external identity", () => {
it("carries the 2.8 identifier, role map and logout claims into core", async () => {
it("carries the role map and logout claims into core", async () => {
setup();
const identity = await getLoginMethod("corp-sso")!.callback!(
fakeRequest() as never,
@@ -211,7 +211,6 @@ describe("a plugin's external identity", () => {
expect(result.kind).toBe("session");
const [user] = [...h.state.users.values()];
expect(user).toMatchObject({ oidcIdentifier: "sub-1", ssoProviderId: 3 });
expect(h.state.identities).toContainEqual(
expect.objectContaining({ providerId: "3", subject: "sub-1" }),
);
@@ -219,9 +218,7 @@ describe("a plugin's external identity", () => {
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
user.id,
expect.objectContaining({
ssoProviderId: 3,
oidcSub: "sub-1",
oidcSid: "sid-1",
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
}),
);
});
@@ -238,29 +235,6 @@ describe("a plugin's external identity", () => {
expect(mapped).not.toHaveProperty("unlockError", "spoofed");
expect(mapped.rateLimitUsername).toBeUndefined();
});
it("finds a 2.8 user by the old identifier and links the identity", async () => {
h.state.users.set("u-1", {
id: "u-1",
username: "Alice",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "sub-1",
});
setup();
const identity = await getLoginMethod("corp-sso")!.callback!(
fakeRequest() as never,
);
await runLogin(fakeRequest() as never, identity, {
methodId: "corp-sso",
rememberMe: false,
});
expect(h.state.users.size).toBe(1);
expect(h.state.identities).toContainEqual(
expect.objectContaining({ userId: "u-1", providerId: "3" }),
);
});
});
describe("ctx.auth.completeRedirectLogin", () => {
@@ -306,7 +280,7 @@ describe("the other ctx.auth helpers", () => {
const { auth } = setup();
expect(await auth.revokeSessions({ providerId: 3, sid: "sid-1" })).toBe(1);
expect(h.manager.revokeSessionsByExternalSession).toHaveBeenCalledWith({
ssoProviderId: 3,
providerId: 3,
sub: null,
sid: "sid-1",
});
@@ -402,8 +376,13 @@ describe("form methods and the external second factor setting", () => {
username: "Bob",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:4:bob",
});
h.state.identities.push({
id: 1,
userId: "u-bob",
providerId: "ldap:4",
subject: "bob",
email: null,
});
h.state.factors.push({
userId: "u-bob",
+1 -1
View File
@@ -474,7 +474,7 @@ describe("ctx.hosts", () => {
password: "secret",
key: "PRIVATE",
autostartPassword: "x",
terminalConfig: '{"keepaliveInterval":5,"fontSize":14}',
sshOptions: '{"keepaliveInterval":5}',
enableDocker: true,
jumpHosts: "[]",
},
+10 -47
View File
@@ -107,7 +107,6 @@ function addUser(overrides: Record<string, unknown> = {}) {
username: "alice",
passwordHash: bcrypt.hashSync(PASSWORD, 4),
isAdmin: false,
isOidc: false,
...overrides,
};
h.state.users.set(user.id as string, user as never);
@@ -196,7 +195,6 @@ describe("external sign-in cannot claim another account", () => {
email: null,
name: "Alice",
legacyIdentifier: "shared-id",
ssoProviderId: 3,
};
beforeEach(() => {
@@ -209,7 +207,7 @@ describe("external sign-in cannot claim another account", () => {
external: true,
}),
);
h.state.settings.set("oidc_auto_provision", "true");
h.state.settings.set("external_auto_provision", "true");
});
async function signIn(identity: Record<string, unknown> = external) {
@@ -223,55 +221,21 @@ describe("external sign-in cannot claim another account", () => {
return h.state.identities.find((row) => row.subject === "sub-1")?.userId;
}
it("never links a local password account by its old identifier", async () => {
addUser({ id: "local", oidcIdentifier: "shared-id", isOidc: false });
await signIn();
expect(linkedTo()).not.toBe("local");
});
it("never links an account that already has an identity", async () => {
addUser({
id: "taken",
username: "bob",
passwordHash: "",
isOidc: true,
oidcIdentifier: "shared-id",
});
it("never links an account that has no identity for this provider", async () => {
addUser({ id: "local", username: "Alice" });
addUser({ id: "sso", username: "bob", passwordHash: "" });
h.state.identities.push({
id: 1,
userId: "taken",
userId: "sso",
providerId: "9",
subject: "bob",
subject: "sub-1",
email: null,
});
await signIn();
expect(linkedTo()).not.toBe("taken");
});
it("never links an account from a different provider", async () => {
addUser({
id: "other",
username: "bob",
passwordHash: "",
isOidc: true,
oidcIdentifier: "shared-id",
ssoProviderId: 7,
});
await signIn();
expect(linkedTo()).not.toBe("other");
});
it("still links a genuine 2.8 account on its first sign-in", async () => {
addUser({
id: "legacy",
username: "Alice",
passwordHash: "",
isOidc: true,
oidcIdentifier: "shared-id",
ssoProviderId: 3,
});
await signIn();
expect(linkedTo()).toBe("legacy");
expect(linkedTo()).not.toBe("local");
expect(
h.state.identities.find((row) => row.providerId === "3")?.userId,
).not.toBe("sso");
});
it("never gives a new account a local user's username", async () => {
@@ -290,7 +254,6 @@ describe("external sign-in cannot claim another account", () => {
id: "sso-user",
username: "carol",
passwordHash: "",
isOidc: true,
});
h.state.identities.push({
id: 1,
@@ -398,7 +398,7 @@ describe("host field options", () => {
host: { enableKey: "on", enableLabelKey: "k", fields, ...extra },
});
it("accepts enableDefault, defaultFrom, shareRead and ownerOnly", () => {
it("accepts enableDefault, shareRead and ownerOnly", () => {
expect(
validateManifest(
host(
@@ -407,7 +407,6 @@ describe("host field options", () => {
key: "mode",
type: "boolean",
labelKey: "k",
defaultFrom: "defaultOn",
shareRead: "edit",
ownerOnly: true,
},
@@ -418,7 +417,7 @@ describe("host field options", () => {
).toEqual([]);
});
it("still accepts an old defaultFrom, but only as a string", () => {
it("rejects the removed defaultFrom", () => {
expect(
validateManifest(
host([
@@ -429,11 +428,6 @@ describe("host field options", () => {
defaultFrom: "missing",
},
]),
),
).toEqual([]);
expect(
validateManifest(
host([{ key: "mode", type: "boolean", labelKey: "k", defaultFrom: 1 }]),
).join(),
).toMatch(/defaultFrom/);
});
@@ -1,144 +0,0 @@
/**
* The ACME settings migration: a 2.8 acme_ssl_settings row comes through into
* the acme-ssl plugin's admin settings, the Cloudflare token stays encrypted
* and leaves the old row, and a second run changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
legacy: {} as Record<string, string>,
pluginRows: [] as Array<{
key: string;
value: string | null;
encrypted: boolean;
}>,
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "acme-ssl" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.legacy[key] ?? null,
set: async (key: string, value: string) => {
state.legacy[key] = value;
},
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
) => state.pluginRows.find((row) => row.key === key) ?? null,
set: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.pluginRows = state.pluginRows.filter((row) => row.key !== key);
state.pluginRows.push({ key, value, encrypted });
},
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `enc:${value}`,
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runAcmeSslSettingsMigration } from "../../upgrade/acme-ssl-settings-migration.js";
const value = (key: string) => {
const row = state.pluginRows.find((candidate) => candidate.key === key);
return row?.value == null ? undefined : JSON.parse(row.value);
};
beforeEach(() => {
state.legacy = {
acme_ssl_settings: JSON.stringify({
enabled: true,
domain: "termix.example.com",
email: "admin@example.com",
challengeType: "dns-cloudflare",
cloudflareToken: "cf-token",
lastIssuedAt: "2026-01-01T00:00:00.000Z",
}),
};
state.pluginRows = [];
state.pluginInstalled = true;
});
describe("runAcmeSslSettingsMigration", () => {
it("moves every field and encrypts the token", async () => {
const result = await runAcmeSslSettingsMigration();
expect(result.moved.sort()).toEqual(
[
"autoRenew",
"challengeType",
"cloudflareToken",
"domain",
"email",
].sort(),
);
expect(value("domain")).toBe("termix.example.com");
expect(value("email")).toBe("admin@example.com");
expect(value("challengeType")).toBe("dns-cloudflare");
expect(value("autoRenew")).toBe(true);
expect(value("cloudflareToken")).toBe("enc:cf-token");
expect(
state.pluginRows.find((row) => row.key === "cloudflareToken")?.encrypted,
).toBe(true);
const left = JSON.parse(state.legacy.acme_ssl_settings);
expect(left.cloudflareToken).toBeUndefined();
expect(left.domain).toBe("termix.example.com");
});
it("maps the webroot challenge and turns renewal off for manual", async () => {
state.legacy.acme_ssl_settings = JSON.stringify({
enabled: true,
domain: "a.example",
email: "a@example.com",
challengeType: "http-webroot",
});
await runAcmeSslSettingsMigration();
expect(value("challengeType")).toBe("http-01");
expect(value("autoRenew")).toBe(true);
state.pluginRows = [];
state.legacy.acme_ssl_settings = JSON.stringify({
enabled: true,
challengeType: "manual",
});
await runAcmeSslSettingsMigration();
expect(value("autoRenew")).toBe(false);
expect(value("challengeType")).toBeUndefined();
});
it("changes nothing on a second run or over values already set", async () => {
await runAcmeSslSettingsMigration();
const snapshot = JSON.stringify(state.pluginRows);
const second = await runAcmeSslSettingsMigration();
expect(second.moved).toEqual([]);
expect(JSON.stringify(state.pluginRows)).toBe(snapshot);
});
it("does nothing before the plugin row exists or without a legacy row", async () => {
state.pluginInstalled = false;
expect((await runAcmeSslSettingsMigration()).moved).toEqual([]);
state.pluginInstalled = true;
state.legacy = {};
expect((await runAcmeSslSettingsMigration()).moved).toEqual([]);
expect(state.pluginRows).toEqual([]);
});
});
@@ -1,10 +1,8 @@
/**
* The AI settings migration.
*
* An upgrade must be lossless: the admin switch, the private endpoint list,
* every user's opt-in, every host's switch and every provider key have to
* reach the ai plugin, keys encrypted and gone from the table. Running it
* twice must not duplicate or clobber anything.
* Every provider key has to reach the ai plugin, encrypted and gone from the
* table. Running it twice must not duplicate or clobber anything.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -127,10 +125,7 @@ vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
allowlistToText,
runAiSettingsMigration,
} from "../../upgrade/ai-settings-migration.js";
import { runAiSettingsMigration } from "../../upgrade/ai-settings-migration.js";
const value = (scope: string, scopeId: string | null, key: string) => {
const row = state.pluginRows.find(
@@ -167,30 +162,7 @@ beforeEach(() => {
state.deks = new Set(["u1", "u2"]);
});
describe("allowlistToText", () => {
it("turns the stored JSON array into one host per line", () => {
expect(allowlistToText('[" a ", "", "b"]')).toBe("a\nb");
expect(allowlistToText("[]")).toBe("");
expect(allowlistToText("nope")).toBeNull();
});
});
describe("runAiSettingsMigration", () => {
it("moves admin, user and host settings", async () => {
await runAiSettingsMigration();
expect(value("admin", null, "globallyEnabled")).toBe(true);
expect(value("admin", null, "privateEndpoints")).toBe(
"localhost\nollama.lan",
);
expect(value("user", "u1", "enabled")).toBe(true);
expect(value("user", "u1", "allowReadOnlyCommands")).toBe(false);
// Never asked stays unset, which the plugin reads as not enabled.
expect(value("user", "u2", "enabled")).toBeUndefined();
expect(value("host", "5", "enableAiAssistant")).toBe(true);
expect(value("host", "6", "enableAiAssistant")).toBeUndefined();
});
it("moves each provider key into its owner's secrets, encrypted, and clears the column", async () => {
await runAiSettingsMigration();
@@ -235,18 +207,18 @@ describe("runAiSettingsMigration", () => {
expect(JSON.stringify(state.pluginRows)).toBe(first);
});
it("does not overwrite a value the plugin already has", async () => {
it("does not overwrite a key the plugin already has", async () => {
state.pluginRows.push({
pluginId: "ai",
scope: "admin",
scopeId: null,
key: "globallyEnabled",
value: "false",
encrypted: false,
scope: "secret",
scopeId: "u1",
key: "provider:3",
value: JSON.stringify("sysenc:kept"),
encrypted: true,
});
await runAiSettingsMigration();
expect(value("admin", null, "globallyEnabled")).toBe(false);
expect(value("secret", "u1", "provider:3")).toBe("sysenc:kept");
});
it("does nothing until the plugin row exists", async () => {
@@ -1,197 +0,0 @@
/**
* The Docker host settings migration.
*
* An upgrade must be lossless: every host with Docker on, and every host on
* Podman, must come through into the docker plugin's host settings, whatever
* shape the engine returned the flag in. Running it twice must not duplicate
* or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pluginRows: [] as Array<{
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
pluginInstalled: true,
columnsGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "docker" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
state.pluginRows.filter(
(row) => row.scope === scope && row.scopeId === scopeId,
),
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
);
if (existing) {
existing.value = value;
return;
}
state.pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async () => {
if (state.columnsGone) throw new Error("no such column: enable_docker");
return state.hostRows;
},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
dockerSettingsFromRow,
runDockerSettingsMigration,
} from "../../upgrade/docker-settings-migration.js";
const hostValues = (hostId: number) =>
Object.fromEntries(
state.pluginRows
.filter((row) => row.scope === "host" && row.scopeId === String(hostId))
.map((row) => [row.key, JSON.parse(row.value ?? "null")]),
);
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.pluginInstalled = true;
state.columnsGone = false;
});
describe("dockerSettingsFromRow", () => {
it("reads SQLite integers, real booleans and strings", () => {
expect(
dockerSettingsFromRow({ id: 1, enable_docker: 1, docker_config: null }),
).toEqual({ enableDocker: true });
expect(
dockerSettingsFromRow({
id: 1,
enable_docker: true,
docker_config: null,
}),
).toEqual({ enableDocker: true });
expect(
dockerSettingsFromRow({ id: 1, enable_docker: 0, docker_config: null }),
).toEqual({});
expect(
dockerSettingsFromRow({
id: 1,
enable_docker: false,
docker_config: null,
}),
).toEqual({});
});
it("keeps a Podman runtime and ignores the default or broken config", () => {
expect(
dockerSettingsFromRow({
id: 1,
enable_docker: 1,
docker_config: '{"runtime":"podman"}',
}),
).toEqual({ enableDocker: true, containerRuntime: "podman" });
expect(
dockerSettingsFromRow({
id: 1,
enable_docker: 1,
docker_config: '{"runtime":"docker"}',
}),
).toEqual({ enableDocker: true });
expect(
dockerSettingsFromRow({
id: 1,
enable_docker: 1,
docker_config: "{oops",
}),
).toEqual({ enableDocker: true });
});
});
describe("runDockerSettingsMigration", () => {
it("moves every host's docker options and skips hosts with nothing set", async () => {
state.hostRows = [
{ id: 1, enable_docker: 1, docker_config: null },
{ id: 2, enable_docker: 1, docker_config: '{"runtime":"podman"}' },
{ id: 3, enable_docker: 0, docker_config: null },
];
const result = await runDockerSettingsMigration();
expect(result).toEqual({ hostsMoved: 2, hostsSkipped: 0 });
expect(hostValues(1)).toEqual({ enableDocker: true });
expect(hostValues(2)).toEqual({
enableDocker: true,
containerRuntime: "podman",
});
expect(hostValues(3)).toEqual({});
});
it("changes nothing when run twice", async () => {
state.hostRows = [
{ id: 1, enable_docker: 1, docker_config: '{"runtime":"podman"}' },
];
await runDockerSettingsMigration();
const before = JSON.stringify(state.pluginRows);
const second = await runDockerSettingsMigration();
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 1 });
expect(JSON.stringify(state.pluginRows)).toBe(before);
});
it("keeps a value already saved in the plugin", async () => {
state.pluginRows.push({
pluginId: "docker",
scope: "host",
scopeId: "1",
key: "enableDocker",
value: "false",
});
state.hostRows = [{ id: 1, enable_docker: 1, docker_config: null }];
await runDockerSettingsMigration();
expect(hostValues(1)).toEqual({ enableDocker: false });
});
it("does nothing before the plugin row exists or once the columns are gone", async () => {
state.hostRows = [{ id: 1, enable_docker: 1, docker_config: null }];
state.pluginInstalled = false;
expect(await runDockerSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
state.pluginInstalled = true;
state.columnsGone = true;
expect(await runDockerSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
expect(state.pluginRows).toEqual([]);
});
});
@@ -1,130 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createAuthState, type AuthState } from "../auth/auth-test-helpers.js";
const h = vi.hoisted(() => ({ state: null as unknown as AuthState }));
vi.mock("../../database/repositories/factory.js", async () => {
const helpers = await import("../auth/auth-test-helpers.js");
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
return Object.fromEntries(
names.map((name) => [
name,
(...args: unknown[]) =>
(
helpers.fakeFactory(h.state) as Record<
string,
(...a: unknown[]) => unknown
>
)[name](...args),
]),
);
});
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
const { runExternalIdentityMigration, parseLegacyIdentifier } =
await import("../../upgrade/external-identity-migration.js");
function user(id: string, extra: Record<string, unknown>) {
h.state.users.set(id, {
id,
username: id,
passwordHash: "",
isAdmin: false,
isOidc: true,
...extra,
});
}
beforeEach(() => {
h.state = createAuthState();
});
describe("parseLegacyIdentifier", () => {
it("splits the prefixed forms and keeps bare subjects on their provider", () => {
expect(parseLegacyIdentifier("ldap:4:bob", null)).toEqual({
providerId: "ldap:4",
subject: "bob",
});
expect(parseLegacyIdentifier("github:7:42", null)).toEqual({
providerId: "7",
subject: "42",
});
expect(parseLegacyIdentifier("ldap:4:cn=a:b", null)).toEqual({
providerId: "ldap:4",
subject: "cn=a:b",
});
expect(parseLegacyIdentifier("sub-123", 3)).toEqual({
providerId: "3",
subject: "sub-123",
});
expect(parseLegacyIdentifier("sub-123", null)).toEqual({
providerId: "legacy-oidc",
subject: "sub-123",
});
expect(parseLegacyIdentifier("github:null:42", null)).toEqual({
providerId: "legacy-oidc",
subject: "42",
});
});
});
describe("runExternalIdentityMigration", () => {
it("moves every identifier without touching the old data", async () => {
user("ldap-user", { oidcIdentifier: "ldap:4:bob", ssoProviderId: 4 });
user("gh-user", { oidcIdentifier: "github:7:42", ssoProviderId: 7 });
user("oidc-user", {
oidcIdentifier: "sub-123",
ssoProviderId: 3,
});
user("env-user", { oidcIdentifier: "someone@example.com" });
user("local", { isOidc: false, passwordHash: "hash" });
const result = await runExternalIdentityMigration();
expect(result).toEqual({ identities: 4, skipped: 0 });
expect(
h.state.identities.map(({ userId, providerId, subject }) => ({
userId,
providerId,
subject,
})),
).toEqual([
{ userId: "ldap-user", providerId: "ldap:4", subject: "bob" },
{ userId: "gh-user", providerId: "7", subject: "42" },
{ userId: "oidc-user", providerId: "3", subject: "sub-123" },
{
userId: "env-user",
providerId: "legacy-oidc",
subject: "someone@example.com",
},
]);
// Lossless: the old column stays.
expect(h.state.users.get("ldap-user")!.oidcIdentifier).toBe("ldap:4:bob");
});
it("is idempotent", async () => {
user("ldap-user", { oidcIdentifier: "ldap:4:bob" });
await runExternalIdentityMigration();
const second = await runExternalIdentityMigration();
expect(second).toEqual({ identities: 0, skipped: 1 });
expect(h.state.identities).toHaveLength(1);
});
it("does not recreate a legacy-oidc link that was moved to its provider", async () => {
user("oidc-user", { oidcIdentifier: "sub-1" });
h.state.identities.push({
id: 1,
userId: "oidc-user",
providerId: "1",
subject: "sub-1",
email: null,
} as never);
const result = await runExternalIdentityMigration();
expect(result).toEqual({ identities: 0, skipped: 1 });
expect(h.state.identities).toHaveLength(1);
});
});
@@ -1,163 +0,0 @@
/**
* The file-manager host-columns migration.
*
* An upgrade must be lossless: a host that had file manager disabled, a
* custom default path, or SCP legacy mode on must keep that configuration
* once the ssh_data columns are dropped. Running it twice must not duplicate
* or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_file_manager: boolean;
default_path: string | null;
scp_legacy: boolean;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter(
(row) =>
row.enable_file_manager === false ||
row.default_path !== null ||
row.scp_legacy,
),
}),
}));
const { runFileManagerSettingsMigration } =
await import("../../upgrade/file-manager-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runFileManagerSettingsMigration", () => {
it("moves a host with file manager disabled into plugin settings", async () => {
hostRows.push({
id: 1,
enable_file_manager: false,
default_path: "/srv/app",
scp_legacy: true,
});
const result = await runFileManagerSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableFileManager")).toBe(false);
expect(storedValue(1, "defaultPath")).toBe("/srv/app");
expect(storedValue(1, "scpLegacy")).toBe(true);
});
it("does nothing on a fresh install with no non-default hosts", async () => {
const result = await runFileManagerSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({
id: 2,
enable_file_manager: false,
default_path: null,
scp_legacy: false,
});
await runFileManagerSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runFileManagerSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("moves each host independently", async () => {
hostRows.push(
{
id: 1,
enable_file_manager: false,
default_path: null,
scp_legacy: false,
},
{
id: 2,
enable_file_manager: true,
default_path: "/data",
scp_legacy: false,
},
);
const result = await runFileManagerSettingsMigration();
expect(result.moved).toBe(2);
expect(storedValue(1, "enableFileManager")).toBe(false);
expect(storedValue(2, "defaultPath")).toBe("/data");
});
});
@@ -1,311 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type PluginRow = {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
};
const db = vi.hoisted(() => ({
settings: {} as Record<string, string>,
pluginRows: [] as PluginRow[],
defaults: [] as Array<{
scope: Record<string, unknown>;
namespace: string;
key: string;
value: string;
}>,
hosts: [] as Array<Record<string, unknown>>,
folders: [] as Array<{ id: number; userId: string; credentialId: number }>,
folderCredential: new Map<string, number>(),
}));
const manifests = vi.hoisted(() => [
{
id: "ssh-terminal",
contributes: {
settings: {
host: {
fields: [
{ key: "fontSize", type: "number", default: 14 },
{ key: "theme", type: "string", default: "termix" },
{ key: "localEcho", type: "select", default: "auto" },
{ key: "linkClickBehavior", type: "select", default: "confirm" },
],
},
},
},
},
{
id: "remote-desktop",
contributes: {
settings: {
host: {
fields: [{ key: "colorDepth", type: "select", default: "inherit" }],
},
},
},
},
{ id: "host-metrics", contributes: { settings: { host: { fields: [] } } } },
]);
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => manifests,
}));
vi.mock("../../database/db/schema.js", () => ({
sshFolders: { id: "id", userId: "userId", credentialId: "credentialId" },
}));
vi.mock("../../database/repositories/factory.js", () => {
const scopeKey = (scope: Record<string, unknown>) =>
scope.level === "admin"
? "admin"
: scope.level === "user"
? `u:${scope.userId}`
: `f:${scope.folderId}`;
return {
createCurrentSettingsRepository: () => ({
get: async (key: string) => db.settings[key] ?? null,
set: async (key: string, value: string) => {
db.settings[key] = value;
},
}),
createCurrentPluginSettingsRepository: () => ({
listByKey: async (pluginId: string, scope: string, key: string) =>
db.pluginRows.filter(
(row) =>
row.pluginId === pluginId && row.scope === scope && row.key === key,
),
getAll: async (pluginId: string, scope: string, scopeId: string) =>
db.pluginRows.filter(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId,
),
set: async (
pluginId: string,
scope: string,
scopeId: string,
key: string,
value: string,
) => {
db.pluginRows = db.pluginRows.filter(
(row) =>
!(
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key
),
);
db.pluginRows.push({ pluginId, scope, scopeId, key, value });
},
delete: async (
pluginId: string,
scope: string,
scopeId: string,
key: string,
) => {
db.pluginRows = db.pluginRows.filter(
(row) =>
!(
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key
),
);
},
}),
createCurrentHostDefaultsRepository: () => ({
listScope: async (scope: Record<string, unknown>) =>
db.defaults.filter((row) => scopeKey(row.scope) === scopeKey(scope)),
apply: async (
scope: Record<string, unknown>,
set: Array<{ namespace: string; key: string; value: string }>,
) => {
for (const entry of set) db.defaults.push({ scope, ...entry });
},
listHosts: async () => db.hosts,
updateHosts: async (
patches: Array<{ id: number; values: Record<string, unknown> }>,
) => {
for (const patch of patches) {
Object.assign(
db.hosts.find((host) => host.id === patch.id)!,
patch.values,
);
}
},
}),
createCurrentHostResolutionRepository: () => ({
findFolderCredentialId: async (_userId: string, folder: string) =>
db.folderCredential.get(folder) ?? null,
}),
createCurrentRepositoryContext: () => ({
drizzle: {
select: () => ({
from: () => ({ where: async () => db.folders }),
}),
},
}),
};
});
const { runHostDefaultsMigration } =
await import("../../upgrade/host-defaults-migration.js");
function level(namespace: string, key: string, scopeLevel: string) {
const row = db.defaults.find(
(entry) =>
entry.namespace === namespace &&
entry.key === key &&
entry.scope.level === scopeLevel,
);
return row ? JSON.parse(row.value) : undefined;
}
function hostValue(hostId: number, pluginId: string, key: string) {
const row = db.pluginRows.find(
(entry) =>
entry.pluginId === pluginId &&
entry.scope === "host" &&
entry.scopeId === String(hostId) &&
entry.key === key,
);
return row?.value ? JSON.parse(row.value) : undefined;
}
const plugin = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: unknown,
): PluginRow => ({
pluginId,
scope,
scopeId,
key,
value: JSON.stringify(value),
});
beforeEach(() => {
db.settings = {};
db.pluginRows = [];
db.defaults = [];
db.hosts = [];
db.folders = [];
db.folderCredential = new Map();
});
describe("runHostDefaultsMigration", () => {
it("moves the old server defaults, dropping the credential and password", async () => {
db.settings.host_defaults = JSON.stringify({
useSocks5: true,
socks5Host: "proxy",
socks5Port: 1081,
socks5Password: "secret",
credentialId: 3,
statusCheckEnabled: false,
});
db.pluginRows.push(
plugin("ssh-terminal", "admin", null, "newHostFontSize", 18),
plugin("host-metrics", "admin", null, "enabledForNewHosts", false),
);
db.folders = [{ id: 4, userId: "u1", credentialId: 9 }];
await runHostDefaultsMigration();
expect(level("core", "socks5", "admin")).toMatchObject({
useSocks5: true,
socks5Host: "proxy",
socks5Port: 1081,
});
expect(JSON.stringify(db.defaults)).not.toContain("secret");
expect(level("core", "statusCheckEnabled", "admin")).toBe(false);
expect(level("ssh-terminal", "fontSize", "admin")).toBe(18);
expect(level("host-metrics", "metricsEnabled", "admin")).toBe(false);
expect(level("core", "auth", "folder")).toMatchObject({
authType: "credential",
credentialId: 9,
});
});
it("moves each user's terminal and remote desktop defaults to their own level", async () => {
db.pluginRows.push(
plugin("ssh-terminal", "user", "u1", "terminalDefaults", {
fontSize: 20,
}),
plugin("ssh-terminal", "user", "u1", "localEcho", "on"),
plugin("remote-desktop", "user", "u1", "colorDepth", "16"),
plugin("remote-desktop", "user", "u1", "disableAudio", "inherit"),
);
await runHostDefaultsMigration();
expect(level("ssh-terminal", "fontSize", "user")).toBe(20);
expect(level("ssh-terminal", "localEcho", "user")).toBe("on");
expect(level("remote-desktop", "colorDepth", "user")).toBe("16");
expect(level("remote-desktop", "disableAudio", "user")).toBeUndefined();
});
it("writes in the look a host really had while it followed its user", async () => {
db.hosts = [
{ id: 1, userId: "u1", defaultOverrides: null },
{ id: 2, userId: "u1", defaultOverrides: null },
{ id: 3, userId: "u1", defaultOverrides: '{"core":[]}' },
];
db.pluginRows.push(
plugin("ssh-terminal", "user", "u1", "terminalDefaults", {
fontSize: 20,
}),
plugin("ssh-terminal", "user", "u1", "localEcho", "on"),
plugin("ssh-terminal", "host", "1", "fontSize", 30),
plugin("ssh-terminal", "host", "1", "localEcho", "default"),
plugin("ssh-terminal", "host", "2", "inheritAppearance", false),
plugin("ssh-terminal", "host", "2", "fontSize", 30),
plugin("ssh-terminal", "host", "2", "localEcho", "off"),
plugin("remote-desktop", "user", "u1", "colorDepth", "24"),
);
await runHostDefaultsMigration();
// Followed the user: the user's look, and their echo mode.
expect(hostValue(1, "ssh-terminal", "fontSize")).toBe(20);
expect(hostValue(1, "ssh-terminal", "localEcho")).toBe("on");
expect(hostValue(1, "remote-desktop", "colorDepth")).toBe("24");
// Had its own look: kept.
expect(hostValue(2, "ssh-terminal", "fontSize")).toBe(30);
expect(hostValue(2, "ssh-terminal", "localEcho")).toBe("off");
// Saved since host defaults: left alone.
expect(hostValue(3, "ssh-terminal", "fontSize")).toBeUndefined();
});
it("gives a credential host its folder's credential", async () => {
db.hosts = [
{
id: 1,
userId: "u1",
authType: "credential",
credentialId: null,
folder: "Prod",
defaultOverrides: null,
},
];
db.folderCredential.set("Prod", 12);
await runHostDefaultsMigration();
expect(db.hosts[0].credentialId).toBe(12);
});
it("moves each level once", async () => {
db.settings.host_defaults = JSON.stringify({ statusCheckEnabled: false });
await runHostDefaultsMigration();
db.defaults = [];
await runHostDefaultsMigration();
expect(level("core", "statusCheckEnabled", "admin")).toBeUndefined();
});
});
@@ -1,200 +0,0 @@
/**
* Host Metrics' settings leaving core: the two admin keys, the new-host
* default inside host_defaults, and the metrics half of every host's
* stats_config. Nothing may be lost, and a second run must change nothing.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const state = vi.hoisted(() => ({
coreSettings: new Map<string, string>(),
pluginRows: [] as Array<{
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
pluginInstalled: true,
db: null as unknown,
}));
const find = (scope: string, scopeId: string | null, key: string) =>
state.pluginRows.find(
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
);
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "host-metrics" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(scope, scopeId, key) ?? null,
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
state.pluginRows.filter(
(row) => row.scope === scope && row.scopeId === scopeId,
),
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 state.pluginRows.push({ scope, scopeId, key, value });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.coreSettings.get(key) ?? null,
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
const { runHostMetricsSettingsMigration, hostSettingsFromStatsConfig } =
await import("../../upgrade/host-metrics-settings-migration.js");
let sqlite: Database.Database;
const value = (scope: string, scopeId: string | null, key: string) => {
const row = find(scope, scopeId, key) as Row | undefined;
return row?.value == null ? undefined : JSON.parse(row.value);
};
beforeEach(() => {
state.coreSettings.clear();
state.pluginRows = [];
state.pluginInstalled = true;
sqlite = new Database(":memory:");
sqlite.exec(
"CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, stats_config TEXT)",
);
state.db = drizzle(sqlite);
});
describe("hostSettingsFromStatsConfig", () => {
it("keeps only what differs from the defaults", () => {
expect(
hostSettingsFromStatsConfig(
JSON.stringify({
statusCheckEnabled: false,
metricsEnabled: false,
metricsInterval: 45,
useGlobalMetricsInterval: false,
enabledWidgets: ["cpu", "disk"],
excludedMounts: ["/snap"],
monitoredMounts: [{ path: "/data", label: "Data" }, { bad: 1 }],
}),
),
).toEqual({
metricsEnabled: false,
metricsInterval: 45,
enabledWidgets: ["cpu", "disk"],
excludedMounts: ["/snap"],
monitoredMounts: [{ path: "/data", label: "Data" }],
});
});
it("ignores the host's interval while it followed the global one", () => {
expect(
hostSettingsFromStatsConfig(JSON.stringify({ metricsInterval: 45 })),
).toEqual({});
});
it("reads a value that was stringified twice", () => {
expect(
hostSettingsFromStatsConfig(
JSON.stringify(JSON.stringify({ metricsEnabled: false })),
),
).toEqual({ metricsEnabled: false });
});
});
describe("runHostMetricsSettingsMigration", () => {
it("moves the admin keys, the new-host default and every host", async () => {
state.coreSettings.set("global_metrics_interval", "20");
state.coreSettings.set("metrics_history_retention_days", "30");
state.coreSettings.set(
"host_defaults",
JSON.stringify({ metricsEnabled: false, statusCheckEnabled: true }),
);
const insert = sqlite.prepare(
"INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)",
);
insert.run(1, JSON.stringify({ metricsEnabled: false }));
insert.run(2, JSON.stringify({ enabledWidgets: ["cpu"] }));
insert.run(3, JSON.stringify({ statusCheckEnabled: false }));
insert.run(4, null);
const result = await runHostMetricsSettingsMigration();
expect(result.moved.sort()).toEqual([
"global_metrics_interval",
"host_defaults.metricsEnabled",
"metrics_history_retention_days",
]);
expect(value("admin", null, "metricsInterval")).toBe(20);
expect(value("admin", null, "historyRetentionDays")).toBe(30);
expect(value("admin", null, "enabledForNewHosts")).toBe(false);
expect(value("host", "1", "metricsEnabled")).toBe(false);
expect(value("host", "2", "enabledWidgets")).toEqual(["cpu"]);
// Only the status half, which is core's: nothing for the plugin.
expect(state.pluginRows.filter((row) => row.scopeId === "3")).toEqual([]);
expect(result.hostsMoved).toBe(2);
});
it("changes nothing the second time", async () => {
state.coreSettings.set("global_metrics_interval", "20");
sqlite
.prepare("INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)")
.run(1, JSON.stringify({ metricsEnabled: false }));
await runHostMetricsSettingsMigration();
// Changed through the plugin after the upgrade.
const interval = find("admin", null, "metricsInterval")!;
interval.value = "90";
const enabled = find("host", "1", "metricsEnabled")!;
enabled.value = "true";
const second = await runHostMetricsSettingsMigration();
expect(second).toEqual({ moved: [], hostsMoved: 0, hostsSkipped: 1 });
expect(value("admin", null, "metricsInterval")).toBe(90);
expect(value("host", "1", "metricsEnabled")).toBe(true);
});
it("waits until the plugin row exists", async () => {
state.pluginInstalled = false;
state.coreSettings.set("global_metrics_interval", "20");
expect(await runHostMetricsSettingsMigration()).toEqual({
moved: [],
hostsMoved: 0,
hostsSkipped: 0,
});
expect(state.pluginRows).toEqual([]);
});
it("skips values out of range", async () => {
state.coreSettings.set("global_metrics_interval", "1");
state.coreSettings.set("metrics_history_retention_days", "900");
await runHostMetricsSettingsMigration();
expect(state.pluginRows).toEqual([]);
});
});
@@ -1,8 +1,7 @@
/**
* The OPKSSH config move. A 2.8 install's config must reach the opkssh
* plugin's folder, the old file must stay for a downgrade, and the install
* must keep the redirect URI its identity providers know. Running it twice
* must not overwrite anything.
* plugin's folder and the old file must stay for a downgrade. Running it
* twice must not overwrite anything.
*/
import fs from "node:fs/promises";
@@ -56,33 +55,22 @@ afterEach(async () => {
});
describe("runOpksshConfigMigration", () => {
it("copies the config, keeps the old one and the old redirect URI, once", async () => {
it("copies the config and keeps the old one, once", async () => {
await fs.mkdir(path.dirname(legacy()), { recursive: true });
await fs.writeFile(legacy(), "providers:\n - alias: google\n");
expect(await runOpksshConfigMigration(dataDir)).toEqual({
copied: true,
legacyCallback: true,
});
expect(await runOpksshConfigMigration(dataDir)).toEqual({ copied: true });
expect(await fs.readFile(target(), "utf8")).toContain("alias: google");
expect(await fs.readFile(legacy(), "utf8")).toContain("alias: google");
expect(state.settings.get("legacyCallback")).toBe("true");
expect(state.settings.has("legacyCallback")).toBe(false);
await fs.writeFile(target(), "providers:\n - alias: edited\n");
state.settings.set("legacyCallback", "false");
expect(await runOpksshConfigMigration(dataDir)).toEqual({
copied: false,
legacyCallback: false,
});
expect(await runOpksshConfigMigration(dataDir)).toEqual({ copied: false });
expect(await fs.readFile(target(), "utf8")).toContain("alias: edited");
expect(state.settings.get("legacyCallback")).toBe("false");
});
it("does nothing on an install that never set up OPKSSH", async () => {
expect(await runOpksshConfigMigration(dataDir)).toEqual({
copied: false,
legacyCallback: false,
});
expect(await runOpksshConfigMigration(dataDir)).toEqual({ copied: false });
expect(state.settings.size).toBe(0);
});
@@ -1,171 +0,0 @@
/**
* The Proxmox host-columns migration.
*
* An upgrade must be lossless: a host that already had Proxmox discovery or
* stats configured must keep that configuration once the ssh_data columns
* are dropped. Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_proxmox: boolean;
proxmox_config: string | null;
enable_proxmox_stats: boolean;
proxmox_stats_config: string | null;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter(
(row) =>
row.enable_proxmox ||
row.proxmox_config !== null ||
row.enable_proxmox_stats ||
row.proxmox_stats_config !== null,
),
}),
}));
const { runProxmoxSettingsMigration } =
await import("../../upgrade/proxmox-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runProxmoxSettingsMigration", () => {
it("moves an enabled host's columns into plugin settings", async () => {
hostRows.push({
id: 1,
enable_proxmox: true,
proxmox_config: JSON.stringify({ defaultAuthType: "password" }),
enable_proxmox_stats: true,
proxmox_stats_config: JSON.stringify({ nodeName: "pve1" }),
});
const result = await runProxmoxSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableProxmox")).toBe(true);
expect(storedValue(1, "proxmoxConfig")).toEqual({
defaultAuthType: "password",
});
expect(storedValue(1, "enableProxmoxStats")).toBe(true);
expect(storedValue(1, "proxmoxStatsConfig")).toEqual({ nodeName: "pve1" });
});
it("does nothing on a fresh install with no proxmox hosts", async () => {
const result = await runProxmoxSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({
id: 2,
enable_proxmox: false,
proxmox_config: JSON.stringify({ autoSyncEnabled: true }),
enable_proxmox_stats: false,
proxmox_stats_config: null,
});
await runProxmoxSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runProxmoxSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("moves each host independently", async () => {
hostRows.push(
{
id: 1,
enable_proxmox: true,
proxmox_config: null,
enable_proxmox_stats: false,
proxmox_stats_config: null,
},
{
id: 2,
enable_proxmox: false,
proxmox_config: null,
enable_proxmox_stats: true,
proxmox_stats_config: JSON.stringify({ nodeName: "pve2" }),
},
);
const result = await runProxmoxSettingsMigration();
expect(result.moved).toBe(2);
expect(storedValue(1, "enableProxmox")).toBe(true);
expect(storedValue(2, "proxmoxStatsConfig")).toEqual({ nodeName: "pve2" });
});
});
@@ -1,256 +0,0 @@
/**
* The Remote Desktop settings migration.
*
* An upgrade must be lossless: the admin switch and guacd address, each
* user's RDP defaults and every host's RDP/VNC/Telnet options must come
* through into the plugin's own settings, whatever shape an old host had.
* Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const state = vi.hoisted(() => ({
coreSettings: new Map<string, string>(),
pluginRows: [] as Array<{
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
userRows: [] as Array<{ user_id: string; rdp_defaults: string | null }>,
statements: [] as string[],
pluginInstalled: true,
}));
const find = (scope: string, scopeId: string | null, key: string) =>
state.pluginRows.find(
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "remote-desktop" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(scope, scopeId, key) ?? null,
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
state.pluginRows.filter(
(row) => row.scope === scope && row.scopeId === scopeId,
),
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;
return;
}
state.pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.coreSettings.get(key) ?? null,
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: { queryChunks?: unknown[] }) => {
const text = JSON.stringify(query.queryChunks ?? query);
return text.includes("user_preferences") ? state.userRows : state.hostRows;
},
runStatement: async (query: unknown) => {
state.statements.push(JSON.stringify(query));
},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
const { runRemoteDesktopSettingsMigration, hostSettingsFromRow } =
await import("../../upgrade/remote-desktop-settings-migration.js");
const value = (scope: string, scopeId: string | null, key: string) => {
const row = find(scope, scopeId, key) as Row | undefined;
return row?.value == null ? undefined : JSON.parse(row.value);
};
function host(overrides: Record<string, unknown>) {
return {
id: 1,
port: 22,
connection_type: "ssh",
enable_rdp: 0,
enable_vnc: 0,
enable_telnet: 0,
rdp_port: 3389,
vnc_port: 5900,
telnet_port: 23,
rdp_security: null,
rdp_ignore_cert: 0,
security: null,
ignore_cert: 0,
guacamole_config: null,
enable_terminal_toolbar: 1,
...overrides,
};
}
beforeEach(() => {
state.coreSettings.clear();
state.pluginRows.length = 0;
state.hostRows.length = 0;
state.userRows.length = 0;
state.statements.length = 0;
state.pluginInstalled = true;
});
describe("runRemoteDesktopSettingsMigration", () => {
it("moves the admin switch and guacd address", async () => {
state.coreSettings.set("guac_enabled", "false");
state.coreSettings.set("guac_url", "guacd:4822");
const result = await runRemoteDesktopSettingsMigration();
expect(result.moved).toEqual(["guac_enabled", "guac_url"]);
expect(value("admin", null, "enabled")).toBe(false);
expect(value("admin", null, "guacdUrl")).toBe("guacd:4822");
});
it("moves a host's options, reading SQLite's 0 and 1 as booleans", async () => {
state.hostRows.push(
host({
id: 7,
enable_rdp: 1,
enable_vnc: 0,
rdp_port: 3390,
rdp_security: "nla",
rdp_ignore_cert: 1,
guacamole_config: JSON.stringify({ "color-depth": 24 }),
enable_terminal_toolbar: 0,
}),
);
const result = await runRemoteDesktopSettingsMigration();
expect(result.hostsMoved).toBe(1);
expect(value("host", "7", "enableRdp")).toBe(true);
expect(value("host", "7", "enableVnc")).toBeUndefined();
expect(value("host", "7", "rdpPort")).toBe(3390);
expect(value("host", "7", "rdpSecurity")).toBe("nla");
expect(value("host", "7", "rdpIgnoreCert")).toBe(true);
expect(value("host", "7", "guacamoleConfig")).toEqual({
"color-depth": 24,
});
expect(value("host", "7", "enableToolbar")).toBe(false);
});
it("leaves a plain SSH host alone", async () => {
state.hostRows.push(host({ id: 3 }));
const result = await runRemoteDesktopSettingsMigration();
expect(result.hostsMoved).toBe(0);
expect(state.pluginRows).toHaveLength(0);
});
it("reads real booleans too, as Postgres returns them", async () => {
state.hostRows.push(host({ id: 8, enable_vnc: true, vnc_port: 5901 }));
await runRemoteDesktopSettingsMigration();
expect(value("host", "8", "enableVnc")).toBe(true);
expect(value("host", "8", "vncPort")).toBe(5901);
});
it("moves each user's RDP defaults", async () => {
state.userRows.push({
user_id: "u1",
rdp_defaults: JSON.stringify({
colorDepth: 24,
resizeMethod: "reconnect",
disableCopy: true,
enableWallpaper: false,
}),
});
state.userRows.push({ user_id: "u2", rdp_defaults: null });
const result = await runRemoteDesktopSettingsMigration();
expect(result.usersMoved).toBe(1);
expect(value("user", "u1", "colorDepth")).toBe("24");
expect(value("user", "u1", "resizeMethod")).toBe("reconnect");
expect(value("user", "u1", "disableCopy")).toBe("on");
expect(value("user", "u1", "enableWallpaper")).toBe("off");
});
it("changes nothing when run twice", async () => {
state.coreSettings.set("guac_url", "guacd:4822");
state.hostRows.push(host({ id: 7, enable_rdp: 1, rdp_port: 3390 }));
state.userRows.push({
user_id: "u1",
rdp_defaults: JSON.stringify({ colorDepth: 16 }),
});
await runRemoteDesktopSettingsMigration();
const firstRun = JSON.stringify(state.pluginRows);
// Changed after the first run: must not be overwritten by the old values.
find("host", "7", "rdpPort")!.value = JSON.stringify(4000);
const second = await runRemoteDesktopSettingsMigration();
expect(second.moved).toEqual([]);
expect(second.usersMoved).toBe(0);
expect(second.hostsMoved).toBe(0);
expect(second.hostsSkipped).toBe(1);
expect(value("host", "7", "rdpPort")).toBe(4000);
expect(JSON.parse(firstRun)).toHaveLength(state.pluginRows.length);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
state.coreSettings.set("guac_url", "guacd:4822");
state.hostRows.push(host({ id: 7, enable_rdp: 1 }));
await runRemoteDesktopSettingsMigration();
expect(state.pluginRows).toHaveLength(0);
});
});
describe("hostSettingsFromRow", () => {
it("turns a connection_type-only host into its protocol switch and port", () => {
expect(
hostSettingsFromRow(
host({ connection_type: "vnc", port: 5905, vnc_port: null }),
),
).toEqual({ enableVnc: true, vncPort: 5905 });
});
it("folds the older security and ignore_cert columns in", () => {
expect(
hostSettingsFromRow(
host({
enable_rdp: 1,
rdp_security: null,
security: "tls",
rdp_ignore_cert: null,
ignore_cert: 1,
}),
),
).toEqual({ enableRdp: true, rdpSecurity: "tls", rdpIgnoreCert: true });
});
});
@@ -1,169 +0,0 @@
/**
* The session logging host-column migration.
*
* The legacy column defaulted to true, the plugin's enable switch defaults
* to false, so every host needs an explicit row, not just the ones that
* turned it off. Running it twice must not duplicate or clobber what it
* moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_session_logging: boolean | number | null;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () => hostRows,
}),
}));
const { runSessionRecordingSettingsMigration } =
await import("../../upgrade/session-recording-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runSessionRecordingSettingsMigration", () => {
it("moves an enabled host's switch into plugin settings", async () => {
hostRows.push({ id: 1, enable_session_logging: true });
const result = await runSessionRecordingSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableSessionRecording")).toBe(true);
expect(
settingsRows.every((row) => row.pluginId === "session-recording"),
).toBe(true);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("moves a disabled host's switch too, since the defaults flipped", async () => {
hostRows.push({ id: 3, enable_session_logging: false });
const result = await runSessionRecordingSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(3, "enableSessionRecording")).toBe(false);
});
it("reads SQLite 0 and 1 as off and on", async () => {
hostRows.push({ id: 6, enable_session_logging: 0 });
hostRows.push({ id: 7, enable_session_logging: 1 });
await runSessionRecordingSettingsMigration();
expect(storedValue(6, "enableSessionRecording")).toBe(false);
expect(storedValue(7, "enableSessionRecording")).toBe(true);
});
it("treats a null legacy value as on, matching the old column default", async () => {
hostRows.push({ id: 4, enable_session_logging: null });
const result = await runSessionRecordingSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(4, "enableSessionRecording")).toBe(true);
});
it("does nothing on a fresh install", async () => {
const result = await runSessionRecordingSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_session_logging: true });
await runSessionRecordingSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runSessionRecordingSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({ id: 5, enable_session_logging: true });
await pluginSettingsRepository.set(
"session-recording",
"host",
"5",
"enableSessionRecording",
"false",
);
const result = await runSessionRecordingSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableSessionRecording")).toBe(false);
});
});
@@ -1,151 +0,0 @@
/**
* The session sharing settings migration.
*
* An upgrade must be lossless: an instance with sharing switched off, and a
* host with sharing switched off, must stay off once the plugin reads its own
* settings. Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const blockedHostIds: number[] = [];
const coreSettings = new Map<string, string>();
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () => blockedHostIds.map((id) => ({ id })),
}),
}));
const { runSessionSharingSettingsMigration } =
await import("../../upgrade/session-sharing-settings-migration.js");
function stored(scopeId: string | null, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === scopeId && entry.key === key,
);
return row?.value == null ? undefined : JSON.parse(row.value);
}
beforeEach(() => {
blockedHostIds.length = 0;
coreSettings.clear();
settingsRows.length = 0;
});
describe("runSessionSharingSettingsMigration", () => {
it("moves a disabled global switch into the admin setting", async () => {
coreSettings.set("session_sharing_globally_enabled", "false");
const result = await runSessionSharingSettingsMigration();
expect(result.movedGlobal).toBe(true);
expect(stored(null, "globallyEnabled")).toBe(false);
expect(settingsRows[0]).toMatchObject({
pluginId: "session-sharing",
scope: "admin",
});
});
it("moves hosts that turned sharing off into host settings", async () => {
blockedHostIds.push(4, 9);
const result = await runSessionSharingSettingsMigration();
expect(result.movedHosts).toBe(2);
expect(stored("4", "allowSessionSharing")).toBe(false);
expect(stored("9", "allowSessionSharing")).toBe(false);
});
it("writes nothing when sharing was on everywhere", async () => {
coreSettings.set("session_sharing_globally_enabled", "true");
const result = await runSessionSharingSettingsMigration();
expect(result).toEqual({ movedGlobal: false, movedHosts: 0, skipped: 0 });
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
coreSettings.set("session_sharing_globally_enabled", "false");
blockedHostIds.push(2);
await runSessionSharingSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runSessionSharingSettingsMigration();
expect(second.movedGlobal).toBe(false);
expect(second.movedHosts).toBe(0);
expect(second.skipped).toBe(2);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
blockedHostIds.push(5);
await pluginSettingsRepository.set(
"session-sharing",
"host",
"5",
"allowSessionSharing",
"true",
);
const result = await runSessionSharingSettingsMigration();
expect(result.skipped).toBe(1);
expect(stored("5", "allowSessionSharing")).toBe(true);
});
});
@@ -1,174 +0,0 @@
/**
* 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);
});
});
@@ -1,248 +0,0 @@
/**
* The terminal's look and behavior moving out of ssh_data.terminal_config and
* user_preferences into the ssh-terminal plugin's settings. Lossless, and a
* second run changes nothing.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const state = vi.hoisted(() => ({
db: null as unknown,
rows: [] as Row[],
installed: true,
}));
const find = (scope: string, scopeId: string | null, key: string) =>
state.rows.find(
(row) =>
row.pluginId === "ssh-terminal" &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.installed && id === "ssh-terminal" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(scope, scopeId, key) ?? null,
getAllForScopeIds: async (scope: string, scopeIds: string[]) =>
state.rows.filter(
(row) => row.scope === scope && scopeIds.includes(row.scopeId ?? ""),
),
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 state.rows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
const {
runSshTerminalLookMigration,
hostSettingsFromTerminalConfig,
userSettingsFromPreferences,
} = await import("../../upgrade/ssh-terminal-look-migration.js");
let sqlite: Database.Database;
beforeEach(() => {
state.rows.length = 0;
state.installed = true;
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, terminal_config TEXT);
CREATE TABLE user_preferences (
user_id TEXT PRIMARY KEY,
terminal_defaults TEXT,
custom_themes TEXT,
command_autocomplete INTEGER
);
`);
state.db = drizzle(sqlite);
});
function host(id: number, key: string): unknown {
const row = find("host", String(id), key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
function user(id: string, key: string): unknown {
const row = find("user", id, key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
describe("hostSettingsFromTerminalConfig", () => {
it("keeps a host that followed the user's look following it", () => {
expect(
hostSettingsFromTerminalConfig(
JSON.stringify({
autoTmux: true,
localEcho: "on",
keepaliveInterval: 30,
startupSnippetId: 3,
}),
),
).toEqual({ autoTmux: true, localEcho: "on" });
});
it("opts a host with its own look out of the user's defaults", () => {
expect(
hostSettingsFromTerminalConfig({
theme: "Termix Dark",
fontSize: "16",
cursorStyle: "wobble",
bellStyle: "visual",
}),
).toEqual({
inheritAppearance: false,
theme: "termix",
fontSize: 16,
bellStyle: "visual",
});
});
it("reads nothing out of garbage", () => {
expect(hostSettingsFromTerminalConfig("not json")).toEqual({});
expect(hostSettingsFromTerminalConfig(null)).toEqual({});
});
});
describe("userSettingsFromPreferences", () => {
it("takes the defaults, saved themes and autocomplete", () => {
expect(
userSettingsFromPreferences({
terminal_defaults: JSON.stringify({ fontSize: 18, theme: "nord" }),
custom_themes: JSON.stringify([
{ id: "a", name: "Mine", colors: { background: "#000" } },
]),
command_autocomplete: 1,
}),
).toEqual({
terminalDefaults: { fontSize: 18, theme: "nord" },
customThemes: [{ id: "a", name: "Mine", colors: { background: "#000" } }],
commandAutocomplete: true,
});
expect(userSettingsFromPreferences({ command_autocomplete: null })).toEqual(
{},
);
});
});
describe("runSshTerminalLookMigration", () => {
function seed() {
sqlite.prepare("INSERT INTO ssh_data VALUES (?, ?)").run(
1,
JSON.stringify({
theme: "dracula",
fontSize: 18,
autoTmux: true,
sudoPasswordAutoFill: true,
keepaliveInterval: 30,
}),
);
sqlite
.prepare("INSERT INTO ssh_data VALUES (?, ?)")
.run(2, JSON.stringify({ passwordPromptAutoFill: false }));
sqlite
.prepare("INSERT INTO ssh_data VALUES (?, ?)")
.run(3, JSON.stringify({ keepaliveInterval: 10 }));
sqlite.prepare("INSERT INTO ssh_data VALUES (?, ?)").run(4, null);
sqlite
.prepare("INSERT INTO user_preferences VALUES (?, ?, ?, ?)")
.run(
"u1",
JSON.stringify({ cursorBlink: false }),
JSON.stringify([
{ id: "t", name: "T", colors: { background: "#111" } },
]),
1,
);
sqlite
.prepare("INSERT INTO user_preferences VALUES (?, ?, ?, ?)")
.run("u2", null, null, null);
}
it("copies each host's terminal keys and each user's terminal preferences", async () => {
seed();
const result = await runSshTerminalLookMigration();
expect(result).toEqual({ hostsMoved: 2, hostsSkipped: 0, usersMoved: 1 });
expect(host(1, "inheritAppearance")).toBe(false);
expect(host(1, "theme")).toBe("dracula");
expect(host(1, "fontSize")).toBe(18);
expect(host(1, "autoTmux")).toBe(true);
expect(host(1, "sudoPasswordAutoFill")).toBe(true);
expect(host(1, "keepaliveInterval")).toBeUndefined();
expect(host(2, "passwordPromptAutoFill")).toBe(false);
expect(host(2, "inheritAppearance")).toBeUndefined();
expect(host(3, "keepaliveInterval")).toBeUndefined();
expect(user("u1", "terminalDefaults")).toEqual({ cursorBlink: false });
expect(user("u1", "customThemes")).toHaveLength(1);
expect(user("u1", "commandAutocomplete")).toBe(true);
expect(state.rows.some((row) => row.scopeId === "u2")).toBe(false);
});
it("changes nothing on a second run, even after values were edited", async () => {
seed();
await runSshTerminalLookMigration();
find("host", "1", "fontSize")!.value = JSON.stringify(12);
find("user", "u1", "commandAutocomplete")!.value = JSON.stringify(false);
const before = JSON.stringify(state.rows);
const second = await runSshTerminalLookMigration();
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 2, usersMoved: 0 });
expect(JSON.stringify(state.rows)).toBe(before);
expect(host(1, "fontSize")).toBe(12);
});
it("waits for the plugin row", async () => {
seed();
state.installed = false;
expect(await runSshTerminalLookMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
usersMoved: 0,
});
expect(state.rows).toEqual([]);
});
it("copies nothing from a database without the columns", async () => {
sqlite = new Database(":memory:");
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
state.db = drizzle(sqlite);
expect(await runSshTerminalLookMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
usersMoved: 0,
});
});
});
@@ -1,104 +0,0 @@
/**
* 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("[]");
});
});
@@ -1,241 +0,0 @@
/**
* The SSH terminal settings migration.
*
* An upgrade must be lossless: an install's session timeout, command history
* switch, touch tuning and image storage settings, and every host that turned
* a terminal switch off, must come through into the plugin's own settings.
* Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
interface HostRow {
id: number;
enable_terminal: number | null;
enable_terminal_toolbar: number | null;
enable_command_history: number | null;
}
const coreSettings = new Map<string, string>();
const pluginRows: Row[] = [];
const hostRows: HostRow[] = [];
let pluginInstalled = true;
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
pluginRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
pluginInstalled && id === "ssh-terminal" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
existing.encrypted = encrypted;
return;
}
pluginRows.push({ pluginId, scope, scopeId, key, value, encrypted });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter(
(row) =>
row.enable_terminal === 0 ||
row.enable_terminal_toolbar === 0 ||
row.enable_command_history === 0,
),
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (plaintext: string) =>
plaintext.startsWith("sysenc:") ? plaintext : `sysenc:${plaintext}`,
}));
const { runSshTerminalSettingsMigration } =
await import("../../upgrade/ssh-terminal-settings-migration.js");
function admin(key: string): unknown {
const row = find("ssh-terminal", "admin", null, key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
function host(id: number, key: string): unknown {
const row = find("ssh-terminal", "host", String(id), key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
beforeEach(() => {
coreSettings.clear();
pluginRows.length = 0;
hostRows.length = 0;
pluginInstalled = true;
});
describe("runSshTerminalSettingsMigration", () => {
it("moves the admin settings with their types, the local dir as a secret", async () => {
coreSettings.set("terminal_session_timeout_minutes", "120");
coreSettings.set("terminal_session_persistence_enabled", "false");
coreSettings.set("command_history_enabled", "false");
coreSettings.set(
"touch_input_settings",
JSON.stringify({ enabled: false, dragThresholdPx: 12 }),
);
coreSettings.set("terminal_image_storage_mode", "local");
coreSettings.set("terminal_image_local_dir", "/data/images");
coreSettings.set("terminal_image_max_count", "50");
const result = await runSshTerminalSettingsMigration();
expect(result.moved).toHaveLength(7);
expect(admin("sessionTimeoutMinutes")).toBe(120);
expect(admin("sessionPersistence")).toBe(false);
expect(admin("commandHistoryEnabled")).toBe(false);
expect(admin("touchInput")).toEqual({
enabled: false,
dragThresholdPx: 12,
});
expect(admin("imageStorageMode")).toBe("local");
expect(admin("imageMaxCount")).toBe(50);
const localDir = find("ssh-terminal", "admin", null, "imageLocalDir");
expect(localDir?.encrypted).toBe(true);
expect(JSON.parse(localDir!.value!)).toBe("sysenc:/data/images");
});
it("moves the terminal half of the admin host defaults to the new-host settings", async () => {
coreSettings.set(
"host_defaults",
JSON.stringify({
useSocks5: true,
fontSize: 18,
fontFamily: "Fira Code",
theme: "Termix Dark",
cursorStyle: "wobble",
cursorBlink: false,
autoTmux: true,
}),
);
await runSshTerminalSettingsMigration();
expect(admin("newHostFontSize")).toBe(18);
expect(admin("newHostFontFamily")).toBe("Fira Code");
expect(admin("newHostTheme")).toBe("termix");
expect(admin("newHostCursorStyle")).toBeUndefined();
expect(admin("newHostCursorBlink")).toBe(false);
expect(admin("newHostAutoTmux")).toBe(true);
});
it("copies each host that switched something off, keeping the rest on", async () => {
hostRows.push(
{
id: 1,
enable_terminal: 1,
enable_terminal_toolbar: 0,
enable_command_history: 1,
},
{
id: 2,
enable_terminal: 0,
enable_terminal_toolbar: 1,
enable_command_history: 0,
},
{
id: 3,
enable_terminal: 1,
enable_terminal_toolbar: 1,
enable_command_history: 1,
},
);
const result = await runSshTerminalSettingsMigration();
expect(result.hostsMoved).toBe(2);
expect(host(1, "enableTerminal")).toBe(true);
expect(host(1, "enableTerminalToolbar")).toBe(false);
expect(host(1, "enableCommandHistory")).toBe(true);
expect(host(2, "enableTerminal")).toBe(false);
expect(host(2, "enableCommandHistory")).toBe(false);
// All on is the plugin's default, so it needs no row.
expect(host(3, "enableTerminal")).toBeUndefined();
});
it("changes nothing on a second run, even after the admin changed a value", async () => {
coreSettings.set("terminal_session_timeout_minutes", "120");
hostRows.push({
id: 1,
enable_terminal: 0,
enable_terminal_toolbar: 1,
enable_command_history: 1,
});
await runSshTerminalSettingsMigration();
const changed = find(
"ssh-terminal",
"admin",
null,
"sessionTimeoutMinutes",
);
changed!.value = JSON.stringify(15);
const rowsBefore = pluginRows.length;
const second = await runSshTerminalSettingsMigration();
expect(second.moved).toEqual([]);
expect(second.hostsMoved).toBe(0);
expect(second.hostsSkipped).toBe(1);
expect(pluginRows).toHaveLength(rowsBefore);
expect(admin("sessionTimeoutMinutes")).toBe(15);
});
it("does nothing until the plugin row exists", async () => {
pluginInstalled = false;
coreSettings.set("terminal_session_timeout_minutes", "120");
const result = await runSshTerminalSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
});
});
@@ -1,79 +0,0 @@
/**
* The silent sign-in default moving from core settings into the sso plugin's
* admin settings.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
core: new Map<string, string>(),
plugin: new Map<string, string | null>(),
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "sso" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.core.get(key) ?? null,
delete: async (key: string) => {
state.core.delete(key);
},
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
) => (state.plugin.has(key) ? { value: state.plugin.get(key) } : null),
set: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
value: string | null,
) => {
state.plugin.set(key, value);
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runSsoSettingsMigration } from "../../upgrade/sso-settings-migration.js";
beforeEach(() => {
state.core = new Map();
state.plugin = new Map();
state.pluginInstalled = true;
});
describe("runSsoSettingsMigration", () => {
it("moves the core value into the sso admin setting", async () => {
state.core.set("oidc_silent_login_default", "true");
expect(await runSsoSettingsMigration()).toEqual({ moved: true });
expect(state.plugin.get("silentLoginDefault")).toBe("true");
expect(state.core.has("oidc_silent_login_default")).toBe(false);
});
it("keeps a value already in plugin settings", async () => {
state.core.set("oidc_silent_login_default", "true");
state.plugin.set("silentLoginDefault", "false");
await runSsoSettingsMigration();
expect(state.plugin.get("silentLoginDefault")).toBe("false");
expect(state.core.has("oidc_silent_login_default")).toBe(false);
});
it("does nothing twice or without the plugin", async () => {
expect(await runSsoSettingsMigration()).toEqual({ moved: false });
state.core.set("oidc_silent_login_default", "true");
state.pluginInstalled = false;
expect(await runSsoSettingsMigration()).toEqual({ moved: false });
expect(state.core.get("oidc_silent_login_default")).toBe("true");
});
});
@@ -1,116 +0,0 @@
/**
* The Step CA settings migration: a 2.8 CA configuration and its private
* endpoint allowlist come through into the step-ca plugin's admin settings,
* the old redirect URI stays in use, and a second run changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
legacy: {} as Record<string, string>,
pluginRows: [] as Array<{ key: string; value: string | null }>,
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "step-ca" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.legacy[key] ?? null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
) => state.pluginRows.find((row) => row.key === key) ?? null,
set: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
value: string | null,
) => {
state.pluginRows = state.pluginRows.filter((row) => row.key !== key);
state.pluginRows.push({ key, value });
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runStepCaSettingsMigration } from "../../upgrade/step-ca-settings-migration.js";
const value = (key: string) => {
const row = state.pluginRows.find((candidate) => candidate.key === key);
return row?.value == null ? undefined : JSON.parse(row.value);
};
beforeEach(() => {
state.legacy = {
step_ca_url: "https://ca.internal:9000",
step_ca_fingerprint: "a".repeat(64),
step_ca_provisioner: "oidc",
step_ca_private_endpoint_allowlist: JSON.stringify([
"ca.internal",
"SSO.internal",
]),
};
state.pluginRows = [];
state.pluginInstalled = true;
});
describe("runStepCaSettingsMigration", () => {
it("moves the 2.8 settings and keeps the old redirect URI", async () => {
const result = await runStepCaSettingsMigration();
expect(result.moved).toEqual([
"step_ca_url",
"step_ca_fingerprint",
"step_ca_provisioner",
"step_ca_private_endpoint_allowlist",
]);
expect(result.legacyCallback).toBe(true);
expect(value("caUrl")).toBe("https://ca.internal:9000");
expect(value("fingerprint")).toBe("a".repeat(64));
expect(value("provisioner")).toBe("oidc");
expect(value("privateEndpoints")).toBe("ca.internal, sso.internal");
expect(value("legacyCallback")).toBe(true);
});
it("changes nothing the second time", async () => {
await runStepCaSettingsMigration();
const before = JSON.stringify(state.pluginRows);
const again = await runStepCaSettingsMigration();
expect(again).toEqual({ moved: [], legacyCallback: false });
expect(JSON.stringify(state.pluginRows)).toBe(before);
});
it("never overwrites a value set in the plugin", async () => {
state.pluginRows = [
{ key: "caUrl", value: JSON.stringify("https://new.ca") },
{ key: "legacyCallback", value: JSON.stringify(false) },
];
await runStepCaSettingsMigration();
expect(value("caUrl")).toBe("https://new.ca");
expect(value("legacyCallback")).toBe(false);
expect(value("provisioner")).toBe("oidc");
});
it("leaves an install that never used Step CA alone", async () => {
state.legacy = {};
const result = await runStepCaSettingsMigration();
expect(result).toEqual({ moved: [], legacyCallback: false });
expect(state.pluginRows).toEqual([]);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
await runStepCaSettingsMigration();
expect(state.pluginRows).toEqual([]);
});
});
@@ -1,228 +0,0 @@
/**
* The Tailscale settings migration.
*
* An upgrade must be lossless: an install that had a working API key must keep
* working without anyone re-entering it. That is the whole point of the test,
* and running it twice proves a restart cannot corrupt what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const coreSettings = new Map<string, string>();
const pluginRows: Row[] = [];
let pluginInstalled = true;
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
pluginRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
const existing = pluginRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
if (existing) {
existing.value = value;
existing.encrypted = encrypted;
return;
}
pluginRows.push({ pluginId, scope, scopeId, key, value, encrypted });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
pluginInstalled && id === "tailscale" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
delete: async (key: string) => coreSettings.delete(key),
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
encryptSystemSecret: async (plaintext: string) =>
!plaintext || plaintext.startsWith("sysenc:v1:")
? plaintext
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
decryptSystemSecret: async (stored: string) =>
stored.startsWith("sysenc:v1:")
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
"utf8",
)
: stored,
}));
const { runTailscaleSettingsMigration } =
await import("../../upgrade/tailscale-settings-migration.js");
function storedValue(key: string): unknown {
const row = pluginRows.find((entry) => entry.key === key);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
coreSettings.clear();
pluginRows.length = 0;
pluginInstalled = true;
});
describe("runTailscaleSettingsMigration", () => {
it("moves an existing key and base URL into plugin settings", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([
"tailscale_api_key",
"tailscale_api_base_url",
]);
expect(storedValue("apiKey")).toMatch(/^sysenc:v1:/);
expect(storedValue("apiBaseUrl")).toBe("https://headscale.example");
});
it("encrypts the key, which was stored in plaintext before", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
await runTailscaleSettingsMigration();
const row = pluginRows.find((entry) => entry.key === "apiKey")!;
expect(row.encrypted).toBe(true);
expect(row.value).not.toContain("tskey-api-legacy");
});
it("leaves the base URL readable, since it is not a secret", async () => {
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
await runTailscaleSettingsMigration();
const row = pluginRows.find((entry) => entry.key === "apiBaseUrl")!;
expect(row.encrypted).toBe(false);
});
it("removes the legacy rows so no plaintext key is left behind", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
await runTailscaleSettingsMigration();
expect(coreSettings.has("tailscale_api_key")).toBe(false);
expect(coreSettings.has("tailscale_api_base_url")).toBe(false);
});
it("is idempotent: a second run changes nothing", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
await runTailscaleSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(pluginRows));
const second = await runTailscaleSettingsMigration();
expect(second.moved).toEqual([]);
expect(pluginRows).toEqual(afterFirst);
});
it("cannot double-encrypt the key across runs", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
await runTailscaleSettingsMigration();
const first = storedValue("apiKey");
await runTailscaleSettingsMigration();
expect(storedValue("apiKey")).toBe(first);
expect(String(storedValue("apiKey")).match(/sysenc:v1:/g)).toHaveLength(1);
});
it("drops a stale legacy row when the value was already moved", async () => {
// An interrupted run, or a downgrade that rewrote the old key.
await pluginSettingsRepository.set(
"tailscale",
"admin",
null,
"apiKey",
JSON.stringify("sysenc:v1:already"),
true,
);
coreSettings.set("tailscale_api_key", "tskey-plaintext-leftover");
const result = await runTailscaleSettingsMigration();
expect(result.skipped).toContain("tailscale_api_key");
expect(storedValue("apiKey")).toBe("sysenc:v1:already");
expect(coreSettings.has("tailscale_api_key")).toBe(false);
});
it("does nothing on a fresh install with no legacy rows", async () => {
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
});
it("skips an empty legacy value rather than storing one", async () => {
coreSettings.set("tailscale_api_key", "");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
});
it("waits for the plugin row, because of the foreign key", async () => {
pluginInstalled = false;
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
// Still there for the next boot, once the plugin is seeded.
expect(coreSettings.get("tailscale_api_key")).toBe("tskey-api-legacy");
});
it("moves each key independently", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual(["tailscale_api_key"]);
expect(storedValue("apiBaseUrl")).toBeUndefined();
});
});
@@ -1,100 +0,0 @@
/**
* The telemetry settings migration: an instance that turned telemetry off in
* 2.8 stays off, keeps its instance id, and a second run changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface SettingsRow {
pluginId: string;
key: string;
value: string | null;
}
const coreSettings = new Map<string, string>();
const settingsRows: SettingsRow[] = [];
let pluginInstalled = true;
const find = (pluginId: string, key: string) =>
settingsRows.find((row) => row.pluginId === pluginId && row.key === key);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) => (pluginInstalled ? { id } : null),
}),
createCurrentPluginSettingsRepository: () => ({
get: async (pluginId: string, _scope: string, _id: null, key: string) =>
find(pluginId, key) ?? null,
set: async (
pluginId: string,
_scope: string,
_id: null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, key);
if (existing) existing.value = value;
else settingsRows.push({ pluginId, key, value });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
}),
}));
const { runTelemetrySettingsMigration } =
await import("../../upgrade/telemetry-settings-migration.js");
const stored = (key: string) => {
const row = find("telemetry", key);
return row?.value == null ? undefined : JSON.parse(row.value);
};
describe("runTelemetrySettingsMigration", () => {
beforeEach(() => {
coreSettings.clear();
settingsRows.length = 0;
pluginInstalled = true;
});
it("moves a disabled switch and the instance id", async () => {
coreSettings.set("analytics_enabled", "false");
coreSettings.set("analytics_instance_id", "abc-123");
const result = await runTelemetrySettingsMigration();
expect(result).toEqual({ movedEnabled: true, movedInstanceId: true });
expect(stored("enabled")).toBe(false);
expect(stored("instanceId")).toBe("abc-123");
});
it("writes nothing for an enabled instance without an id", async () => {
coreSettings.set("analytics_enabled", "true");
await runTelemetrySettingsMigration();
expect(settingsRows).toHaveLength(0);
});
it("is idempotent and never overwrites plugin values", async () => {
coreSettings.set("analytics_enabled", "false");
coreSettings.set("analytics_instance_id", "old");
settingsRows.push({
pluginId: "telemetry",
key: "instanceId",
value: JSON.stringify("new"),
});
await runTelemetrySettingsMigration();
const second = await runTelemetrySettingsMigration();
expect(second).toEqual({ movedEnabled: false, movedInstanceId: false });
expect(stored("instanceId")).toBe("new");
expect(settingsRows).toHaveLength(2);
});
it("does nothing without the plugin row", async () => {
pluginInstalled = false;
coreSettings.set("analytics_enabled", "false");
await runTelemetrySettingsMigration();
expect(settingsRows).toHaveLength(0);
});
});
@@ -1,149 +0,0 @@
/**
* The tmux monitor host-column migration.
*
* An upgrade must be lossless: a host with the monitor on must keep it on
* once the ssh_data column is dropped. Running it twice must not duplicate
* or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_tmux_monitor: boolean;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () => hostRows.filter((row) => row.enable_tmux_monitor),
}),
}));
const { runTmuxMonitorSettingsMigration } =
await import("../../upgrade/tmux-monitor-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runTmuxMonitorSettingsMigration", () => {
it("moves a host's enabled switch into plugin settings", async () => {
hostRows.push({ id: 1, enable_tmux_monitor: true });
const result = await runTmuxMonitorSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableTmuxMonitor")).toBe(true);
expect(settingsRows.every((row) => row.pluginId === "tmux-monitor")).toBe(
true,
);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("leaves a disabled host untouched", async () => {
hostRows.push({ id: 3, enable_tmux_monitor: false });
const result = await runTmuxMonitorSettingsMigration();
expect(result.moved).toBe(0);
expect(storedValue(3, "enableTmuxMonitor")).toBeUndefined();
});
it("does nothing on a fresh install", async () => {
const result = await runTmuxMonitorSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_tmux_monitor: true });
await runTmuxMonitorSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runTmuxMonitorSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({ id: 5, enable_tmux_monitor: true });
await pluginSettingsRepository.set(
"tmux-monitor",
"host",
"5",
"enableTmuxMonitor",
"false",
);
const result = await runTmuxMonitorSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableTmuxMonitor")).toBe(false);
});
});
@@ -1,185 +0,0 @@
/**
* The tunnel host-columns migration.
*
* An upgrade must be lossless: a host with tunnels on and a saved tunnel list
* must keep both once the ssh_data columns are dropped. Running it twice
* must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_tunnel: boolean;
tunnel_connections: string | null;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter(
(row) => row.enable_tunnel || row.tunnel_connections !== null,
),
}),
}));
const { runTunnelsSettingsMigration } =
await import("../../upgrade/tunnels-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
const tunnel = {
scope: "s2s",
mode: "local",
sourcePort: 8080,
endpointHost: "db",
endpointPort: 5432,
maxRetries: 3,
retryInterval: 10,
autoStart: true,
};
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runTunnelsSettingsMigration", () => {
it("moves a host's switch and saved tunnels into plugin settings", async () => {
hostRows.push({
id: 1,
enable_tunnel: true,
tunnel_connections: JSON.stringify([tunnel]),
});
const result = await runTunnelsSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableTunnel")).toBe(true);
expect(storedValue(1, "tunnelConnections")).toEqual([tunnel]);
expect(settingsRows.every((row) => row.pluginId === "tunnels")).toBe(true);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("keeps a disabled host's saved tunnels and its disabled switch", async () => {
hostRows.push({
id: 3,
enable_tunnel: false,
tunnel_connections: JSON.stringify([tunnel]),
});
await runTunnelsSettingsMigration();
expect(storedValue(3, "enableTunnel")).toBe(false);
expect(storedValue(3, "tunnelConnections")).toEqual([tunnel]);
});
it("stores an unreadable tunnel list as empty rather than failing", async () => {
hostRows.push({ id: 4, enable_tunnel: true, tunnel_connections: "{nope" });
const result = await runTunnelsSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(4, "tunnelConnections")).toEqual([]);
});
it("does nothing on a fresh install", async () => {
const result = await runTunnelsSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_tunnel: true, tunnel_connections: null });
await runTunnelsSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runTunnelsSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({
id: 5,
enable_tunnel: true,
tunnel_connections: JSON.stringify([tunnel]),
});
await pluginSettingsRepository.set(
"tunnels",
"host",
"5",
"enableTunnel",
"false",
);
const result = await runTunnelsSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableTunnel")).toBe(false);
expect(storedValue(5, "tunnelConnections")).toBeUndefined();
});
});
@@ -1,152 +0,0 @@
/**
* The Vault host settings migration.
*
* Every host's ssh_data.vault_profile_id must come through into the vault
* plugin's profileId host setting, and an install that used Vault keeps the
* 2.8 redirect URI. Running it twice must change nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pluginRows: [] as Array<{
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
legacyProfiles: 0,
adoptedProfiles: 0,
pluginInstalled: true,
columnGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "vault" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
state.pluginRows.push({ scope, scopeId, key, value });
},
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: unknown) => {
const text = JSON.stringify(query);
if (text.includes("vault_profile_id")) {
if (state.columnGone) throw new Error("no such column");
return state.hostRows.filter((row) => row.vault_profile_id != null);
}
const count = text.includes("p_vault_profiles")
? state.adoptedProfiles
: state.legacyProfiles;
return Array.from({ length: Math.min(count, 1) }, () => ({ "1": 1 }));
},
runStatement: async () => {},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runVaultSettingsMigration } from "../../upgrade/vault-settings-migration.js";
const setting = (scopeId: string | null, key: string) =>
state.pluginRows.find((row) => row.scopeId === scopeId && row.key === key)
?.value;
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.legacyProfiles = 0;
state.adoptedProfiles = 0;
state.pluginInstalled = true;
state.columnGone = false;
});
describe("runVaultSettingsMigration", () => {
it("moves every host's profile, keeps the old redirect URI, and a second run changes nothing", async () => {
state.hostRows = [
{ id: 1, vault_profile_id: 7 },
{ id: 2, vault_profile_id: null },
{ id: 3, vault_profile_id: "9" },
];
state.adoptedProfiles = 2;
const first = await runVaultSettingsMigration();
expect(first).toEqual({
hostsMoved: 2,
hostsSkipped: 0,
legacyCallback: true,
});
expect(setting("1", "profileId")).toBe("7");
expect(setting("2", "profileId")).toBeUndefined();
expect(setting("3", "profileId")).toBe("9");
expect(setting(null, "legacyCallback")).toBe("true");
const rows = state.pluginRows.length;
const second = await runVaultSettingsMigration();
expect(second).toEqual({
hostsMoved: 0,
hostsSkipped: 2,
legacyCallback: false,
});
expect(state.pluginRows).toHaveLength(rows);
});
it("keeps a host's value already set in the plugin", async () => {
state.pluginRows.push({
scope: "host",
scopeId: "1",
key: "profileId",
value: "4",
});
state.hostRows = [{ id: 1, vault_profile_id: 7 }];
await runVaultSettingsMigration();
expect(setting("1", "profileId")).toBe("4");
});
it("keeps the old redirect URI for profiles the plugin has not adopted yet", async () => {
state.legacyProfiles = 1;
const result = await runVaultSettingsMigration();
expect(result.legacyCallback).toBe(true);
});
it("uses the new redirect URI on a fresh install, and profiles made later do not change that", async () => {
state.columnGone = true;
const first = await runVaultSettingsMigration();
expect(first.legacyCallback).toBe(false);
expect(setting(null, "legacyCallback")).toBe("false");
state.adoptedProfiles = 3;
await runVaultSettingsMigration();
expect(setting(null, "legacyCallback")).toBe("false");
});
it("waits for the plugin row", async () => {
state.pluginInstalled = false;
state.hostRows = [{ id: 1, vault_profile_id: 7 }];
await runVaultSettingsMigration();
expect(state.pluginRows).toHaveLength(0);
});
});
@@ -1,190 +0,0 @@
/**
* The Wake-on-LAN host settings migration.
*
* An upgrade must be lossless: every host with a MAC address or a broadcast
* address must come through into the wake-on-lan plugin's host settings.
* Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pluginRows: [] as Array<{
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
pluginInstalled: true,
columnsGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "wake-on-lan" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
state.pluginRows.filter(
(row) => row.scope === scope && row.scopeId === scopeId,
),
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
);
if (existing) {
existing.value = value;
return;
}
state.pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async () => {
if (state.columnsGone) throw new Error("no such column: mac_address");
return state.hostRows;
},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
wakeOnLanSettingsFromRow,
runWakeOnLanSettingsMigration,
} from "../../upgrade/wake-on-lan-settings-migration.js";
const hostValues = (hostId: number) =>
Object.fromEntries(
state.pluginRows
.filter((row) => row.scope === "host" && row.scopeId === String(hostId))
.map((row) => [row.key, JSON.parse(row.value ?? "null")]),
);
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.pluginInstalled = true;
state.columnsGone = false;
});
describe("wakeOnLanSettingsFromRow", () => {
it("carries over a MAC address", () => {
expect(
wakeOnLanSettingsFromRow({
id: 1,
mac_address: "aa:bb:cc:dd:ee:ff",
wol_broadcast_address: null,
}),
).toEqual({ macAddress: "aa:bb:cc:dd:ee:ff" });
});
it("carries over a broadcast address alongside the MAC address", () => {
expect(
wakeOnLanSettingsFromRow({
id: 1,
mac_address: "aa:bb:cc:dd:ee:ff",
wol_broadcast_address: "192.168.1.255",
}),
).toEqual({
macAddress: "aa:bb:cc:dd:ee:ff",
broadcastAddress: "192.168.1.255",
});
});
it("produces nothing for a host with neither set", () => {
expect(
wakeOnLanSettingsFromRow({
id: 1,
mac_address: null,
wol_broadcast_address: null,
}),
).toEqual({});
});
});
describe("runWakeOnLanSettingsMigration", () => {
it("moves every host's wake-on-lan settings and skips hosts with nothing set", async () => {
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
{
id: 2,
mac_address: "11:22:33:44:55:66",
wol_broadcast_address: "192.168.1.255",
},
{ id: 3, mac_address: null, wol_broadcast_address: null },
];
const result = await runWakeOnLanSettingsMigration();
expect(result).toEqual({ hostsMoved: 2, hostsSkipped: 0 });
expect(hostValues(1)).toEqual({ macAddress: "aa:bb:cc:dd:ee:ff" });
expect(hostValues(2)).toEqual({
macAddress: "11:22:33:44:55:66",
broadcastAddress: "192.168.1.255",
});
expect(hostValues(3)).toEqual({});
});
it("changes nothing when run twice", async () => {
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
];
await runWakeOnLanSettingsMigration();
const before = JSON.stringify(state.pluginRows);
const second = await runWakeOnLanSettingsMigration();
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 1 });
expect(JSON.stringify(state.pluginRows)).toBe(before);
});
it("keeps a value already saved in the plugin", async () => {
state.pluginRows.push({
pluginId: "wake-on-lan",
scope: "host",
scopeId: "1",
key: "macAddress",
value: JSON.stringify("11:11:11:11:11:11"),
});
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
];
await runWakeOnLanSettingsMigration();
expect(hostValues(1)).toEqual({ macAddress: "11:11:11:11:11:11" });
});
it("does nothing before the plugin row exists or once the columns are gone", async () => {
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
];
state.pluginInstalled = false;
expect(await runWakeOnLanSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
state.pluginInstalled = true;
state.columnsGone = true;
expect(await runWakeOnLanSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
expect(state.pluginRows).toEqual([]);
});
});
@@ -1,161 +0,0 @@
/**
* The Warpgate host settings migration.
*
* Every host with use_warpgate on, whatever shape the engine returned it in,
* and every pre-2.7 host still on auth type "warpgate", must come through
* into the warpgate plugin's useWarpgate host setting. Running it twice must
* change nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pluginRows: [] as Array<{
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
statements: [] as string[],
pluginInstalled: true,
columnGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "warpgate" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
state.pluginRows.push({ scope, scopeId, key, value });
},
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: { queryChunks?: unknown[] }) => {
const text = JSON.stringify(query);
if (text.includes("use_warpgate") && state.columnGone) {
throw new Error("no such column: use_warpgate");
}
return state.hostRows.filter(
(row) => !text.includes("WHERE") || row.auth_type === "warpgate",
);
},
runStatement: async (query: unknown) => {
const text = JSON.stringify(query);
state.statements.push(text);
const id = (query as { queryChunks: unknown[] }).queryChunks.find(
(chunk) => typeof chunk === "number",
);
const row = state.hostRows.find((candidate) => candidate.id === id);
if (row) row.auth_type = "none";
},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
runWarpgateSettingsMigration,
usesWarpgate,
} from "../../upgrade/warpgate-settings-migration.js";
const enabled = (hostId: number) =>
state.pluginRows.some(
(row) =>
row.scopeId === String(hostId) &&
row.key === "useWarpgate" &&
row.value === "true",
);
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.statements = [];
state.pluginInstalled = true;
state.columnGone = false;
});
describe("usesWarpgate", () => {
it("reads the flag in every shape the engines return", () => {
for (const flag of [true, 1, "1", "true"]) {
expect(
usesWarpgate({ id: 1, use_warpgate: flag, auth_type: "none" }),
).toBe(true);
}
for (const flag of [false, 0, "0", null]) {
expect(
usesWarpgate({ id: 1, use_warpgate: flag, auth_type: "none" }),
).toBe(false);
}
expect(usesWarpgate({ id: 1, auth_type: "warpgate" })).toBe(true);
});
});
describe("runWarpgateSettingsMigration", () => {
it("moves every flagged host and fixes the old auth type, and a second run changes nothing", async () => {
state.hostRows = [
{ id: 1, use_warpgate: 1, auth_type: "password" },
{ id: 2, use_warpgate: 0, auth_type: "key" },
{ id: 3, use_warpgate: true, auth_type: "none" },
{ id: 4, use_warpgate: 0, auth_type: "warpgate" },
];
const first = await runWarpgateSettingsMigration();
expect(first).toEqual({
hostsMoved: 3,
hostsSkipped: 0,
authTypesFixed: 1,
});
expect(enabled(1)).toBe(true);
expect(enabled(2)).toBe(false);
expect(enabled(3)).toBe(true);
expect(enabled(4)).toBe(true);
expect(state.hostRows[3].auth_type).toBe("none");
const rows = state.pluginRows.length;
const second = await runWarpgateSettingsMigration();
// Host 4 is on auth type "none" now, so only the flagged two are seen.
expect(second).toEqual({
hostsMoved: 0,
hostsSkipped: 2,
authTypesFixed: 0,
});
expect(state.pluginRows).toHaveLength(rows);
});
it("still fixes old auth types on an install without the column", async () => {
state.columnGone = true;
state.hostRows = [{ id: 5, auth_type: "warpgate" }];
await runWarpgateSettingsMigration();
expect(enabled(5)).toBe(true);
expect(state.hostRows[0].auth_type).toBe("none");
});
it("waits for the plugin row", async () => {
state.pluginInstalled = false;
state.hostRows = [{ id: 1, use_warpgate: 1, auth_type: "password" }];
await runWarpgateSettingsMigration();
expect(state.pluginRows).toHaveLength(0);
});
});
@@ -1,184 +0,0 @@
/**
* The web endpoint host-columns migration.
*
* An upgrade must be lossless: a host with web endpoints on and a saved
* config must keep both once the ssh_data columns are dropped. Running it
* twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_web_ui: boolean;
web_ui_config: string | null;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter((row) => row.enable_web_ui || row.web_ui_config !== null),
}),
}));
const { runWebEndpointSettingsMigration } =
await import("../../upgrade/web-endpoint-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
const endpoint = {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "embedded",
};
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runWebEndpointSettingsMigration", () => {
it("moves a host's switch and saved config into plugin settings", async () => {
hostRows.push({
id: 1,
enable_web_ui: true,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableWebUi")).toBe(true);
expect(storedValue(1, "webUiConfig")).toEqual({ endpoints: [endpoint] });
expect(settingsRows.every((row) => row.pluginId === "web-endpoint")).toBe(
true,
);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("keeps a disabled host's saved config and its disabled switch", async () => {
hostRows.push({
id: 3,
enable_web_ui: false,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
await runWebEndpointSettingsMigration();
expect(storedValue(3, "enableWebUi")).toBe(false);
expect(storedValue(3, "webUiConfig")).toEqual({ endpoints: [endpoint] });
});
it("stores an unreadable config as null rather than failing", async () => {
hostRows.push({ id: 4, enable_web_ui: true, web_ui_config: "{nope" });
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(4, "webUiConfig")).toBeNull();
});
it("does nothing on a fresh install", async () => {
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_web_ui: true, web_ui_config: null });
await runWebEndpointSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runWebEndpointSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({
id: 5,
enable_web_ui: true,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
await pluginSettingsRepository.set(
"web-endpoint",
"host",
"5",
"enableWebUi",
"false",
);
const result = await runWebEndpointSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableWebUi")).toBe(false);
expect(storedValue(5, "webUiConfig")).toBeUndefined();
});
});
@@ -75,17 +75,15 @@ function insertSession({ id, userId, sub, sid, providerId }: SessionInput) {
.sqlite!.prepare(
`INSERT INTO sessions (
id, user_id, jwt_token, device_type, device_info,
oidc_sub, oidc_sid, sso_provider_id,
external_session_ref,
created_at, expires_at, last_active_at
) VALUES (?, ?, ?, 'browser', 'test', ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, 'browser', 'test', ?, ?, ?, ?)`,
)
.run(
id,
userId,
`token-${id}`,
sub,
sid,
providerId,
JSON.stringify({ providerId, sub, sid }),
"2026-07-10T00:00:00.000Z",
"2026-07-11T00:00:00.000Z",
"2026-07-10T00:00:00.000Z",
@@ -108,9 +106,7 @@ describe("AuthManager.revokeSessionsByExternalSession", () => {
jwt_token TEXT NOT NULL,
device_type TEXT NOT NULL,
device_info TEXT NOT NULL,
oidc_sub TEXT,
oidc_sid TEXT,
sso_provider_id INTEGER,
external_session_ref TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_active_at TEXT NOT NULL
@@ -148,7 +144,7 @@ describe("AuthManager.revokeSessionsByExternalSession", () => {
await expect(
authManager.revokeSessionsByExternalSession({
ssoProviderId: 7,
providerId: 7,
sub: "subject-1",
sid: "session-1",
}),
@@ -182,7 +178,7 @@ describe("AuthManager.revokeSessionsByExternalSession", () => {
await expect(
authManager.revokeSessionsByExternalSession({
ssoProviderId: 7,
providerId: 7,
sub: "subject-1",
}),
).resolves.toBe(2);
@@ -190,10 +186,34 @@ describe("AuthManager.revokeSessionsByExternalSession", () => {
expect(sessionIds()).toEqual(["other-subject"]);
});
it("leaves sessions with no or a broken reference alone", async () => {
mocks
.sqlite!.prepare(
`INSERT INTO sessions (
id, user_id, jwt_token, device_type, device_info,
external_session_ref, created_at, expires_at, last_active_at
) VALUES (?, 'user-1', 't', 'browser', 'test', ?, 'a', 'b', 'c')`,
)
.run("broken", "{not json");
mocks
.sqlite!.prepare(
`INSERT INTO sessions (
id, user_id, jwt_token, device_type, device_info,
external_session_ref, created_at, expires_at, last_active_at
) VALUES (?, 'user-1', 't', 'browser', 'test', NULL, 'a', 'b', 'c')`,
)
.run("password");
await expect(
authManager.revokeSessionsByExternalSession({ sub: "subject-1" }),
).resolves.toBe(0);
expect(sessionIds()).toEqual(["broken", "password"]);
});
it("does not persist when no session matches", async () => {
await expect(
authManager.revokeSessionsByExternalSession({
ssoProviderId: 7,
providerId: 7,
sid: "missing",
}),
).resolves.toBe(0);
@@ -213,7 +233,7 @@ describe("AuthManager.revokeSessionsByExternalSession", () => {
await expect(
authManager.revokeSessionsByExternalSession({
ssoProviderId: 7,
providerId: 7,
sid: "session-1",
}),
).rejects.toThrow("disk full");
@@ -1,129 +0,0 @@
/**
* The SSH connection options moving out of ssh_data.terminal_config into
* the core ssh_options column, against a real SQLite database through the
* same raw SQL helpers the boot path uses.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ db: null as unknown }));
vi.mock("../../../database/db/index.js", () => ({ getDb: () => state.db }));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
const { runSshOptionsMigration } =
await import("../../../utils/crypto-migration/ssh-options-migration.js");
let sqlite: Database.Database;
beforeEach(() => {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE ssh_data (
id INTEGER PRIMARY KEY,
terminal_config TEXT,
ssh_options TEXT
);
`);
state.db = drizzle(sqlite);
});
function insert(id: number, terminalConfig: unknown, sshOptions?: string) {
sqlite
.prepare(
"INSERT INTO ssh_data (id, terminal_config, ssh_options) VALUES (?, ?, ?)",
)
.run(
id,
terminalConfig === null ? null : JSON.stringify(terminalConfig),
sshOptions ?? null,
);
}
function options(id: number): unknown {
const row = sqlite
.prepare("SELECT ssh_options FROM ssh_data WHERE id = ?")
.get(id) as { ssh_options: string | null };
return row.ssh_options === null ? null : JSON.parse(row.ssh_options);
}
describe("runSshOptionsMigration", () => {
it("copies only the connection options, leaving the look and old tunnel keys", async () => {
insert(1, {
theme: "dracula",
fontSize: 16,
keepaliveInterval: 30,
keepaliveCountMax: 3,
allowLegacyAlgorithms: false,
agentSocketPath: "/run/agent.sock",
agentIdentity: "work",
agentForwarding: true,
environmentVariables: [{ key: "LANG", value: "C" }],
startupSnippetId: 4,
cfAccessClientId: "old",
cfTunnelHostname: "old.example.com",
});
const result = await runSshOptionsMigration();
expect(result.hostsMoved).toBe(1);
expect(options(1)).toEqual({
keepaliveInterval: 30,
keepaliveCountMax: 3,
allowLegacyAlgorithms: false,
agentSocketPath: "/run/agent.sock",
agentIdentity: "work",
agentForwarding: true,
environmentVariables: [{ key: "LANG", value: "C" }],
});
const terminalConfig = sqlite
.prepare("SELECT terminal_config FROM ssh_data WHERE id = 1")
.get() as { terminal_config: string };
expect(JSON.parse(terminalConfig.terminal_config).keepaliveInterval).toBe(
30,
);
});
it("marks a host with no options as done and skips hosts with no config", async () => {
insert(1, { theme: "dracula" });
insert(2, null);
const result = await runSshOptionsMigration();
expect(result.hostsMoved).toBe(0);
expect(options(1)).toEqual({});
expect(options(2)).toBeNull();
});
it("changes nothing on a second run, even after the options were edited", async () => {
insert(1, { keepaliveInterval: 30 });
insert(
2,
{ keepaliveInterval: 45 },
JSON.stringify({ keepaliveCountMax: 9 }),
);
await runSshOptionsMigration();
sqlite
.prepare("UPDATE ssh_data SET ssh_options = ? WHERE id = 1")
.run(JSON.stringify({ keepaliveInterval: 90 }));
const second = await runSshOptionsMigration();
expect(second.hostsMoved).toBe(0);
expect(options(1)).toEqual({ keepaliveInterval: 90 });
expect(options(2)).toEqual({ keepaliveCountMax: 9 });
});
it("does not throw on a database without the column", async () => {
sqlite = new Database(":memory:");
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
state.db = drizzle(sqlite);
await expect(runSshOptionsMigration()).resolves.toEqual({
hostsMoved: 0,
});
});
});
@@ -1,143 +0,0 @@
/**
* The status half of ssh_data.stats_config moving into the core
* status_check_enabled and status_check_interval columns. Run against a real
* SQLite database through the same raw SQL helpers the boot path uses.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
settings: new Map<string, string>(),
db: null as unknown,
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings.get(key) ?? null,
set: async (key: string, value: string) => {
state.settings.set(key, value);
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
const {
runHostStatusConfigMigration,
statusConfigFromStatsConfig,
HOST_STATUS_CONFIG_MIGRATED,
} =
await import("../../utils/crypto-migration/host-status-config-migration.js");
let sqlite: Database.Database;
function createHosts(withStatsConfig = true) {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE ssh_data (
id INTEGER PRIMARY KEY,
${withStatsConfig ? "stats_config TEXT," : ""}
status_check_enabled INTEGER NOT NULL DEFAULT 1,
status_check_interval INTEGER
);
`);
state.db = drizzle(sqlite);
}
const hosts = () =>
sqlite
.prepare(
"SELECT id, status_check_enabled AS enabled, status_check_interval AS interval FROM ssh_data ORDER BY id",
)
.all();
beforeEach(() => {
state.settings.clear();
createHosts();
});
describe("statusConfigFromStatsConfig", () => {
it.each([
[null, true, null],
["{}", true, null],
['{"statusCheckEnabled":false}', false, null],
['{"disableTcpPing":true}', false, null],
['{"useGlobalStatusInterval":false,"statusCheckInterval":45}', true, 45],
['{"useGlobalStatusInterval":true,"statusCheckInterval":45}', true, null],
['{"useGlobalStatusInterval":false,"statusCheckInterval":2}', true, null],
[
JSON.stringify(JSON.stringify({ statusCheckEnabled: false })),
false,
null,
],
["not json", true, null],
])("%s", (raw, enabled, interval) => {
expect(statusConfigFromStatsConfig(raw)).toEqual({
statusCheckEnabled: enabled,
statusCheckInterval: interval,
});
});
});
describe("runHostStatusConfigMigration", () => {
it("copies each host's status options into the new columns", async () => {
const insert = sqlite.prepare(
"INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)",
);
insert.run(1, JSON.stringify({ statusCheckEnabled: false }));
insert.run(
2,
JSON.stringify({
useGlobalStatusInterval: false,
statusCheckInterval: 45,
}),
);
insert.run(
3,
JSON.stringify({ disableTcpPing: true, metricsEnabled: false }),
);
insert.run(4, JSON.stringify({ metricsInterval: 10 }));
insert.run(5, null);
const result = await runHostStatusConfigMigration();
expect(result).toEqual({ skipped: false, hostsMoved: 3 });
expect(hosts()).toEqual([
{ id: 1, enabled: 0, interval: null },
{ id: 2, enabled: 1, interval: 45 },
{ id: 3, enabled: 0, interval: null },
{ id: 4, enabled: 1, interval: null },
{ id: 5, enabled: 1, interval: null },
]);
expect(state.settings.get(HOST_STATUS_CONFIG_MIGRATED)).toBe("true");
});
it("changes nothing the second time", async () => {
sqlite
.prepare("INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)")
.run(1, JSON.stringify({ statusCheckEnabled: false }));
await runHostStatusConfigMigration();
// The user turns checks back on after the upgrade.
sqlite.exec("UPDATE ssh_data SET status_check_enabled = 1 WHERE id = 1");
expect(await runHostStatusConfigMigration()).toEqual({
skipped: true,
hostsMoved: 0,
});
expect(hosts()).toEqual([{ id: 1, enabled: 1, interval: null }]);
});
it("finishes on a fresh install that never had stats_config", async () => {
createHosts(false);
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
expect(await runHostStatusConfigMigration()).toEqual({
skipped: false,
hostsMoved: 0,
});
expect(state.settings.get(HOST_STATUS_CONFIG_MIGRATED)).toBe("true");
});
});
@@ -1,127 +0,0 @@
/**
* Moves the 2.8 ACME configuration into the acme-ssl plugin.
*
* 2.8 kept it as one JSON row, `acme_ssl_settings`, in core settings. Each
* field becomes a plugin admin setting. "manual" was never ACME (it meant an
* uploaded certificate, which core still handles), so it turns renewal off.
* The Cloudflare token was plaintext and lands as an encrypted secret; it is
* then removed from the old row. The rest of the row stays for one release.
*
* Idempotent: a field already set in plugin_settings is never overwritten.
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
const PLUGIN_ID = "acme-ssl";
const LEGACY_KEY = "acme_ssl_settings";
export interface AcmeSslSettingsMigrationResult {
moved: string[];
}
interface LegacyAcmeSettings {
enabled?: unknown;
domain?: unknown;
email?: unknown;
challengeType?: unknown;
cloudflareToken?: unknown;
}
const text = (value: unknown) =>
typeof value === "string" && value.trim() ? value.trim() : null;
export async function runAcmeSslSettingsMigration(): Promise<AcmeSslSettingsMigrationResult> {
const result: AcmeSslSettingsMigrationResult = { moved: [] };
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
const settings = createCurrentSettingsRepository();
const raw = await settings.get(LEGACY_KEY);
if (!raw) return result;
let legacy: LegacyAcmeSettings;
try {
legacy = JSON.parse(raw) as LegacyAcmeSettings;
} catch {
return result;
}
if (!legacy || typeof legacy !== "object") return result;
const pluginSettings = createCurrentPluginSettingsRepository();
const writeIfUnset = async (
field: string,
value: unknown,
secret = false,
) => {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) return;
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
field,
JSON.stringify(value),
secret,
);
result.moved.push(field);
};
const challenge = legacy.challengeType;
const manual = challenge === "manual";
const domain = text(legacy.domain);
if (domain) await writeIfUnset("domain", domain);
const email = text(legacy.email);
if (email) await writeIfUnset("email", email);
if (challenge === "dns-cloudflare") {
await writeIfUnset("challengeType", "dns-cloudflare");
} else if (challenge === "http-webroot") {
await writeIfUnset("challengeType", "http-01");
}
if (typeof legacy.enabled === "boolean") {
await writeIfUnset("autoRenew", legacy.enabled && !manual);
}
const token = text(legacy.cloudflareToken);
if (token) {
await writeIfUnset(
"cloudflareToken",
await encryptSystemSecret(token),
true,
);
const { cloudflareToken: _dropped, ...rest } = legacy;
await settings.set(LEGACY_KEY, JSON.stringify(rest));
}
if (result.moved.length > 0) {
databaseLogger.info(
`Moved ${result.moved.length} ACME setting(s) into plugin settings`,
{
operation: "acme_ssl_settings_migration",
moved: result.moved.join(", "),
},
);
}
} catch (error) {
// Must not stop the backend: the plugin then shows "not configured".
databaseLogger.warn("ACME settings migration failed", {
operation: "acme_ssl_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
+8 -144
View File
@@ -1,22 +1,13 @@
/**
* Moves the AI assistant's settings and provider keys into the ai plugin.
* Moves each AI provider's api_key, encrypted under its owner's data key,
* into a ctx.secrets row "provider:<id>" for that owner, and empties the
* column so no key material is left in the plugin's table. It needs the
* owner's data key, so it runs on every boot until the key opens.
*
* - settings.ai_globally_enabled and ai_private_endpoint_allowlist become the
* admin settings globallyEnabled and privateEndpoints (one host per line).
* - user_preferences.ai_assistant_enabled and ai_read_only_commands become
* each user's enabled and allowReadOnlyCommands.
* - ssh_data.enable_ai_assistant becomes the host setting enableAiAssistant.
* - Each provider's api_key, encrypted under its owner's data key, becomes a
* ctx.secrets row "provider:<id>" for that owner, and the column is emptied
* so no key material is left in the plugin's table.
*
* The old columns stay in the database, unused: core's drizzle migrations
* run before this does, so dropping them there would lose the data first.
* The providers table is ai_providers until the plugin adopts it and
* p_ai_providers after, so both names are tried.
*
* Idempotent: nothing is written where the plugin already has a row, and a
* key is only cleared once its copy exists. Safe on every boot.
* Idempotent: a key is only cleared once its copy exists.
*/
import { sql } from "drizzle-orm";
@@ -24,7 +15,6 @@ import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { LazyFieldEncryption } from "../utils/lazy-field-encryption.js";
@@ -37,37 +27,9 @@ import {
const PLUGIN_ID = "ai";
export interface AiSettingsMigrationResult {
admin: string[];
users: number;
hosts: number;
keys: number;
}
type Flag = number | boolean | string | null | undefined;
function isTrue(value: Flag): boolean {
return value === true || value === 1 || value === "1" || value === "true";
}
function isSet(value: Flag): boolean {
return value !== null && value !== undefined;
}
/** The legacy JSON array as the textarea the plugin now reads. */
export function allowlistToText(raw: string): string | null {
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
return parsed
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean)
.join("\n");
} catch {
return null;
}
}
async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
try {
return await selectRows<T>(query);
@@ -78,12 +40,7 @@ async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
}
export async function runAiSettingsMigration(): Promise<AiSettingsMigrationResult> {
const result: AiSettingsMigrationResult = {
admin: [],
users: 0,
hosts: 0,
keys: 0,
};
const result: AiSettingsMigrationResult = { keys: 0 };
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
@@ -96,91 +53,6 @@ export async function runAiSettingsMigration(): Promise<AiSettingsMigrationResul
const pluginSettings = createCurrentPluginSettingsRepository();
const setIfMissing = async (
scope: "admin" | "user" | "host",
scopeId: string | null,
key: string,
value: unknown,
): Promise<boolean> => {
const existing = await pluginSettings.get(PLUGIN_ID, scope, scopeId, key);
if (existing) return false;
await pluginSettings.set(
PLUGIN_ID,
scope,
scopeId,
key,
JSON.stringify(value),
);
return true;
};
try {
const settings = createCurrentSettingsRepository();
const enabled = await settings.get("ai_globally_enabled");
if (enabled !== null && enabled !== "") {
if (await setIfMissing("admin", null, "globallyEnabled", isTrue(enabled)))
result.admin.push("globallyEnabled");
}
const allowlist = await settings.get("ai_private_endpoint_allowlist");
const text = allowlist ? allowlistToText(allowlist) : null;
if (text !== null) {
if (await setIfMissing("admin", null, "privateEndpoints", text))
result.admin.push("privateEndpoints");
}
} catch (error) {
warn("admin settings", error);
}
const users = await tryRows<{
user_id: string;
ai_assistant_enabled: Flag;
ai_read_only_commands: Flag;
}>(
sql`SELECT user_id, ai_assistant_enabled, ai_read_only_commands FROM user_preferences`,
);
for (const row of users) {
try {
let moved = false;
if (isSet(row.ai_assistant_enabled)) {
moved =
(await setIfMissing(
"user",
row.user_id,
"enabled",
isTrue(row.ai_assistant_enabled),
)) || moved;
}
if (isSet(row.ai_read_only_commands)) {
moved =
(await setIfMissing(
"user",
row.user_id,
"allowReadOnlyCommands",
isTrue(row.ai_read_only_commands),
)) || moved;
}
if (moved) result.users++;
} catch (error) {
warn("a user's settings", error);
}
}
const hosts = await tryRows<{ id: number; enable_ai_assistant: Flag }>(
sql`SELECT id, enable_ai_assistant FROM ssh_data`,
);
for (const row of hosts) {
if (!isTrue(row.enable_ai_assistant)) continue;
try {
if (
await setIfMissing("host", String(row.id), "enableAiAssistant", true)
) {
result.hosts++;
}
} catch (error) {
warn("a host's setting", error);
}
}
for (const table of ["ai_providers", "p_ai_providers"]) {
const providers = await tryRows<{
id: number;
@@ -227,17 +99,9 @@ export async function runAiSettingsMigration(): Promise<AiSettingsMigrationResul
}
}
if (
result.admin.length > 0 ||
result.users > 0 ||
result.hosts > 0 ||
result.keys > 0
) {
databaseLogger.info("Moved AI assistant settings into the ai plugin", {
if (result.keys > 0) {
databaseLogger.info("Moved AI provider keys into the ai plugin", {
operation: "ai_settings_migration",
admin: result.admin.join(", "),
users: result.users,
hosts: result.hosts,
keys: result.keys,
});
}
+2 -53
View File
@@ -1,5 +1,6 @@
/**
* Boot copies of 2.8 data into plugin settings and plugin tables.
* Boot copies of 2.8 data into plugin settings and plugin tables. Only the
* ones that need a user's data key are left: they finish once it opens.
*
* 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
@@ -10,70 +11,18 @@
import { databaseLogger } from "../utils/logger.js";
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
import { getErrorMessage } from "../utils/error-message.js";
import { runTailscaleSettingsMigration } from "./tailscale-settings-migration.js";
import { runProxmoxSettingsMigration } from "./proxmox-settings-migration.js";
import { runFileManagerSettingsMigration } from "./file-manager-settings-migration.js";
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 { runTelemetrySettingsMigration } from "./telemetry-settings-migration.js";
import { runSessionRecordingSettingsMigration } from "./session-recording-settings-migration.js";
import { runRemoteDesktopSettingsMigration } from "./remote-desktop-settings-migration.js";
import { runHostMetricsSettingsMigration } from "./host-metrics-settings-migration.js";
import { runDockerSettingsMigration } from "./docker-settings-migration.js";
import { runAiSettingsMigration } from "./ai-settings-migration.js";
import { runWakeOnLanSettingsMigration } from "./wake-on-lan-settings-migration.js";
import { runWarpgateSettingsMigration } from "./warpgate-settings-migration.js";
import { runStepCaSettingsMigration } from "./step-ca-settings-migration.js";
import { runAcmeSslSettingsMigration } from "./acme-ssl-settings-migration.js";
import { runVaultSettingsMigration } from "./vault-settings-migration.js";
import { runSecretSourcesTokenMigration } from "./secret-sources-token-migration.js";
import { runTotpMigration } from "./totp-migration.js";
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
import { runSsoSettingsMigration } from "./sso-settings-migration.js";
import { runSsoLegacyIdentityMigration } from "./sso-legacy-identity-migration.js";
import { runHostDefaultsMigration } from "./host-defaults-migration.js";
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
["runTailscaleSettingsMigration", runTailscaleSettingsMigration],
["runProxmoxSettingsMigration", runProxmoxSettingsMigration],
["runFileManagerSettingsMigration", runFileManagerSettingsMigration],
["runTunnelsSettingsMigration", runTunnelsSettingsMigration],
["runWebEndpointSettingsMigration", runWebEndpointSettingsMigration],
["runSshTerminalSettingsMigration", runSshTerminalSettingsMigration],
["runSshTerminalLookMigration", runSshTerminalLookMigration],
["runSshTerminalMacrosMigration", runSshTerminalMacrosMigration],
["runSnippetsSettingsMigration", runSnippetsSettingsMigration],
["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],
["runNotificationChannelMigration", runNotificationChannelMigration],
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
["runSsoSettingsMigration", runSsoSettingsMigration],
["runSsoLegacyIdentityMigration", runSsoLegacyIdentityMigration],
["runTelemetrySettingsMigration", runTelemetrySettingsMigration],
// Last: it reads what the moves above wrote.
["runHostDefaultsMigration", runHostDefaultsMigration],
];
export const runPluginDataMigrations = DatabaseSaveTrigger.batched(
@@ -1,118 +0,0 @@
/**
* Moves each host's Docker options out of ssh_data and into the docker
* plugin's host settings: enable_docker becomes enableDocker and the runtime
* in docker_config becomes containerRuntime.
*
* The columns stay in the database, unused: core's drizzle migrations run
* before this does, so dropping them there would lose the data first.
*
* Idempotent: a host that already has any docker row is skipped, so this is
* safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
import { selectRows } from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "docker";
interface LegacyHostRow {
id: number;
enable_docker: number | boolean | string | null;
docker_config: string | null;
}
/** The plugin's host settings for one legacy row, defaults left out. */
export function dockerSettingsFromRow(
row: LegacyHostRow,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
const flag = row.enable_docker;
if (flag === true || flag === 1 || flag === "1" || flag === "true") {
values.enableDocker = true;
}
if (row.docker_config) {
try {
const config = JSON.parse(row.docker_config) as { runtime?: unknown };
if (config?.runtime === "podman") values.containerRuntime = "podman";
} catch {
// unreadable config means the default runtime
}
}
return values;
}
export interface DockerSettingsMigrationResult {
hostsMoved: number;
hostsSkipped: number;
}
export async function runDockerSettingsMigration(): Promise<DockerSettingsMigrationResult> {
const result: DockerSettingsMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
let rows: LegacyHostRow[];
try {
// Raw SQL: schema.ts no longer declares these columns.
rows = await selectRows<LegacyHostRow>(
sql`SELECT id, enable_docker, docker_config FROM ssh_data`,
);
} catch {
// A fresh install never had the columns.
return result;
}
for (const row of rows) {
const values = dockerSettingsFromRow(row);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
try {
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
if (existing.length > 0) {
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("Docker host settings migration failed for a host", {
operation: "docker_settings_migration",
hostId: row.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
if (result.hostsMoved > 0) {
databaseLogger.info(
`Moved Docker settings for ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "docker_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,31 @@
/**
* Renames the auto-provision setting from its 2.8 name, oidc_auto_provision,
* to external_auto_provision: it covers every external login, not only OIDC.
*
* Idempotent: a value already under the new key wins and the old row is
* dropped.
*/
import { databaseLogger } from "../utils/logger.js";
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import { EXTERNAL_AUTO_PROVISION_KEY } from "../auth/provisioning.js";
const LEGACY_KEY = "oidc_auto_provision";
export async function runExternalAutoProvisionMigration(): Promise<boolean> {
try {
const settings = createCurrentSettingsRepository();
const legacy = await settings.get(LEGACY_KEY);
if (legacy === null) return false;
if ((await settings.get(EXTERNAL_AUTO_PROVISION_KEY)) === null) {
await settings.set(EXTERNAL_AUTO_PROVISION_KEY, legacy);
}
await settings.delete(LEGACY_KEY);
return true;
} catch (error) {
databaseLogger.warn("Auto-provision setting rename failed", {
operation: "external_auto_provision_migration",
error: error instanceof Error ? error.message : String(error),
});
return false;
}
}
@@ -1,117 +0,0 @@
/**
* Moves sign-in identities into their own table.
*
* users.oidc_identifier packed provider and subject into one string:
* "ldap:<provider>:<id>", "github:<provider>:<id>", or a bare OIDC subject
* whose provider was users.sso_provider_id. Each becomes a
* user_external_identities row; LDAP ones under provider "ldap:<provider>",
* the id the ldap plugin signs in with. TOTP enrolment moves in totp-migration.ts.
*
* Lossless: the old column is left as it is. Idempotent: rows that already
* exist are skipped, so it runs on every boot.
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentUserAuthRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
/** The provider id the sso plugin gives its env-configured provider. */
const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
export interface ExternalIdentityMigrationResult {
identities: number;
skipped: number;
}
/** Splits a stored identifier into provider and subject. */
export function parseLegacyIdentifier(
identifier: string,
ssoProviderId: number | null | undefined,
): { providerId: string; subject: string } | null {
if (!identifier) return null;
const prefixed = /^(ldap|github):([^:]+):(.+)$/.exec(identifier);
if (prefixed) {
const provider = prefixed[2];
const missing = provider === "null" || provider === "undefined";
// LDAP providers moved to their own table in 2.9 and their identities
// carry the "ldap:" prefix so their ids cannot clash with SSO ones.
if (prefixed[1] === "ldap") {
return missing
? null
: { providerId: `ldap:${provider}`, subject: prefixed[3] };
}
return {
providerId: missing ? LEGACY_OIDC_PROVIDER_ID : provider,
subject: prefixed[3],
};
}
return {
providerId:
ssoProviderId !== null && ssoProviderId !== undefined
? String(ssoProviderId)
: LEGACY_OIDC_PROVIDER_ID,
subject: identifier,
};
}
export async function runExternalIdentityMigration(): Promise<ExternalIdentityMigrationResult> {
const result: ExternalIdentityMigrationResult = {
identities: 0,
skipped: 0,
};
try {
const users = await createCurrentUserRepository().listAll();
const auth = createCurrentUserAuthRepository();
for (const user of users) {
if (user.oidcIdentifier) {
const parsed = parseLegacyIdentifier(
user.oidcIdentifier,
user.ssoProviderId,
);
if (parsed) {
const existing = await auth.findIdentity(
parsed.providerId,
parsed.subject,
);
// sso-legacy-identity-migration.ts moves "legacy-oidc" rows to
// their provider; recreating one would undo that on every boot.
const moved =
!existing &&
parsed.providerId === LEGACY_OIDC_PROVIDER_ID &&
(await auth.listIdentitiesForUser(user.id)).some(
(link) => link.subject === parsed.subject,
);
if (existing || moved) {
result.skipped++;
} else {
await auth.linkIdentity({
userId: user.id,
providerId: parsed.providerId,
subject: parsed.subject,
});
result.identities++;
}
}
}
}
if (result.identities > 0) {
databaseLogger.info("Moved sign-in identities", {
operation: "external_identity_migration",
...result,
});
}
} catch (error) {
databaseLogger.error("External identity migration failed", error, {
operation: "external_identity_migration",
});
}
return result;
}
@@ -1,102 +0,0 @@
/**
* Moves the three file-manager host columns into the file-manager plugin's
* host-scope settings, before those columns are dropped from ssh_data.
*
* Idempotent: skips a host that already has a file-manager settings row, so
* it is safe to run on every boot. Lossless: reads straight off the
* still-present ssh_data columns, so it must run and finish before those
* columns are ever dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
legacyFlag,
selectLegacyRows,
} from "../utils/crypto-migration/raw-rows.js";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
export interface FileManagerSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyFileManagerRow {
id: number;
enable_file_manager: number | boolean | null;
default_path: string | null;
scp_legacy: number | boolean | null;
}
export async function runFileManagerSettingsMigration(): Promise<FileManagerSettingsMigrationResult> {
const result: FileManagerSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
// Raw SQL, not the typed schema: this runs once schema.ts has already
// dropped these columns, so the query is the only thing left that still
// knows they used to exist.
const rows = await selectLegacyRows<LegacyFileManagerRow>(sql`
SELECT id, enable_file_manager, default_path, scp_legacy
FROM ssh_data
WHERE enable_file_manager = false OR default_path IS NOT NULL
OR scp_legacy = true
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"file-manager",
"host",
scopeId,
"enableFileManager",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"file-manager",
"host",
scopeId,
"enableFileManager",
JSON.stringify(legacyFlag(row.enable_file_manager, true)),
);
await pluginSettingsRepository.set(
"file-manager",
"host",
scopeId,
"defaultPath",
JSON.stringify(row.default_path ?? null),
);
await pluginSettingsRepository.set(
"file-manager",
"host",
scopeId,
"scpLegacy",
JSON.stringify(!!row.scp_legacy),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved file manager settings for ${result.moved} host(s) into plugin settings`,
{ operation: "file_manager_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep
// whatever file-manager settings they already had (none, on a fresh run).
databaseLogger.warn("File manager settings migration failed", {
operation: "file_manager_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
@@ -1,447 +0,0 @@
/**
* Moves 2.8 and early 2.9 default settings into host defaults, and gets hosts
* ready to be classified against them.
*
* Levels, each moved once:
* - core `settings.host_defaults` (SOCKS5 and the status check) becomes the
* server level. Its credential and SOCKS5 password are dropped: a
* credential belongs to one user and a password is never a default.
* - a folder's credential becomes that folder's auth default.
* - ssh-terminal's admin "new host" look, tmux and command history become the
* server level; each user's terminal defaults, local echo and link click
* setting become their user level.
* - host-metrics' "enabled for new hosts" becomes the server level.
* - remote-desktop's per-user display settings become the user level.
*
* Hosts: a host saved before host defaults gets the value it really used
* written in, where that was not its stored value. A terminal that
* followed the user's look used the user's look; an echo mode of "default"
* used the user's setting; a credential host with no credential used its
* folder's; a remote desktop used the connecting user's display settings.
* The classify pass that follows compares those against the new levels, so
* nothing a user sees changes.
*
* The old rows stay in place for a downgrade until 26.10.0.
*/
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
createCurrentHostDefaultsRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import type { HostRow } from "../database/repositories/host-defaults-repository.js";
import { hostSettingsPlugins } from "../database/routes/host-plugin-settings.js";
import {
CORE_NAMESPACE,
defaultValuesEqual,
normalizeSocks5Default,
parseDefaultOverrides,
} from "../../types/host-defaults.js";
import { encodeDefaultValue } from "../hosts/defaults/levels.js";
import { folderChainPaths } from "../hosts/defaults/resolve.js";
import { databaseLogger } from "../utils/logger.js";
const TERMINAL = "ssh-terminal";
const METRICS = "host-metrics";
const DESKTOP = "remote-desktop";
const TERMINAL_APPEARANCE_KEYS = [
"theme",
"cursorBlink",
"cursorStyle",
"fontSize",
"fontFamily",
"scrollback",
"letterSpacing",
"lineHeight",
"bellStyle",
"minimumContrastRatio",
"backgroundImage",
"backgroundImageOpacity",
"customThemeColors",
];
/** ssh-terminal admin key -> the host key it seeded. */
const TERMINAL_NEW_HOST_KEYS: Record<string, string> = {
commandHistoryForNewHosts: "enableCommandHistory",
newHostTheme: "theme",
newHostFontFamily: "fontFamily",
newHostFontSize: "fontSize",
newHostCursorStyle: "cursorStyle",
newHostCursorBlink: "cursorBlink",
newHostAutoTmux: "autoTmux",
};
/** ssh-terminal keys whose "default" meant the user's own setting. */
const TERMINAL_USER_MODES: Record<string, string> = {
localEcho: "auto",
linkClickBehavior: "confirm",
};
const DESKTOP_DISPLAY_KEYS = [
"colorDepth",
"resizeMethod",
"forceLossless",
"enableWallpaper",
"enableFontSmoothing",
"enableDesktopComposition",
"disableAudio",
"enablePrinting",
"enableDrive",
"disableCopy",
"disablePaste",
];
const MARKER_PREFIX = "host_defaults_moved_";
function decode(raw: string | null | undefined): unknown {
if (raw === null || raw === undefined) return undefined;
try {
let parsed = JSON.parse(raw);
if (typeof parsed === "string" && /^[[{]/.test(parsed)) {
parsed = JSON.parse(parsed);
}
return parsed;
} catch {
return undefined;
}
}
function asObject(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
async function once(name: string, run: () => Promise<void>): Promise<void> {
const settings = createCurrentSettingsRepository();
const marker = `${MARKER_PREFIX}${name}`;
if (await settings.get(marker)) return;
await run();
await settings.set(marker, new Date().toISOString());
}
type Level =
| { level: "admin" }
| { level: "user"; userId: string }
| { level: "folder"; userId: string; folderId: number };
async function setDefault(
scope: Level,
namespace: string,
key: string,
value: unknown,
): Promise<void> {
const repository = createCurrentHostDefaultsRepository();
const existing = await repository.listScope(scope);
if (existing.some((row) => row.namespace === namespace && row.key === key)) {
return;
}
await repository.apply(
scope,
[{ namespace, key, value: encodeDefaultValue(value) }],
[],
null,
);
}
async function moveCoreLevels(): Promise<void> {
const raw = await createCurrentSettingsRepository().get("host_defaults");
const stored = asObject(decode(raw)) ?? {};
if (stored.useSocks5 === true) {
await setDefault(
{ level: "admin" },
CORE_NAMESPACE,
"socks5",
normalizeSocks5Default({ ...stored, socks5ProxyChain: null }),
);
}
if (stored.statusCheckEnabled === false) {
await setDefault(
{ level: "admin" },
CORE_NAMESPACE,
"statusCheckEnabled",
false,
);
}
if (stored.credentialId !== undefined || stored.socks5Password) {
databaseLogger.info(
"Host defaults no longer hold a credential or proxy password; set a credential per user or folder instead",
{ operation: "host_defaults_migration" },
);
}
}
async function moveFolderCredentials(): Promise<void> {
const { createCurrentRepositoryContext } =
await import("../database/repositories/factory.js");
const { sshFolders } = await import("../database/db/schema.js");
const { isNotNull } = await import("drizzle-orm");
const rows = await createCurrentRepositoryContext()
.drizzle.select({
id: sshFolders.id,
userId: sshFolders.userId,
credentialId: sshFolders.credentialId,
})
.from(sshFolders)
.where(isNotNull(sshFolders.credentialId));
for (const row of rows) {
await setDefault(
{ level: "folder", userId: row.userId, folderId: row.id },
CORE_NAMESPACE,
"auth",
{
authType: "credential",
credentialId: row.credentialId,
overrideCredentialUsername: false,
agentSocketPath: null,
agentIdentity: null,
},
);
}
}
async function storedRows(
pluginId: string,
scope: "admin" | "user",
key: string,
) {
return (
await createCurrentPluginSettingsRepository().listByKey(
pluginId,
scope,
key,
)
).filter((row) => row.value !== null);
}
async function moveTerminalLevels(): Promise<void> {
for (const [adminKey, hostKey] of Object.entries(TERMINAL_NEW_HOST_KEYS)) {
for (const row of await storedRows(TERMINAL, "admin", adminKey)) {
const value = decode(row.value);
if (value === undefined || value === null) continue;
await setDefault({ level: "admin" }, TERMINAL, hostKey, value);
}
}
for (const row of await storedRows(TERMINAL, "user", "terminalDefaults")) {
const values = asObject(decode(row.value));
if (!values || !row.scopeId) continue;
for (const key of TERMINAL_APPEARANCE_KEYS) {
if (values[key] === undefined) continue;
await setDefault(
{ level: "user", userId: row.scopeId },
TERMINAL,
key,
values[key],
);
}
}
for (const key of Object.keys(TERMINAL_USER_MODES)) {
for (const row of await storedRows(TERMINAL, "user", key)) {
const value = decode(row.value);
if (typeof value !== "string" || !row.scopeId) continue;
await setDefault(
{ level: "user", userId: row.scopeId },
TERMINAL,
key,
value,
);
}
}
}
async function moveMetricsLevels(): Promise<void> {
for (const row of await storedRows(METRICS, "admin", "enabledForNewHosts")) {
if (decode(row.value) === false) {
await setDefault({ level: "admin" }, METRICS, "metricsEnabled", false);
}
}
}
async function moveDesktopLevels(): Promise<void> {
for (const key of DESKTOP_DISPLAY_KEYS) {
for (const row of await storedRows(DESKTOP, "user", key)) {
const value = decode(row.value);
if (typeof value !== "string" || value === "inherit" || !row.scopeId) {
continue;
}
await setDefault(
{ level: "user", userId: row.scopeId },
DESKTOP,
key,
value,
);
}
}
}
/**
* Hosts saved before host defaults (by an older release, or 2.8 after a
* downgrade), which still carry the old meaning. A host saved since has its
* overrides set, and a namespace it lacks is classified as it stands.
*/
function unclassified(rows: HostRow[]): HostRow[] {
return rows.filter(
(row) =>
!row.sharedSource && parseDefaultOverrides(row.defaultOverrides) === null,
);
}
async function userValues(
pluginId: string,
key: string,
): Promise<Map<string, unknown>> {
const result = new Map<string, unknown>();
for (const row of await storedRows(pluginId, "user", key)) {
if (row.scopeId) result.set(row.scopeId, decode(row.value));
}
return result;
}
function manifestDefault(manifest: PluginManifest, key: string): unknown {
return manifest.contributes?.settings?.host?.fields.find(
(field) => field.key === key,
)?.default;
}
/**
* Writes one host value, or removes the row when it is the manifest default
* so the host reads that default the usual way.
*/
async function writeHostValue(
manifest: PluginManifest,
hostId: number,
key: string,
value: unknown,
): Promise<void> {
const settings = createCurrentPluginSettingsRepository();
if (defaultValuesEqual(value, manifestDefault(manifest, key))) {
await settings.delete(manifest.id, "host", String(hostId), key);
} else {
await settings.set(
manifest.id,
"host",
String(hostId),
key,
JSON.stringify(value),
);
}
}
async function bakeTerminalHosts(
manifest: PluginManifest,
rows: HostRow[],
): Promise<void> {
const hosts = unclassified(rows);
if (hosts.length === 0) return;
const settings = createCurrentPluginSettingsRepository();
const lookByUser = await userValues(TERMINAL, "terminalDefaults");
const modesByUser = new Map(
await Promise.all(
Object.keys(TERMINAL_USER_MODES).map(
async (key) => [key, await userValues(TERMINAL, key)] as const,
),
),
);
for (const host of hosts) {
const stored = new Map(
(await settings.getAll(TERMINAL, "host", String(host.id))).map((row) => [
row.key,
decode(row.value),
]),
);
if (stored.get("inheritAppearance") !== false) {
const look = asObject(lookByUser.get(host.userId)) ?? {};
for (const key of TERMINAL_APPEARANCE_KEYS) {
const value = look[key] ?? manifestDefault(manifest, key);
await writeHostValue(manifest, host.id, key, value);
}
}
for (const [key, fallback] of Object.entries(TERMINAL_USER_MODES)) {
const own = stored.get(key);
if (own !== undefined && own !== "default") continue;
const user = modesByUser.get(key)?.get(host.userId);
await writeHostValue(
manifest,
host.id,
key,
typeof user === "string" && user !== "default" ? user : fallback,
);
}
}
}
async function bakeDesktopHosts(
manifest: PluginManifest,
rows: HostRow[],
): Promise<void> {
const hosts = unclassified(rows);
if (hosts.length === 0) return;
const byKey = new Map(
await Promise.all(
DESKTOP_DISPLAY_KEYS.map(
async (key) => [key, await userValues(DESKTOP, key)] as const,
),
),
);
for (const host of hosts) {
for (const key of DESKTOP_DISPLAY_KEYS) {
const value = byKey.get(key)?.get(host.userId);
if (typeof value !== "string" || value === "inherit") continue;
await writeHostValue(manifest, host.id, key, value);
}
}
}
/** A credential host with no credential of its own used its folder's. */
async function bakeFolderCredentials(rows: HostRow[]): Promise<void> {
const hosts = unclassified(rows).filter(
(row) => row.authType === "credential" && !row.credentialId && row.folder,
);
if (hosts.length === 0) return;
const repository = createCurrentHostDefaultsRepository();
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const resolution = createCurrentHostResolutionRepository();
const patches: Array<{ id: number; values: Partial<HostRow> }> = [];
for (const host of hosts) {
if (folderChainPaths(host.folder).length === 0) continue;
const credentialId = await resolution.findFolderCredentialId(
host.userId,
host.folder!,
);
if (credentialId) patches.push({ id: host.id, values: { credentialId } });
}
await repository.updateHosts(patches);
}
export async function runHostDefaultsMigration(): Promise<void> {
const manifests = new Map(
hostSettingsPlugins().map((manifest) => [manifest.id, manifest]),
);
try {
await once("core", async () => {
await moveCoreLevels();
await moveFolderCredentials();
});
if (manifests.has(TERMINAL)) await once(TERMINAL, moveTerminalLevels);
if (manifests.has(METRICS)) await once(METRICS, moveMetricsLevels);
if (manifests.has(DESKTOP)) await once(DESKTOP, moveDesktopLevels);
const rows = await createCurrentHostDefaultsRepository().listHosts({
all: true,
});
await bakeFolderCredentials(rows);
const terminal = manifests.get(TERMINAL);
if (terminal) await bakeTerminalHosts(terminal, rows);
const desktop = manifests.get(DESKTOP);
if (desktop) await bakeDesktopHosts(desktop, rows);
} catch (error) {
databaseLogger.warn("Host defaults migration failed", {
operation: "host_defaults_migration",
error: error instanceof Error ? error.message : String(error),
});
}
}
@@ -1,182 +0,0 @@
/**
* Moves Host Metrics' settings out of core and into the host-metrics
* plugin's own settings.
*
* Admin keys from `settings`: global_metrics_interval,
* metrics_history_retention_days and metricsEnabled inside host_defaults (the
* default for new hosts). Per host, the metrics half of
* ssh_data.stats_config: metricsEnabled, the host's own interval, the enabled
* widgets and the mount lists. The status half is core and moved by
* host-status-config-migration.ts.
*
* The old settings rows and the column stay in place, unused.
*
* Idempotent: an admin key already set, or a host that already has any
* host-metrics row, is skipped, so this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { selectRows } from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "host-metrics";
/** A JSON object stored as text, or {} when it is anything else. */
function parseStatsConfig(raw: string | null): Record<string, unknown> {
try {
let parsed: unknown = raw ? JSON.parse(raw) : null;
if (typeof parsed === "string") parsed = JSON.parse(parsed);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
function seconds(value: unknown, min: number, max: number): number | null {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= min && parsed <= max
? parsed
: null;
}
/** The plugin's host settings for one stats_config value, defaults left out. */
export function hostSettingsFromStatsConfig(
raw: string | null,
): Record<string, unknown> {
const config = parseStatsConfig(raw);
const values: Record<string, unknown> = {};
if (config.metricsEnabled === false) values.metricsEnabled = false;
if (config.useGlobalMetricsInterval === false) {
const interval = seconds(config.metricsInterval, 5, 3600);
if (interval !== null) values.metricsInterval = interval;
}
if (Array.isArray(config.enabledWidgets)) {
values.enabledWidgets = config.enabledWidgets.filter(
(widget): widget is string => typeof widget === "string",
);
}
if (Array.isArray(config.excludedMounts) && config.excludedMounts.length) {
values.excludedMounts = config.excludedMounts.filter(
(mount): mount is string => typeof mount === "string",
);
}
if (Array.isArray(config.monitoredMounts) && config.monitoredMounts.length) {
values.monitoredMounts = config.monitoredMounts.filter(
(mount) =>
!!mount &&
typeof mount === "object" &&
typeof (mount as { path?: unknown }).path === "string",
);
}
return values;
}
export interface HostMetricsSettingsMigrationResult {
moved: string[];
hostsMoved: number;
hostsSkipped: number;
}
export async function runHostMetricsSettingsMigration(): Promise<HostMetricsSettingsMigrationResult> {
const result: HostMetricsSettingsMigrationResult = {
moved: [],
hostsMoved: 0,
hostsSkipped: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
const write = (
scope: "admin" | "host",
scopeId: string | null,
key: string,
value: unknown,
) =>
pluginSettings.set(PLUGIN_ID, scope, scopeId, key, JSON.stringify(value));
try {
const settings = createCurrentSettingsRepository();
const moves: [string, string, number, number][] = [
["global_metrics_interval", "metricsInterval", 5, 3600],
["metrics_history_retention_days", "historyRetentionDays", 1, 90],
];
for (const [legacyKey, field, min, max] of moves) {
const value = seconds(await settings.get(legacyKey), min, max);
if (value === null) continue;
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) continue;
await write("admin", null, field, value);
result.moved.push(legacyKey);
}
const defaults = parseStatsConfig(await settings.get("host_defaults"));
if (defaults.metricsEnabled === false) {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
"enabledForNewHosts",
);
if (!existing || existing.value === null) {
await write("admin", null, "enabledForNewHosts", false);
result.moved.push("host_defaults.metricsEnabled");
}
}
} catch (error) {
databaseLogger.warn("Host Metrics admin settings migration failed", {
operation: "host_metrics_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
try {
// Raw SQL: schema.ts no longer declares stats_config.
const rows = await selectRows<{ id: number; stats_config: string | null }>(
sql`SELECT id, stats_config FROM ssh_data WHERE stats_config IS NOT NULL`,
);
for (const row of rows) {
const values = hostSettingsFromStatsConfig(row.stats_config);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
if (existing.length > 0) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await write("host", scopeId, key, value);
}
result.hostsMoved++;
}
} catch {
// A fresh install never had the column.
}
if (result.moved.length > 0 || result.hostsMoved > 0) {
databaseLogger.info(
`Moved ${result.moved.length} Host Metrics setting(s) and ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "host_metrics_settings_migration" },
);
}
return result;
}
+4 -32
View File
@@ -3,27 +3,20 @@
*
* The config file moves from DATA_DIR/.opk/config.yml to the plugin's own
* folder (DATA_DIR/plugin-data/opkssh/config.yml). The old file is copied, not
* moved, so a downgrade still finds it. An install that had a config also
* keeps sending the pre-2.9 redirect URI (/host/opkssh-callback), because
* that is the one registered with its identity providers: the plugin's
* legacyCallback admin setting is turned on.
* moved, so a downgrade still finds it.
*
* Idempotent: an existing target file or setting is never overwritten.
* Idempotent: an existing target file is never overwritten.
*/
import { promises as fs } from "node:fs";
import path from "node:path";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
import { createCurrentPluginRepository } from "../database/repositories/factory.js";
const PLUGIN_ID = "opkssh";
export interface OpksshConfigMigrationResult {
copied: boolean;
legacyCallback: boolean;
}
async function exists(file: string): Promise<boolean> {
@@ -39,10 +32,7 @@ export async function runOpksshConfigMigration(
dataDir: string = process.env.DATA_DIR ||
path.join(process.cwd(), "db", "data"),
): Promise<OpksshConfigMigrationResult> {
const result: OpksshConfigMigrationResult = {
copied: false,
legacyCallback: false,
};
const result: OpksshConfigMigrationResult = { copied: false };
const legacyConfig = path.join(dataDir, ".opk", "config.yml");
if (!(await exists(legacyConfig))) return result;
@@ -61,24 +51,6 @@ export async function runOpksshConfigMigration(
await fs.copyFile(legacyConfig, target);
result.copied = true;
}
const settings = createCurrentPluginSettingsRepository();
const current = await settings.get(
PLUGIN_ID,
"admin",
null,
"legacyCallback",
);
if (!current) {
await settings.set(
PLUGIN_ID,
"admin",
null,
"legacyCallback",
JSON.stringify(true),
);
result.legacyCallback = true;
}
} catch (error) {
databaseLogger.warn("OPKSSH config migration failed", {
operation: "opkssh_config_migration",
+1 -3
View File
@@ -9,16 +9,14 @@ import { runLdapProviderMigration } from "./ldap-provider-migration.js";
import { runOpksshConfigMigration } from "./opkssh-config-migration.js";
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
import { runSessionRecordingDataMigration } from "./session-recording-data-migration.js";
import { runHostMetricsSettingsMigration } from "./host-metrics-settings-migration.js";
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
export async function runPluginDataMoves(): Promise<void> {
await runLdapProviderMigration();
await runOpksshConfigMigration();
await runTermixIdentityCaMigration();
// Both set how long history is kept, which the plugin prunes by on activate.
// Sets how long history is kept, which the plugin prunes by on activate.
await runSessionRecordingDataMigration();
await runHostMetricsSettingsMigration();
// Automations name channels by id, so they are in place before it starts.
await runNotificationChannelMigration();
}
@@ -1,119 +0,0 @@
/**
* Moves the four Proxmox host columns into the proxmox plugin's host-scope
* settings, before those columns are dropped from ssh_data.
*
* Idempotent: skips a host that already has a proxmox settings row, so it is
* safe to run on every boot. Lossless: reads straight off the still-present
* ssh_data columns, so it must run and finish before those columns are ever
* dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
export interface ProxmoxSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyProxmoxRow {
id: number;
enable_proxmox: number | boolean | null;
proxmox_config: string | null;
enable_proxmox_stats: number | boolean | null;
proxmox_stats_config: string | null;
}
export async function runProxmoxSettingsMigration(): Promise<ProxmoxSettingsMigrationResult> {
const result: ProxmoxSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
// Raw SQL, not the typed schema: this runs once schema.ts has already
// dropped these columns, so the query is the only thing left that still
// knows they used to exist.
const rows = await selectLegacyRows<LegacyProxmoxRow>(sql`
SELECT id, enable_proxmox, proxmox_config, enable_proxmox_stats, proxmox_stats_config
FROM ssh_data
WHERE enable_proxmox = true OR proxmox_config IS NOT NULL
OR enable_proxmox_stats = true OR proxmox_stats_config IS NOT NULL
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"proxmox",
"host",
scopeId,
"enableProxmox",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"enableProxmox",
JSON.stringify(!!row.enable_proxmox),
);
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"proxmoxConfig",
JSON.stringify(
row.proxmox_config ? safeParse(row.proxmox_config) : null,
),
);
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"enableProxmoxStats",
JSON.stringify(!!row.enable_proxmox_stats),
);
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"proxmoxStatsConfig",
JSON.stringify(
row.proxmox_stats_config ? safeParse(row.proxmox_stats_config) : null,
),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved Proxmox settings for ${result.moved} host(s) into plugin settings`,
{ operation: "proxmox_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep
// whatever proxmox settings they already had (none, on a fresh run).
databaseLogger.warn("Proxmox settings migration failed", {
operation: "proxmox_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
function safeParse(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return null;
}
}
@@ -1,289 +0,0 @@
/**
* Moves Remote Desktop's settings out of core and into the remote-desktop
* plugin's own settings.
*
* Admin keys from `settings`: guac_enabled and guac_url. Per user, the RDP
* defaults JSON in user_preferences.rdp_defaults. Per host, the option
* columns in ssh_data: the three protocol switches and ports, rdp_security,
* rdp_ignore_cert and guacamole_config, folding in the older security,
* ignore_cert and connection_type-only shapes, plus enable_terminal_toolbar,
* which also drove the remote desktop toolbar.
*
* The logins (users, passwords, credential ids, auth types, domain) stay in
* core, next to the host, where sharing needs them.
*
* The columns stay in the database, unused: core's drizzle migrations run
* before this does, so dropping them there would lose the data first.
*
* Idempotent: an admin key already set, or a user or host that already has
* any remote-desktop row, is skipped, so this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "remote-desktop";
const PROTOCOLS = ["rdp", "vnc", "telnet"] as const;
const DEFAULT_PORTS = { rdp: 3389, vnc: 5900, telnet: 23 };
type Flag = number | boolean | string | null | undefined;
/** true, 1 and "1" are on; null means the column was never set. */
function flag(value: Flag): boolean | undefined {
if (value === null || value === undefined) return undefined;
return value === true || value === 1 || value === "1" || value === "true";
}
function port(value: unknown): number | undefined {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535
? parsed
: undefined;
}
function jsonObject(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== "string" || !value) return undefined;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: undefined;
} catch {
return undefined;
}
}
interface LegacyHostRow {
id: number;
port: number | null;
connection_type: string | null;
enable_rdp: Flag;
enable_vnc: Flag;
enable_telnet: Flag;
rdp_port: number | null;
vnc_port: number | null;
telnet_port: number | null;
rdp_security: string | null;
rdp_ignore_cert: Flag;
security: string | null;
ignore_cert: Flag;
guacamole_config: string | null;
enable_terminal_toolbar: Flag;
}
/** The plugin's host settings for one legacy row, defaults left out. */
export function hostSettingsFromRow(
row: LegacyHostRow,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
const flags = {
rdp: flag(row.enable_rdp) === true,
vnc: flag(row.enable_vnc) === true,
telnet: flag(row.enable_telnet) === true,
};
const legacyType = PROTOCOLS.find((p) => p === row.connection_type);
// Hosts from before the per-protocol switches only had connection_type.
if (!flags.rdp && !flags.vnc && !flags.telnet && legacyType) {
flags[legacyType] = true;
}
const ports = {
rdp: port(row.rdp_port),
vnc: port(row.vnc_port),
telnet: port(row.telnet_port),
};
if (legacyType && ports[legacyType] === undefined) {
ports[legacyType] = port(row.port);
}
for (const protocol of PROTOCOLS) {
const key = `enable${protocol[0].toUpperCase()}${protocol.slice(1)}`;
if (flags[protocol]) values[key] = true;
const value = ports[protocol];
if (value !== undefined && value !== DEFAULT_PORTS[protocol]) {
values[`${protocol}Port`] = value;
}
}
const security = row.rdp_security || row.security;
if (security) values.rdpSecurity = security;
const ignoreCert = flag(row.rdp_ignore_cert) ?? flag(row.ignore_cert);
if (ignoreCert) values.rdpIgnoreCert = true;
const config = jsonObject(row.guacamole_config);
if (config && Object.keys(config).length > 0) values.guacamoleConfig = config;
if (flag(row.enable_terminal_toolbar) === false) values.enableToolbar = false;
return values;
}
/** Old camelCase defaults JSON -> the plugin's user settings. */
function userSettingsFromDefaults(
defaults: Record<string, unknown>,
): Record<string, string> {
const values: Record<string, string> = {};
const depth = Number(defaults.colorDepth);
if ([16, 24, 32].includes(depth)) values.colorDepth = String(depth);
if (
defaults.resizeMethod === "display-update" ||
defaults.resizeMethod === "reconnect"
) {
values.resizeMethod = defaults.resizeMethod;
}
for (const key of [
"forceLossless",
"enableWallpaper",
"enableFontSmoothing",
"enableDesktopComposition",
"disableAudio",
"enablePrinting",
"enableDrive",
"disableCopy",
"disablePaste",
]) {
if (typeof defaults[key] === "boolean") {
values[key] = defaults[key] ? "on" : "off";
}
}
return values;
}
export interface RemoteDesktopSettingsMigrationResult {
moved: string[];
usersMoved: number;
hostsMoved: number;
hostsSkipped: number;
}
export async function runRemoteDesktopSettingsMigration(): Promise<RemoteDesktopSettingsMigrationResult> {
const result: RemoteDesktopSettingsMigrationResult = {
moved: [],
usersMoved: 0,
hostsMoved: 0,
hostsSkipped: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
const write = (
scope: "admin" | "user" | "host",
scopeId: string | null,
key: string,
value: unknown,
) =>
pluginSettings.set(PLUGIN_ID, scope, scopeId, key, JSON.stringify(value));
try {
const settings = createCurrentSettingsRepository();
const moves: [string, string, (raw: string) => unknown][] = [
["guac_enabled", "enabled", (raw) => raw !== "false" && raw !== "0"],
["guac_url", "guacdUrl", (raw) => raw],
];
for (const [legacyKey, field, parse] of moves) {
const legacy = await settings.get(legacyKey);
if (legacy === null || legacy === "") continue;
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) continue;
await write("admin", null, field, parse(legacy));
result.moved.push(legacyKey);
}
} catch (error) {
databaseLogger.warn("Remote Desktop admin settings migration failed", {
operation: "remote_desktop_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
try {
const rows = await selectRows<{
user_id: string;
rdp_defaults: string | null;
}>(sql`SELECT user_id, rdp_defaults FROM user_preferences`);
for (const row of rows) {
const defaults = jsonObject(row.rdp_defaults);
if (!defaults) continue;
const values = userSettingsFromDefaults(defaults);
if (Object.keys(values).length === 0) continue;
const existing = await pluginSettings.getAll(
PLUGIN_ID,
"user",
row.user_id,
);
if (existing.length > 0) continue;
for (const [key, value] of Object.entries(values)) {
await write("user", row.user_id, key, value);
}
result.usersMoved++;
}
} catch {
// A fresh install never had the column.
}
try {
// Raw SQL: schema.ts no longer declares these columns.
const rows = await selectRows<LegacyHostRow>(sql`
SELECT id, port, connection_type, enable_rdp, enable_vnc, enable_telnet,
rdp_port, vnc_port, telnet_port, rdp_security, rdp_ignore_cert,
security, ignore_cert, guacamole_config, enable_terminal_toolbar
FROM ssh_data
`);
for (const row of rows) {
const values = hostSettingsFromRow(row);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
if (existing.length > 0) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await write("host", scopeId, key, value);
}
result.hostsMoved++;
}
// Those same connection_type-only hosts kept the enable_ssh default of
// on, which core used to correct on every read. FALSE and NOT read the
// same on SQLite's integers and on real booleans.
await runStatement(sql`
UPDATE ssh_data SET enable_ssh = FALSE
WHERE connection_type IN ('rdp', 'vnc', 'telnet')
AND (enable_rdp IS NULL OR NOT enable_rdp)
AND (enable_vnc IS NULL OR NOT enable_vnc)
AND (enable_telnet IS NULL OR NOT enable_telnet)
`);
} catch {
// A fresh install never had the columns.
}
if (
result.moved.length > 0 ||
result.usersMoved > 0 ||
result.hostsMoved > 0
) {
databaseLogger.info(
`Moved ${result.moved.length} Remote Desktop setting(s), ${result.usersMoved} user(s) and ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "remote_desktop_settings_migration" },
);
}
return result;
}
@@ -1,91 +0,0 @@
/**
* Moves the session logging host column into the session-recording plugin's
* host-scope settings, before the column is dropped from ssh_data.
*
* The legacy column defaulted to true; the plugin's enable switch defaults to
* false like every other plugin's. So unlike a migration whose old and new
* defaults agree, every host needs an explicit row here, not just the ones
* that turned the feature off.
*
* Idempotent: skips a host that already has a session-recording settings row,
* so it is safe to run 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 { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
export interface SessionRecordingSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacySessionLoggingRow {
id: number;
enable_session_logging: number | boolean | null;
}
export async function runSessionRecordingSettingsMigration(): Promise<SessionRecordingSettingsMigrationResult> {
const result: SessionRecordingSettingsMigrationResult = {
moved: 0,
skipped: 0,
};
try {
// Raw SQL, not the typed schema, so this keeps working once schema.ts
// stops declaring this column.
const rows = await selectLegacyRows<LegacySessionLoggingRow>(sql`
SELECT id, enable_session_logging FROM ssh_data
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"session-recording",
"host",
scopeId,
"enableSessionRecording",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"session-recording",
"host",
scopeId,
"enableSessionRecording",
JSON.stringify(legacyFlag(row.enable_session_logging, true)),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved session recording settings for ${result.moved} host(s) into plugin settings`,
{
operation: "session_recording_settings_migration",
moved: result.moved,
},
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep the
// plugin's own default (off) until this runs successfully.
databaseLogger.warn("Session recording settings migration failed", {
operation: "session_recording_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more