refactor(backend): final cleanup pass

- re-register WebAuthn passkey routes (registration was dropped in the
  #1054 merge, breaking passkey login) and document all six endpoints
- delete utils/simple-db-ops.ts (last caller migrated to DataCrypto)
- starter: use the typed serverReady export, collapse the four-way
  version lookup to env then package.json candidates
- add OpenAPI JSDoc to c2s-tunnel-presets endpoints
- strip block-divider comment banners
This commit is contained in:
LukeGus
2026-07-16 04:42:31 -05:00
parent 9c8528e4d2
commit f3a4d5662c
11 changed files with 216 additions and 233 deletions
@@ -46,6 +46,22 @@ function validateConfig(config: unknown): config is TunnelConnection[] {
}); });
} }
/**
* @openapi
* /c2s-tunnel-presets:
* get:
* summary: List client tunnel presets
* description: Returns the authenticated user's saved client-to-server tunnel presets.
* tags:
* - Tunnel Presets
* responses:
* 200:
* description: List of tunnel presets.
* 401:
* description: Authentication required.
* 500:
* description: Failed to list presets.
*/
router.get( router.get(
"/", "/",
authenticateJWT, authenticateJWT,
@@ -67,6 +83,37 @@ router.get(
}, },
); );
/**
* @openapi
* /c2s-tunnel-presets:
* post:
* summary: Create a client tunnel preset
* description: Saves a named client-to-server tunnel configuration for the authenticated user.
* tags:
* - Tunnel Presets
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* config:
* type: array
* items:
* type: object
* responses:
* 200:
* description: Preset created.
* 400:
* description: Invalid name or config.
* 401:
* description: Authentication required.
* 500:
* description: Failed to create preset.
*/
router.post( router.post(
"/", "/",
authenticateJWT, authenticateJWT,
@@ -111,6 +158,29 @@ router.post(
}, },
); );
/**
* @openapi
* /c2s-tunnel-presets/{id}:
* put:
* summary: Update a client tunnel preset
* description: Updates the name or config of one of the authenticated user's tunnel presets.
* tags:
* - Tunnel Presets
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Preset updated.
* 400:
* description: Invalid name or config.
* 404:
* description: Preset not found.
* 500:
* description: Failed to update preset.
*/
router.put( router.put(
"/:id", "/:id",
authenticateJWT, authenticateJWT,
@@ -171,6 +241,27 @@ router.put(
}, },
); );
/**
* @openapi
* /c2s-tunnel-presets/{id}:
* delete:
* summary: Delete a client tunnel preset
* description: Deletes one of the authenticated user's tunnel presets.
* tags:
* - Tunnel Presets
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Preset deleted.
* 404:
* description: Preset not found.
* 500:
* description: Failed to delete preset.
*/
router.delete( router.delete(
"/:id", "/:id",
authenticateJWT, authenticateJWT,
@@ -1,6 +1,6 @@
import type { NextFunction, Request, Response } from "express"; import type { NextFunction, Request, Response } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js"; import type { AuthenticatedRequest } from "../../../types/index.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js"; import { DataCrypto } from "../../utils/data-crypto.js";
export function applyHostEnrollmentDefaults( export function applyHostEnrollmentDefaults(
hostData: Record<string, unknown>, hostData: Record<string, unknown>,
@@ -34,7 +34,7 @@ export function requireHostEnrollmentAccessForPath(
return; return;
} }
if (!SimpleDBOps.isUserDataUnlocked(authReq.userId)) { if (!DataCrypto.canUserAccessData(authReq.userId)) {
res.status(423).json({ res.status(423).json({
error: "User data is locked. Sign in before enrolling hosts.", error: "User data is locked. Sign in before enrolling hosts.",
code: "DATA_LOCKED", code: "DATA_LOCKED",
-2
View File
@@ -22,9 +22,7 @@ const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware(); const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware();
// ---------------------------------------------------------------------------
// Helpers // Helpers
// ---------------------------------------------------------------------------
// Proxmox node names are restricted to [a-zA-Z0-9-] by PVE itself, // Proxmox node names are restricted to [a-zA-Z0-9-] by PVE itself,
// but we validate defensively before using in a shell command. // but we validate defensively before using in a shell command.
-2
View File
@@ -1035,9 +1035,7 @@ router.get(
}, },
); );
// ============================================================================
// SNIPPET SHARING // SNIPPET SHARING
// ============================================================================
/** /**
* @openapi * @openapi
-6
View File
@@ -88,10 +88,8 @@ async function getActorUsername(userId: string): Promise<string> {
return user?.username ?? userId; return user?.username ?? userId;
} }
// ---------------------------------------------------------------------------
// Public resolver — UNAUTHENTICATED. Serves authorized_keys (text/plain) or a // Public resolver — UNAUTHENTICATED. Serves authorized_keys (text/plain) or a
// small HTML viewer for browsers, keyed by handle, with optional /<ALGO> filter. // small HTML viewer for browsers, keyed by handle, with optional /<ALGO> filter.
// ---------------------------------------------------------------------------
// Never let an intermediary/CDN cache a public resolver feed (a disabled/removed // Never let an intermediary/CDN cache a public resolver feed (a disabled/removed
// key could keep being served after revocation), and keep it out of search // key could keep being served after revocation), and keep it out of search
@@ -276,9 +274,7 @@ router.get("/u/:handle/ca", caResolver);
router.get("/u/:handle", resolveHandle); router.get("/u/:handle", resolveHandle);
router.get("/u/:handle/:algo", resolveHandle); router.get("/u/:handle/:algo", resolveHandle);
// ---------------------------------------------------------------------------
// Authenticated management API. // Authenticated management API.
// ---------------------------------------------------------------------------
/** /**
* @openapi * @openapi
@@ -1024,9 +1020,7 @@ router.delete(
}, },
); );
// ---------------------------------------------------------------------------
// Certificate authority — central revocation (rotate) + expiry (validity). // Certificate authority — central revocation (rotate) + expiry (validity).
// ---------------------------------------------------------------------------
type CaRow = { type CaRow = {
id: number; id: number;
@@ -132,6 +132,20 @@ export function registerUserWebAuthnRoutes(
router: Router, router: Router,
{ authenticateJWT, authManager, isNativeAppRequest }: WebAuthnRoutesDeps, { authenticateJWT, authManager, isNativeAppRequest }: WebAuthnRoutesDeps,
): void { ): void {
/**
* @openapi
* /users/webauthn/credentials:
* get:
* summary: List passkeys
* description: Lists the authenticated user's registered passkeys.
* tags:
* - WebAuthn
* responses:
* 200:
* description: List of passkeys.
* 401:
* description: Authentication required.
*/
router.get("/webauthn/credentials", authenticateJWT, async (req, res) => { router.get("/webauthn/credentials", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
if (!userId) { if (!userId) {
@@ -155,6 +169,22 @@ export function registerUserWebAuthnRoutes(
}); });
}); });
/**
* @openapi
* /users/webauthn/register/options:
* post:
* summary: Start passkey registration
* description: Generates WebAuthn registration options for the authenticated user.
* tags:
* - WebAuthn
* responses:
* 200:
* description: Registration options and challenge id.
* 401:
* description: Authentication required.
* 404:
* description: User not found.
*/
router.post( router.post(
"/webauthn/register/options", "/webauthn/register/options",
authenticateJWT, authenticateJWT,
@@ -213,6 +243,22 @@ export function registerUserWebAuthnRoutes(
}, },
); );
/**
* @openapi
* /users/webauthn/register/verify:
* post:
* summary: Finish passkey registration
* description: Verifies the WebAuthn registration response and stores the passkey.
* tags:
* - WebAuthn
* responses:
* 200:
* description: Passkey registered.
* 400:
* description: Registration failed or challenge expired.
* 401:
* description: Authentication required.
*/
router.post( router.post(
"/webauthn/register/verify", "/webauthn/register/verify",
authenticateJWT, authenticateJWT,
@@ -282,6 +328,20 @@ export function registerUserWebAuthnRoutes(
}, },
); );
/**
* @openapi
* /users/webauthn/authenticate/options:
* post:
* summary: Start passkey login
* description: Generates WebAuthn authentication options, optionally scoped to a username.
* tags:
* - WebAuthn
* responses:
* 200:
* description: Authentication options and challenge id.
* 404:
* description: No passkeys found for the user.
*/
router.post("/webauthn/authenticate/options", async (req, res) => { router.post("/webauthn/authenticate/options", async (req, res) => {
const origin = getRequestOrigin(req); const origin = getRequestOrigin(req);
const rpID = getRpID(origin); const rpID = getRpID(origin);
@@ -333,6 +393,22 @@ export function registerUserWebAuthnRoutes(
res.json({ options, challengeId }); res.json({ options, challengeId });
}); });
/**
* @openapi
* /users/webauthn/authenticate/verify:
* post:
* summary: Finish passkey login
* description: Verifies the WebAuthn assertion and issues a session token (or a TOTP challenge).
* tags:
* - WebAuthn
* responses:
* 200:
* description: Login succeeded or TOTP verification required.
* 400:
* description: Challenge expired or invalid response.
* 401:
* description: Passkey not recognized or authentication failed.
*/
router.post("/webauthn/authenticate/verify", async (req, res) => { router.post("/webauthn/authenticate/verify", async (req, res) => {
const challenge = takeChallenge( const challenge = takeChallenge(
authenticationChallenges, authenticationChallenges,
@@ -468,6 +544,25 @@ export function registerUserWebAuthnRoutes(
} }
}); });
/**
* @openapi
* /users/webauthn/credentials/{credentialId}:
* delete:
* summary: Delete a passkey
* description: Removes one of the authenticated user's passkeys.
* tags:
* - WebAuthn
* parameters:
* - in: path
* name: credentialId
* required: true
* schema: { type: string }
* responses:
* 200:
* description: Passkey deleted.
* 401:
* description: Authentication required.
*/
router.delete( router.delete(
"/webauthn/credentials/:credentialId", "/webauthn/credentials/:credentialId",
authenticateJWT, authenticateJWT,
+7
View File
@@ -32,6 +32,7 @@ import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
import { registerUserSettingsRoutes } from "./user-settings-routes.js"; import { registerUserSettingsRoutes } from "./user-settings-routes.js";
import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js"; import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js";
import { registerUserTotpRoutes } from "./user-totp-routes.js"; import { registerUserTotpRoutes } from "./user-totp-routes.js";
import { registerUserWebAuthnRoutes } from "./user-webauthn-routes.js";
import { registerUserSessionRoutes } from "./user-session-routes.js"; import { registerUserSessionRoutes } from "./user-session-routes.js";
import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js"; import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js"; import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
@@ -2425,6 +2426,12 @@ registerUserTotpRoutes(router, {
isNativeAppRequest, isNativeAppRequest,
}); });
registerUserWebAuthnRoutes(router, {
authenticateJWT,
authManager,
isNativeAppRequest,
});
/** /**
* @openapi * @openapi
* /users/delete-user: * /users/delete-user:
-6
View File
@@ -54,10 +54,8 @@ interface TmuxSessionOverview extends TmuxSessionSummary {
tags: string[]; tags: string[];
} }
// ---------------------------------------------------------------------------
// SSH connection (lean variant of the per-module pattern used by server-stats // SSH connection (lean variant of the per-module pattern used by server-stats
// and docker; jump hosts and SOCKS5 reuse the shared helpers) // and docker; jump hosts and SOCKS5 reuse the shared helpers)
// ---------------------------------------------------------------------------
async function buildSshConfig(host: SSHHost): Promise<ConnectConfig> { async function buildSshConfig(host: SSHHost): Promise<ConnectConfig> {
const base: ConnectConfig = { const base: ConnectConfig = {
@@ -241,9 +239,7 @@ async function withHostConnection<T>(
return withConnection(getPoolKey(host), connectToHost(host), fn); return withConnection(getPoolKey(host), connectToHost(host), fn);
} }
// ---------------------------------------------------------------------------
// tmux queries // tmux queries
// ---------------------------------------------------------------------------
async function tmuxAvailable(conn: Client): Promise<boolean> { async function tmuxAvailable(conn: Client): Promise<boolean> {
try { try {
@@ -326,9 +322,7 @@ async function collectPaneMetrics(
); );
} }
// ---------------------------------------------------------------------------
// Express app // Express app
// ---------------------------------------------------------------------------
const app = express(); const app = express();
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
+18 -49
View File
@@ -38,58 +38,27 @@ import {
port: process.env.PORT || 4090, port: process.env.PORT || 4090,
}); });
let version = "unknown"; let version = process.env.VERSION || "unknown";
if (version === "unknown") {
const versionSources = [ const candidates = [
() => process.env.VERSION, path.join(process.cwd(), "package.json"),
() => { path.join(
path.dirname(fileURLToPath(import.meta.url)),
"../../../package.json",
),
];
for (const packageJsonPath of candidates) {
try { try {
const packageJsonPath = path.join(process.cwd(), "package.json");
const packageJson = JSON.parse( const packageJson = JSON.parse(
readFileSync(packageJsonPath, "utf-8"), readFileSync(packageJsonPath, "utf-8"),
); );
return packageJson.version; if (packageJson.version) {
version = packageJson.version;
break;
}
} catch { } catch {
return null; // try the next location
} }
},
() => {
try {
const __filename = fileURLToPath(import.meta.url);
const packageJsonPath = path.join(
path.dirname(__filename),
"../../../package.json",
);
const packageJson = JSON.parse(
readFileSync(packageJsonPath, "utf-8"),
);
return packageJson.version;
} catch {
return null;
}
},
() => {
try {
const packageJsonPath = path.join("/app", "package.json");
const packageJson = JSON.parse(
readFileSync(packageJsonPath, "utf-8"),
);
return packageJson.version;
} catch {
return null;
}
},
];
for (const getVersion of versionSources) {
try {
const foundVersion = getVersion();
if (foundVersion && foundVersion !== "unknown") {
version = foundVersion;
break;
}
} catch {
continue;
} }
} }
versionLogger.info(`Termix Backend starting - Version: ${version}`, { versionLogger.info(`Termix Backend starting - Version: ${version}`, {
@@ -152,15 +121,15 @@ import {
}, },
); );
const dbServer = await import("./database/database.js"); const { serverReady } = await import("./database/database.js");
await (dbServer as unknown as { serverReady: Promise<void> }).serverReady; await serverReady;
await import("./hosts/terminal/index.js"); await import("./hosts/terminal/index.js");
await import("./hosts/tunnel/index.js"); await import("./hosts/tunnel/index.js");
await import("./hosts/file-manager/index.js"); await import("./hosts/file-manager/index.js");
await import("./hosts/metrics/index.js"); await import("./hosts/metrics/index.js");
await import("./hosts/docker/index.js"); await import("./hosts/docker/index.js");
await import("./hosts/docker/console.js"); await import("./hosts/docker/console.js");
await import("./hosts/tmux/index.js"); // --- tmux-monitor --- await import("./hosts/tmux/index.js");
await import("./hosts/serial.js"); await import("./hosts/serial.js");
await import("./services/dashboard.js"); await import("./services/dashboard.js");
await import("./services/homepage.js"); await import("./services/homepage.js");
@@ -4,9 +4,9 @@ const mocks = vi.hoisted(() => ({
isUserDataUnlocked: vi.fn(), isUserDataUnlocked: vi.fn(),
})); }));
vi.mock("../../../utils/simple-db-ops.js", () => ({ vi.mock("../../../utils/data-crypto.js", () => ({
SimpleDBOps: { DataCrypto: {
isUserDataUnlocked: mocks.isUserDataUnlocked, canUserAccessData: mocks.isUserDataUnlocked,
}, },
})); }));
-163
View File
@@ -1,163 +0,0 @@
import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
import { DataCrypto } from "./data-crypto.js";
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
import type { SQL } from "drizzle-orm";
type TableName =
| "users"
| "ssh_data"
| "ssh_credentials"
| "termix_identity_ca"
| "recent_activity"
| "socks5_proxy_presets";
class SimpleDBOps {
static async insert<T extends Record<string, unknown>>(
table: SQLiteTable,
tableName: TableName,
data: T,
userId: string,
): Promise<T> {
const userDataKey = DataCrypto.validateUserAccess(userId);
const tempId = data.id || `temp-${userId}-${Date.now()}`;
const dataWithTempId = { ...data, id: tempId };
const encryptedData = DataCrypto.encryptRecord(
tableName,
dataWithTempId,
userId,
userDataKey,
);
if (!data.id) {
delete encryptedData.id;
}
const result = await getDb()
.insert(table)
.values(encryptedData)
.returning();
DatabaseSaveTrigger.triggerSave(`insert_${tableName}`);
const decryptedResult = DataCrypto.decryptRecord(
tableName,
result[0],
userId,
userDataKey,
);
return decryptedResult as T;
}
static async select<T extends Record<string, unknown>>(
query: unknown,
tableName: TableName,
userId: string,
): Promise<T[]> {
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
return [];
}
const results = await query;
const decryptedResults = DataCrypto.decryptRecords<T>(
tableName,
results as T[],
userId,
userDataKey,
);
return decryptedResults;
}
static async selectOne<T extends Record<string, unknown>>(
query: unknown,
tableName: TableName,
userId: string,
): Promise<T | undefined> {
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
return undefined;
}
const result = await query;
if (!result) return undefined;
const decryptedResult = DataCrypto.decryptRecord<T>(
tableName,
result as T,
userId,
userDataKey,
);
return decryptedResult;
}
static async update<T extends Record<string, unknown>>(
table: SQLiteTable,
tableName: TableName,
where: unknown,
data: Partial<T>,
userId: string,
): Promise<T[]> {
const userDataKey = DataCrypto.validateUserAccess(userId);
const encryptedData = DataCrypto.encryptRecord(
tableName,
data,
userId,
userDataKey,
);
const result = await getDb()
.update(table)
.set(encryptedData)
.where(where as SQL | undefined)
.returning();
DatabaseSaveTrigger.triggerSave(`update_${tableName}`);
const decryptedResults = DataCrypto.decryptRecords(
tableName,
result,
userId,
userDataKey,
);
return decryptedResults as T[];
}
static async delete(
table: SQLiteTable,
tableName: TableName,
where: unknown,
): Promise<unknown[]> {
const result = await getDb()
.delete(table)
.where(where as SQL | undefined)
.returning();
DatabaseSaveTrigger.triggerSave(`delete_${tableName}`);
return result;
}
static async healthCheck(userId: string): Promise<boolean> {
return DataCrypto.canUserAccessData(userId);
}
static isUserDataUnlocked(userId: string): boolean {
return DataCrypto.getUserDataKey(userId) !== null;
}
static async selectEncrypted(query: unknown): Promise<unknown[]> {
const results = await query;
return results as unknown[];
}
}
export { SimpleDBOps, type TableName };