fix(sync): send shared hosts with text columns in their stored form (#1631)

resolveHostById turns forceKeyboardInteractive into a boolean, and the
shared-host export passed it on as is. A desktop could not bind the boolean
into the text column, so every shared host failed to apply.
This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-10-14 08:59:52 +08:00
1 parent 1396b3a70c
commit c044dbde87
2 files changed
+71 -7

No files matched your search

+21 -5
View File
@@ -5,7 +5,7 @@
* an entity in without renumbering; anything referenced syncs first.
*/
import { and, eq } from "drizzle-orm";
import { and, eq, getTableColumns } from "drizzle-orm";
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import {
@@ -493,6 +493,25 @@ const SHARED_HOST_DROP = [
...HOST_LOCAL_FIELDS,
];
const HOST_TEXT_COLUMNS = new Set(
Object.entries(getTableColumns(hosts))
.filter(([, column]) => column.dataType === "string")
.map(([field]) => field),
);
/**
* A resolved host field back in the form its column stores, as an owned host
* sends it: the resolver parses JSON columns and turns the "true"/"false" of
* forceKeyboardInteractive into a boolean, which a text column cannot bind.
*/
function storedForm(field: string, value: unknown): unknown {
if (value !== null && typeof value === "object") return JSON.stringify(value);
if (typeof value === "boolean" && HOST_TEXT_COLUMNS.has(field)) {
return String(value);
}
return value;
}
async function loadSharedHosts(userId: string): Promise<SyncRow[]> {
const {
createCurrentRbacAccessRepository,
@@ -540,10 +559,7 @@ async function loadSharedHosts(userId: string): Promise<SyncRow[]> {
const wire: SyncRow = {};
for (const [field, value] of Object.entries(host)) {
if (SHARED_HOST_DROP.includes(field)) continue;
wire[field] =
value !== null && typeof value === "object"
? JSON.stringify(value)
: value;
wire[field] = storedForm(field, value);
}
wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts);
wire.pluginSettings = await exportHostPluginSettings(hostId, {
+50 -2
View File
@@ -14,6 +14,7 @@ const state = vi.hoisted(() => ({
current: null as null | { drizzle: unknown },
server: null as null | { drizzle: unknown },
allow: true,
sharedHostIds: new Set<number>(),
}));
const SERVER_USER = "srv-user";
@@ -38,13 +39,18 @@ vi.mock("../../utils/system-crypto.js", () => ({
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 3) },
DataCrypto: {
getUserDataKey: () => Buffer.alloc(32, 3),
decryptRecord: (_table: string, record: unknown) => record,
},
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async () => state.allow,
canAccessHost: async () => ({ hasAccess: false }),
canAccessHost: async (_userId: string, hostId: number) => ({
hasAccess: state.sharedHostIds.has(hostId),
}),
getUserPermissions: async () => [],
}),
},
@@ -239,6 +245,7 @@ beforeEach(async () => {
resetFeed();
resetSeqAllocator();
state.allow = true;
state.sharedHostIds.clear();
server = await side(SERVER_USER);
state.server = server.ctx;
desktops = [await side("desk-a"), await side("desk-b")];
@@ -480,6 +487,47 @@ describe("after linking", () => {
});
});
describe("hosts shared with the account", () => {
it("arrive on the desktop with their text columns as stored", async () => {
const [desk] = desktops;
await on(server, async () => {
await server.db.run(
sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES ('owner', 'owner', '', 0)`,
);
await db(server).insert(schema.hosts).values({
userId: "owner",
name: "theirs",
ip: "10.0.0.5",
port: 22,
username: "root",
authType: "none",
forceKeyboardInteractive: "true",
syncId: "host-theirs",
});
const [host] = await db(server).select().from(schema.hosts);
await db(server).insert(schema.hostAccess).values({
hostId: host.id,
userId: SERVER_USER,
grantedBy: "owner",
permissionLevel: "view",
});
state.sharedHostIds.add(host.id);
markChanged();
});
const engine = await linkDesktop(desk);
await pass(desk, engine);
const [copy] = await on(desk, () => hostsOf(desk));
expect(copy?.name).toBe("theirs");
expect(copy.forceKeyboardInteractive).toBe("true");
expect(JSON.parse(copy.sharedSource!)).toMatchObject({
owner: "owner",
permissionLevel: "view",
});
});
});
describe("two desktops on one account", () => {
it("converge on edits, creates and deletes made offline on each", async () => {
const [a, b] = desktops;