chore: lock the plugin boundary and remove 2.9.0 leftovers

This commit is contained in:
LukeGus committed 2026-09-25 07:24:46 -05:00
1 parent 6489b62ece
commit 2961297989
399 files changed
+3964 -5627

No files matched your search

@@ -0,0 +1,119 @@
import type { PluginLogger } from "@termix/plugin-sdk/backend";
export type ConnectionStage =
| "dns"
| "tcp"
| "handshake"
| "auth"
| "connected"
| "connection"
| "error"
| "proxy"
| "jump"
| "sftp_connecting"
| "sftp_auth"
| "sftp_connected";
export type LogEntry = {
id: string;
timestamp: Date;
type: "info" | "success" | "warning" | "error";
stage: ConnectionStage;
message: string;
details?: Record<string, unknown> | string;
};
export function createConnectionLog(
type: LogEntry["type"],
stage: ConnectionStage,
message: string,
details?: Record<string, unknown>,
): Omit<LogEntry, "id" | "timestamp"> {
return { type, stage, message, details };
}
function normalizeHostAddress(value: unknown): string {
if (typeof value !== "string") return "";
return value
.replace(/^\[|\]$/g, "")
.trim()
.toLowerCase();
}
/**
* Whether a numeric host id resolved to a different machine than the client
* meant. Ids from the desktop app and a sync server drift apart, and the
* resolved row supplies the credentials, so a mismatch must be refused.
*/
export function hostAddressMismatch(
clientAddress: unknown,
resolvedAddress: unknown,
): boolean {
const resolved = normalizeHostAddress(resolvedAddress);
if (!resolved) return false;
return resolved !== normalizeHostAddress(clientAddress);
}
export function resolveServerHostId(
clientHostId: number | null,
resolvedHost: { id?: unknown } | null,
): number | null {
return typeof resolvedHost?.id === "number" ? resolvedHost.id : clientHostId;
}
export class HostAddressMismatchError extends Error {
constructor() {
super(
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
"The host ids on this device and on the sync server have drifted apart. " +
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.',
);
this.name = "HostAddressMismatchError";
}
}
export class HostNotOnThisServerError extends Error {
constructor() {
super(
"This host does not exist on the sync server, so the connection was refused. " +
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.',
);
this.name = "HostNotOnThisServerError";
}
}
type Meta = Record<string, unknown>;
export interface FileLogger {
info: (message: string, meta?: Meta) => void;
success: (message: string, meta?: Meta) => void;
warn: (message: string, meta?: Meta) => void;
error: (message: string, error?: unknown, meta?: Meta) => void;
}
function withMeta(message: string, meta?: Meta): string {
if (!meta || Object.keys(meta).length === 0) return message;
try {
return `${message} ${JSON.stringify(meta)}`;
} catch {
return message;
}
}
/** ctx.log takes a plain message; this keeps the structured fields readable. */
export function createFileLogger(log: PluginLogger): FileLogger {
return {
info: (message, meta) => log.info(withMeta(message, meta)),
success: (message, meta) => log.info(withMeta(message, meta)),
warn: (message, meta) => log.warn(withMeta(message, meta)),
error: (message, error, meta) => {
if (error instanceof Error) {
log.error(withMeta(message, meta), error);
} else if (error && typeof error === "object" && !meta) {
log.error(withMeta(message, error as Meta));
} else {
log.error(withMeta(message, meta));
}
},
};
}
+40 -60
View File
@@ -1,9 +1,5 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import express, { type Router } from "express";
import cookieParser from "cookie-parser";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
import { getErrorMessage } from "./error-message.js";
@@ -15,30 +11,17 @@ import {
} from "./transfer-tuning.js";
const TRANSFER_PROFILES_KEY = "transfer-profiles";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../../../src/backend/utils/audit-logger.js";
import { createCurrentHostResolutionRepository } from "../../../../src/backend/database/repositories/factory.js";
import {
resolveHostById,
resolveHostBySyncId,
} from "../../../../src/backend/hosts/host-resolver.js";
import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js";
import { JumpHostChainError } from "../../../../src/backend/hosts/jump-host-chain.js";
import {
createConnectionLog,
createFileLogger,
hostAddressMismatch,
HostAddressMismatchError,
HostNotOnThisServerError,
resolveServerHostId,
} from "../../../../src/backend/hosts/host-identity.js";
import type {
LogEntry,
ConnectionStage,
} from "../../../../src/types/connection-log.js";
type ConnectionStage,
type FileLogger,
type LogEntry,
} from "./host-identity.js";
import {
ChannelOpenSerializer,
execChannel,
@@ -66,7 +49,17 @@ import {
import { createFilesService } from "./service.js";
import { tables } from "./tables.js";
const fileLogger = logger;
let fileLogger: FileLogger = createFileLogger(console);
// Shape of a resolved host as this route reads it; core fills more fields
// than PluginSshHost names.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type LooseHost = PluginSshHost & Record<string, any>;
// Core's jump chain failure, matched by name so no core class is imported.
function isJumpHostChainError(error: unknown): boolean {
return error instanceof Error && error.name === "JumpHostChainError";
}
/** Cosmetic only: whether the proxy step is worth a connection-log line. */
function hasSocks5Config(host: PluginSshHost): boolean {
@@ -115,6 +108,7 @@ function assertResolvedHost(
}
export async function activate(ctx: PluginContext) {
fileLogger = createFileLogger(ctx.log);
setPluginSsh(ctx.ssh);
setPluginCtx(ctx);
ctx.disposables.add(() => setPluginSsh(null));
@@ -276,7 +270,7 @@ export async function activate(ctx: PluginContext) {
throw new Error("Cannot open transfer connection: unknown host");
}
const host = await resolveHostById(hostId, userId);
const host = await ctx.asUser(userId, () => ctx.ssh.resolveHost(hostId));
if (!host) {
throw new Error("Host not found for transfer connection");
}
@@ -304,6 +298,7 @@ export async function activate(ctx: PluginContext) {
host as unknown as PluginSshHost,
{
purpose: "file-transfer",
profile: "stream",
timeoutMs: 60000,
overrides: { tcpKeepAliveInitialDelay: 5000 },
},
@@ -600,9 +595,9 @@ export async function activate(ctx: PluginContext) {
let resolvedSocks5ProxyChain = socks5ProxyChain;
const resolveFrom = async () => {
const resolvedHost = hostSyncId
? await resolveHostBySyncId(hostSyncId, userId)
: await resolveHostById(hostId, userId);
const resolvedHost = (await ctx.ssh.resolveHost(hostId, {
syncId: hostSyncId,
})) as LooseHost | null;
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
if (!resolvedHost) return;
resolvedIp = resolvedHost.ip;
@@ -690,7 +685,9 @@ export async function activate(ctx: PluginContext) {
let serverHostId = hostId;
if (hostSyncId && userId) {
const resolvedHost = await resolveHostBySyncId(hostSyncId, userId);
const resolvedHost = await ctx.ssh.resolveHost(hostId, {
syncId: hostSyncId,
});
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
serverHostId = resolveServerHostId(hostId, resolvedHost) ?? hostId;
}
@@ -744,6 +741,7 @@ export async function activate(ctx: PluginContext) {
const ssh = pluginSsh();
const prepared = await ssh.prepare(connectTarget, {
purpose: "file-manager",
profile: "session",
client,
serverHostId,
log: (level, message) =>
@@ -850,17 +848,13 @@ export async function activate(ctx: PluginContext) {
scheduleSessionCleanup(sessionId);
if (userId) {
const { ipAddress, userAgent } = getRequestMeta(req);
void (async () => {
await logAudit({
userId,
username: await getAuditUsername(userId),
await ctx.audit.record({
request: req,
action: "file_manager_connect",
resourceType: "host",
resourceId: serverHostId ? String(serverHostId) : undefined,
resourceName: `${username}@${ip}:${port}`,
ipAddress,
userAgent,
success: true,
});
})();
@@ -1169,17 +1163,14 @@ export async function activate(ctx: PluginContext) {
hostId,
});
const stage =
error instanceof JumpHostChainError || jumpHostCount > 0
? "jump"
: "proxy";
isJumpHostChainError(error) || jumpHostCount > 0 ? "jump" : "proxy";
connectionLogs.push(
createConnectionLog("error", stage, getErrorMessage(error)),
);
return res.status(500).json({
error:
error instanceof JumpHostChainError
? `Failed to connect through jump hosts: ${error.message}`
: getErrorMessage(error),
error: isJumpHostChainError(error)
? `Failed to connect through jump hosts: ${error.message}`
: getErrorMessage(error),
connectionLogs,
});
}
@@ -1195,24 +1186,13 @@ export async function activate(ctx: PluginContext) {
port: number,
): Promise<void> {
try {
const host = await createCurrentHostResolutionRepository().findHostById(
hostId,
userId,
);
const hostName =
host?.userId === userId && host.name
? host.name
: `${username}@${ip}:${port}`;
const authManager = AuthManager.getInstance();
await axios.post(
"http://localhost:30006/activity/log",
{ type: "file_manager", hostId, hostName },
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(userId)}`,
},
},
const host = await ctx.hosts.get(hostId);
await ctx.asUser(userId, () =>
ctx.hosts.recordActivity(
hostId,
"file_manager",
host?.name || `${username}@${ip}:${port}`,
),
);
} catch (error) {
fileLogger.warn("Failed to log file manager activity", {
@@ -15,7 +15,6 @@ import {
permanentlyDeleteTrashItem,
restoreTrashItem,
} from "./trash-service.js";
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
type FileOperationRoutesDeps = {
ctx: PluginContext;
@@ -29,10 +28,8 @@ export function registerFileOperationRoutes(
app: Express,
{ ctx, sshSessions, verifySessionOwnership }: FileOperationRoutesDeps,
): void {
// NOTE: trash retention is an install-wide admin setting, not a per-host
// access check, so it stays on PermissionManager.isAdmin rather than
// ctx.hosts.checkAccess (see rule 5 scope: host resolution/access only).
const permissionManager = PermissionManager.getInstance();
// Trash retention is an install-wide admin setting, not a per-host check.
const canManageRetention = () => ctx.rbac.has("admin.settings.manage");
const getTrashRetentionDays = async (): Promise<number> => {
try {
const value = Number(await ctx.settings.get(TRASH_RETENTION_DAYS_KEY));
@@ -68,9 +65,7 @@ export function registerFileOperationRoutes(
res.json({
items: await listTrash(await getSessionSftp(session), retentionDays),
retentionDays,
canManageRetention: await permissionManager.isAdmin(
ctx.currentActor()!,
),
canManageRetention: await canManageRetention(),
});
} catch (error) {
ctx.log.error("Failed to list trash", error as Error);
@@ -121,8 +116,7 @@ export function registerFileOperationRoutes(
});
app.put("/trash-retention", async (req, res) => {
const userId = ctx.currentActor()!;
if (!(await permissionManager.isAdmin(userId))) {
if (!(await canManageRetention())) {
return res.status(403).json({ error: "Admin access required" });
}
const retentionDays = Number(req.body?.retentionDays);
+5 -1
View File
@@ -40,7 +40,11 @@ async function withServiceSftp<T>(
): Promise<T> {
return ctx.ssh.withConnection<T, import("ssh2").Client>(
hostId,
{ pool: "file-manager-service", purpose: "file-manager" },
{
pool: "file-manager-service",
purpose: "file-manager",
profile: "session",
},
(client) =>
new Promise<T>((resolve, reject) => {
client.sftp(async (err, sftp) => {