mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
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.
692 lines
22 KiB
TypeScript
692 lines
22 KiB
TypeScript
/**
|
|
* Core's own sync entities, registered the same way a plugin registers one.
|
|
*
|
|
* Order values leave room between them (10, 30, 50...) so a plugin can slot
|
|
* an entity in without renumbering; anything referenced syncs first.
|
|
*/
|
|
|
|
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 {
|
|
hosts,
|
|
sshCredentials,
|
|
sshFolders,
|
|
userPreferences,
|
|
} from "../database/db/schema.js";
|
|
import {
|
|
CORE_OWNER,
|
|
listEntities,
|
|
registerEntity,
|
|
} from "../plugins/sync-registry.js";
|
|
import { FieldCrypto } from "../utils/field-crypto.js";
|
|
import { syncLogger } from "../utils/logger.js";
|
|
import { getPluginRuntime } from "../plugins/index.js";
|
|
import { singletonSyncId } from "./wire.js";
|
|
import { parseDefaultOverrides } from "../../types/host-defaults.js";
|
|
import {
|
|
exportHostPluginSettings,
|
|
importHostPluginSettings,
|
|
} from "./host-plugin-settings.js";
|
|
import {
|
|
exportProtocolLogins,
|
|
exportSharedProtocolLogins,
|
|
importProtocolLogins,
|
|
} from "./host-protocol-auth.js";
|
|
import {
|
|
coversHostDefault,
|
|
eraseHostDefault,
|
|
loadAdminDefaults,
|
|
loadHostDefaults,
|
|
writeAdminDefaults,
|
|
writeHostDefault,
|
|
} from "./host-defaults.js";
|
|
|
|
const CREDENTIAL_REFERENCE = {
|
|
field: "credentialId",
|
|
syncField: "credentialSyncId",
|
|
entityType: "sshCredentials",
|
|
} as const;
|
|
|
|
const HOST_REFERENCES = [
|
|
CREDENTIAL_REFERENCE,
|
|
{
|
|
field: "parentHostId",
|
|
syncField: "parentHostSyncId",
|
|
entityType: "hosts",
|
|
},
|
|
{ field: "jumpHosts[].hostId", entityType: "hosts" },
|
|
] as const;
|
|
|
|
/** Columns that only mean something on the device that holds them. */
|
|
const HOST_LOCAL_FIELDS = [
|
|
"connectionOrigin",
|
|
"localOnly",
|
|
"sharedSource",
|
|
"hostKeyLastVerified",
|
|
];
|
|
|
|
/**
|
|
* Columns 26.10.0 dropped. A 2.9 desktop still sends them; they never reach
|
|
* the table.
|
|
*/
|
|
const HOST_RETIRED_FIELDS = ["quickActions", "terminalConfig"];
|
|
|
|
let registered = false;
|
|
|
|
/** Registers core's entities. Idempotent. */
|
|
export function registerCoreSyncEntities(): void {
|
|
if (
|
|
registered &&
|
|
listEntities().some((entity) => entity.owner === CORE_OWNER)
|
|
)
|
|
return;
|
|
registered = true;
|
|
|
|
registerEntity(
|
|
CORE_OWNER,
|
|
{
|
|
type: "sshCredentials",
|
|
table: sshCredentials,
|
|
order: 10,
|
|
encryptedFields: FieldCrypto.fieldsFor("ssh_credentials"),
|
|
readOnlyFields: ["usageCount", "lastUsed", "sharedSource"],
|
|
shouldSync: (row) => !row.sharedSource,
|
|
permissions: {
|
|
create: "credentials.create",
|
|
update: "credentials.edit",
|
|
delete: "credentials.delete",
|
|
},
|
|
afterWrite: async ({ id, userId, created }) => {
|
|
if (id === null || created) return;
|
|
const { SharedHostSecretsManager } =
|
|
await import("../utils/shared-host-secrets-manager.js");
|
|
await SharedHostSecretsManager.getInstance().resyncHostsForCredential(
|
|
id,
|
|
userId,
|
|
);
|
|
const { SharedCredentialSecretsManager } =
|
|
await import("../utils/shared-credential-secrets-manager.js");
|
|
await SharedCredentialSecretsManager.getInstance().resyncCredential(
|
|
id,
|
|
userId,
|
|
);
|
|
},
|
|
remove: async (row, userId) => {
|
|
const { deleteOwnedCredential } =
|
|
await import("../hosts/delete-credential.js");
|
|
await deleteOwnedCredential(userId, row.id as number);
|
|
},
|
|
},
|
|
{
|
|
// A credential shared with the user can back their own host.
|
|
canReference: async (userId, _ownerId, id) => {
|
|
const { findUsableCredential } =
|
|
await import("../hosts/usable-credential.js");
|
|
return (await findUsableCredential(id, userId)) !== null;
|
|
},
|
|
},
|
|
);
|
|
|
|
// vaultProfiles (order 20) belongs to the vault plugin.
|
|
|
|
registerEntity(CORE_OWNER, {
|
|
type: "sshFolders",
|
|
table: sshFolders,
|
|
order: 30,
|
|
references: [CREDENTIAL_REFERENCE],
|
|
readOnlyFields: ["localOnly"],
|
|
shouldSync: (row) => !row.localOnly,
|
|
permissions: {
|
|
create: "hosts.create",
|
|
update: "hosts.edit",
|
|
delete: "hosts.delete",
|
|
},
|
|
});
|
|
|
|
registerEntity(
|
|
CORE_OWNER,
|
|
{
|
|
type: "hosts",
|
|
table: hosts,
|
|
order: 50,
|
|
encryptedFields: FieldCrypto.fieldsFor("ssh_data"),
|
|
readOnlyFields: [...HOST_LOCAL_FIELDS, ...HOST_RETIRED_FIELDS],
|
|
references: HOST_REFERENCES,
|
|
shouldSync: (row) => !row.localOnly && !row.sharedSource,
|
|
permissions: {
|
|
create: "hosts.create",
|
|
update: "hosts.edit",
|
|
delete: "hosts.delete",
|
|
},
|
|
serialize: async (row, resolveSyncId) => ({
|
|
...row,
|
|
pluginSettings:
|
|
typeof row.id === "number"
|
|
? await exportHostPluginSettings(row.id)
|
|
: {},
|
|
protocolAuth:
|
|
typeof row.id === "number"
|
|
? await exportProtocolLogins(
|
|
row.id,
|
|
String(row.userId),
|
|
resolveSyncId,
|
|
)
|
|
: {},
|
|
}),
|
|
afterWrite: async ({ id, userId, wire, resolveId }) => {
|
|
if (id === null) return;
|
|
// The server owns host defaults: it keeps what the host set itself
|
|
// and resolves the rest. A desktop takes the server's values as sent.
|
|
const { getLink } = await import("./client/link-store.js");
|
|
const onServer = !(await getLink().catch(() => null));
|
|
await importHostPluginSettings(
|
|
id,
|
|
wire.pluginSettings,
|
|
onServer ? parseDefaultOverrides(wire.defaultOverrides) : null,
|
|
);
|
|
await importProtocolLogins(id, userId, wire.protocolAuth, resolveId);
|
|
await dropInvalidParent(userId, id);
|
|
if (onServer) {
|
|
const { applyDefaultsAfterHostWrite } =
|
|
await import("../hosts/defaults/index.js");
|
|
await applyDefaultsAfterHostWrite(id);
|
|
}
|
|
const { SharedHostSecretsManager } =
|
|
await import("../utils/shared-host-secrets-manager.js");
|
|
await SharedHostSecretsManager.getInstance().resyncHost(id);
|
|
},
|
|
remove: async (row, userId) => {
|
|
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
|
|
await deleteOwnedHost(userId, row.id as number);
|
|
},
|
|
},
|
|
{
|
|
canReference: async (userId, _ownerId, id) => {
|
|
const { PermissionManager } =
|
|
await import("../utils/permission-manager.js");
|
|
const access = await PermissionManager.getInstance().canAccessHost(
|
|
userId,
|
|
id,
|
|
"view",
|
|
);
|
|
return access.hasAccess;
|
|
},
|
|
},
|
|
);
|
|
|
|
// After hosts: a folder's jump chain names hosts by syncId.
|
|
registerEntity(
|
|
CORE_OWNER,
|
|
{ type: "hostDefaults", table: null, order: 60 },
|
|
{
|
|
load: loadHostDefaults,
|
|
write: writeHostDefault,
|
|
erase: eraseHostDefault,
|
|
covers: (syncId) => coversHostDefault(syncId, activeManifest),
|
|
},
|
|
);
|
|
|
|
registerEntity(
|
|
CORE_OWNER,
|
|
{ type: "hostDefaultsAdmin", table: null, order: 7, singleton: true },
|
|
{
|
|
readOnly: true,
|
|
load: loadAdminDefaults,
|
|
write: writeAdminDefaults,
|
|
erase: async () => {},
|
|
},
|
|
);
|
|
|
|
// dashboardServiceLinks (70) and homepageItems (80) belong to homepage.
|
|
|
|
registerEntity(CORE_OWNER, {
|
|
type: "userPreferences",
|
|
table: userPreferences,
|
|
order: 90,
|
|
singleton: true,
|
|
readOnlyFields: ["storageMode"],
|
|
// A keybinding parameter a plugin declares with a syncEntity holds a
|
|
// local row id of that entity; the wire carries its syncId.
|
|
serialize: (row, resolveSyncId) =>
|
|
mapKeybindingReferences(row, async (entityType, value) => {
|
|
const id = Number(value);
|
|
return Number.isInteger(id) ? resolveSyncId(entityType, id) : null;
|
|
}),
|
|
deserialize: (row, resolveId) =>
|
|
mapKeybindingReferences(row, async (entityType, value) => {
|
|
const id = await resolveId(entityType, value);
|
|
return id === null ? null : String(id);
|
|
}),
|
|
});
|
|
|
|
registerEntity(
|
|
CORE_OWNER,
|
|
{ type: "pluginUserSettings", table: null, order: 95 },
|
|
{
|
|
load: loadPluginUserSettings,
|
|
write: writePluginUserSetting,
|
|
erase: erasePluginUserSetting,
|
|
covers: (syncId) => !!activeManifest(syncId.split(":")[0]),
|
|
},
|
|
);
|
|
|
|
registerEntity(
|
|
CORE_OWNER,
|
|
{ type: "accountProfile", table: null, order: 5, singleton: true },
|
|
{
|
|
readOnly: true,
|
|
load: loadAccountProfile,
|
|
write: writeAccountProfile,
|
|
erase: async () => {},
|
|
},
|
|
);
|
|
|
|
registerEntity(
|
|
CORE_OWNER,
|
|
{ type: "sharedCredentials", table: null, order: 15 },
|
|
{
|
|
readOnly: true,
|
|
load: loadSharedCredentials,
|
|
write: (userId, wire) => writeSharedCopy("sshCredentials", userId, wire),
|
|
erase: (userId, syncId) =>
|
|
eraseSharedCopy("sshCredentials", userId, syncId),
|
|
},
|
|
);
|
|
|
|
registerEntity(
|
|
CORE_OWNER,
|
|
{ type: "sharedHosts", table: null, order: 55 },
|
|
{
|
|
readOnly: true,
|
|
load: loadSharedHosts,
|
|
write: (userId, wire) => writeSharedCopy("hosts", userId, wire),
|
|
erase: (userId, syncId) => eraseSharedCopy("hosts", userId, syncId),
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Rewrites every keybinding parameter whose declaration names a sync entity,
|
|
* keeping the column's storage form. A value that does not map becomes null.
|
|
*/
|
|
export async function mapKeybindingReferences(
|
|
row: SyncRow,
|
|
map: (entityType: string, value: string) => Promise<string | null>,
|
|
): Promise<SyncRow> {
|
|
const stored = row.customKeybindings;
|
|
if (typeof stored !== "string" || !stored) return row;
|
|
let bindings: unknown;
|
|
try {
|
|
bindings = JSON.parse(stored);
|
|
} catch {
|
|
return row;
|
|
}
|
|
if (!Array.isArray(bindings)) return row;
|
|
const { findKeybindingAction } =
|
|
await import("../database/routes/keybinding-validation.js");
|
|
let changed = false;
|
|
for (const binding of bindings) {
|
|
const action = (binding as { action?: Record<string, unknown> } | null)
|
|
?.action;
|
|
if (!action || typeof action.type !== "string") continue;
|
|
const params = findKeybindingAction(action.type)?.params ?? {};
|
|
for (const [name, param] of Object.entries(params)) {
|
|
const value = action[name];
|
|
if (!param.syncEntity || typeof value !== "string" || !value) continue;
|
|
action[name] = await map(param.syncEntity, value);
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed
|
|
? { ...row, customKeybindings: JSON.stringify(bindings) }
|
|
: row;
|
|
}
|
|
|
|
/** A synced parent link that would make a cycle here is dropped. */
|
|
async function dropInvalidParent(userId: string, hostId: number) {
|
|
const { createCurrentRepositoryContext } =
|
|
await import("../database/repositories/factory.js");
|
|
const db = createCurrentRepositoryContext().drizzle;
|
|
const [row] = await db
|
|
.select({ parentHostId: hosts.parentHostId })
|
|
.from(hosts)
|
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
|
.limit(1);
|
|
if (typeof row?.parentHostId !== "number") return;
|
|
const { validateParentHostId } =
|
|
await import("../database/routes/host-parent-validation.js");
|
|
if (await validateParentHostId(userId, hostId, row.parentHostId)) {
|
|
await db
|
|
.update(hosts)
|
|
.set({ parentHostId: null })
|
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)));
|
|
}
|
|
}
|
|
|
|
function activeManifests(): PluginManifest[] {
|
|
try {
|
|
return getPluginRuntime()
|
|
.loader.list()
|
|
.filter((plugin) => plugin.state === "active" && plugin.manifest)
|
|
.map((plugin) => plugin.manifest as PluginManifest);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function activeManifest(pluginId: string): PluginManifest | undefined {
|
|
return activeManifests().find((manifest) => manifest.id === pluginId);
|
|
}
|
|
|
|
async function loadPluginUserSettings(userId: string): Promise<SyncRow[]> {
|
|
const { declaredFields, getSetting } = await import("../plugins/settings.js");
|
|
const { createCurrentPluginSettingsRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
const repository = createCurrentPluginSettingsRepository();
|
|
const rows: SyncRow[] = [];
|
|
for (const manifest of activeManifests()) {
|
|
const fields = declaredFields(manifest, "user");
|
|
if (fields.length === 0) continue;
|
|
const stored = new Set(
|
|
(await repository.getAll(manifest.id, "user", userId))
|
|
.filter((row) => row.value !== null)
|
|
.map((row) => row.key),
|
|
);
|
|
for (const field of fields) {
|
|
if (!stored.has(field.key)) continue;
|
|
rows.push({
|
|
syncId: `${manifest.id}:${field.key}`,
|
|
pluginId: manifest.id,
|
|
key: field.key,
|
|
value: await getSetting(manifest, "user", userId, field.key),
|
|
});
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
async function writePluginUserSetting(userId: string, wire: SyncRow) {
|
|
const manifest = activeManifest(String(wire.pluginId));
|
|
if (!manifest) return;
|
|
const { setSetting } = await import("../plugins/settings.js");
|
|
const error = await setSetting(
|
|
manifest,
|
|
"user",
|
|
userId,
|
|
String(wire.key),
|
|
wire.value,
|
|
);
|
|
if (error) throw new Error(error);
|
|
}
|
|
|
|
async function erasePluginUserSetting(userId: string, syncId: string) {
|
|
const [pluginId, ...rest] = syncId.split(":");
|
|
const { createCurrentPluginSettingsRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
await createCurrentPluginSettingsRepository().delete(
|
|
pluginId,
|
|
"user",
|
|
userId,
|
|
rest.join(":"),
|
|
);
|
|
}
|
|
|
|
async function loadAccountProfile(userId: string): Promise<SyncRow[]> {
|
|
const { createCurrentUserRepository, createCurrentRoleRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
const user = await createCurrentUserRepository().findById(userId);
|
|
if (!user) return [];
|
|
const { PermissionManager } = await import("../utils/permission-manager.js");
|
|
const roles = await createCurrentRoleRepository().listUserRoles(userId);
|
|
return [
|
|
{
|
|
syncId: singletonSyncId("accountProfile"),
|
|
remoteUserId: userId,
|
|
username: user.username,
|
|
isAdmin: !!user.isAdmin,
|
|
roles: roles.map((role) => role.roleName).sort(),
|
|
permissions: (
|
|
await PermissionManager.getInstance().getUserPermissions(userId)
|
|
).sort(),
|
|
},
|
|
];
|
|
}
|
|
|
|
async function writeAccountProfile(userId: string, wire: SyncRow) {
|
|
const { updateLink } = await import("./client/link-store.js");
|
|
await updateLink({
|
|
remoteUserId: (wire.remoteUserId as string) ?? null,
|
|
remoteUsername: (wire.username as string) ?? null,
|
|
account: {
|
|
remoteUserId: wire.remoteUserId as string,
|
|
username: wire.username as string,
|
|
isAdmin: !!wire.isAdmin,
|
|
roles: (wire.roles as string[]) ?? [],
|
|
permissions: (wire.permissions as string[]) ?? [],
|
|
},
|
|
});
|
|
if (typeof wire.username === "string" && wire.username) {
|
|
const { createCurrentUserRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
const users = createCurrentUserRepository();
|
|
const current = await users.findById(userId);
|
|
if (current && current.username !== wire.username) {
|
|
await users.update(userId, { username: wire.username });
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Host columns a shared copy never carries as the owner has them. */
|
|
const SHARED_HOST_DROP = [
|
|
"id",
|
|
"userId",
|
|
"credentialId",
|
|
"parentHostId",
|
|
"pluginSettings",
|
|
"protocolAuth",
|
|
"quickActions",
|
|
"createdAt",
|
|
"updatedAt",
|
|
"isShared",
|
|
"permissionLevel",
|
|
...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,
|
|
createCurrentRoleRepository,
|
|
createCurrentHostResolutionRepository,
|
|
createCurrentUserRepository,
|
|
} = await import("../database/repositories/factory.js");
|
|
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
|
const entries =
|
|
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
|
|
userId,
|
|
roleIds,
|
|
);
|
|
const hostRepository = createCurrentHostResolutionRepository();
|
|
const owned = await hostRepository.listOwnedHostIds(userId);
|
|
const levels = new Map<number, string>();
|
|
for (const entry of entries) {
|
|
if (owned.has(entry.hostId)) continue;
|
|
if (!levels.has(entry.hostId)) {
|
|
levels.set(entry.hostId, entry.permissionLevel);
|
|
}
|
|
}
|
|
if (levels.size === 0) return [];
|
|
|
|
const { resolveHostById } = await import("../hosts/host-resolver.js");
|
|
const users = createCurrentUserRepository();
|
|
const ownerNames = new Map<string, string>();
|
|
const rows: SyncRow[] = [];
|
|
|
|
for (const [hostId, permissionLevel] of levels) {
|
|
try {
|
|
const host = (await resolveHostById(
|
|
hostId,
|
|
userId,
|
|
)) as unknown as SyncRow | null;
|
|
if (!host || typeof host.syncId !== "string") continue;
|
|
const ownerId = String(host.userId);
|
|
if (!ownerNames.has(ownerId)) {
|
|
ownerNames.set(
|
|
ownerId,
|
|
(await users.findById(ownerId))?.username ?? "",
|
|
);
|
|
}
|
|
|
|
const wire: SyncRow = {};
|
|
for (const [field, value] of Object.entries(host)) {
|
|
if (SHARED_HOST_DROP.includes(field)) continue;
|
|
wire[field] = storedForm(field, value);
|
|
}
|
|
wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts);
|
|
wire.pluginSettings = await exportHostPluginSettings(hostId, {
|
|
userId,
|
|
permissionLevel,
|
|
});
|
|
wire.protocolAuth = await exportSharedProtocolLogins(host, userId);
|
|
wire.syncId = host.syncId;
|
|
wire.shared = {
|
|
hostId,
|
|
owner: ownerNames.get(ownerId) ?? "",
|
|
permissionLevel,
|
|
};
|
|
rows.push(wire);
|
|
} catch (error) {
|
|
syncLogger.warn("Could not include a shared host in sync", {
|
|
operation: "sync_shared_host",
|
|
hostId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
async function jumpHostsToSyncIds(value: unknown): Promise<string | null> {
|
|
const list = Array.isArray(value) ? value : [];
|
|
if (list.length === 0) return null;
|
|
const { createCurrentHostRepository } =
|
|
await import("../database/repositories/factory.js");
|
|
const repository = createCurrentHostRepository();
|
|
const mapped = [];
|
|
for (const item of list) {
|
|
const hostId = (item as { hostId?: unknown })?.hostId;
|
|
if (typeof hostId !== "number") continue;
|
|
const target = await repository.findById(hostId);
|
|
if (target?.syncId) mapped.push({ ...item, hostId: target.syncId });
|
|
}
|
|
return JSON.stringify(mapped);
|
|
}
|
|
|
|
async function loadSharedCredentials(userId: string): Promise<SyncRow[]> {
|
|
const {
|
|
createCurrentCredentialAccessRepository,
|
|
createCurrentCredentialRepository,
|
|
createCurrentRoleRepository,
|
|
createCurrentUserRepository,
|
|
} = await import("../database/repositories/factory.js");
|
|
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
|
const grants =
|
|
await createCurrentCredentialAccessRepository().listSharedWithUser(
|
|
userId,
|
|
roleIds,
|
|
);
|
|
if (grants.length === 0) return [];
|
|
const { findUsableCredential } =
|
|
await import("../hosts/usable-credential.js");
|
|
const credentials = createCurrentCredentialRepository();
|
|
const users = createCurrentUserRepository();
|
|
const rows: SyncRow[] = [];
|
|
for (const grant of grants) {
|
|
try {
|
|
const row = (await credentials.findById(
|
|
grant.credentialId,
|
|
)) as SyncRow | null;
|
|
const usable = (await findUsableCredential(
|
|
grant.credentialId,
|
|
userId,
|
|
)) as unknown as SyncRow | null;
|
|
if (!row || !usable || typeof row.syncId !== "string") continue;
|
|
rows.push({
|
|
syncId: row.syncId,
|
|
name: row.name,
|
|
description: row.description ?? null,
|
|
tags: row.tags ?? null,
|
|
authType: usable.authType,
|
|
username: usable.username ?? null,
|
|
password: usable.password ?? null,
|
|
privateKey: usable.privateKey ?? null,
|
|
keyPassword: usable.keyPassword ?? null,
|
|
keyType: usable.keyType ?? null,
|
|
publicKey: usable.publicKey ?? null,
|
|
certPublicKey: usable.certPublicKey ?? null,
|
|
shared: {
|
|
credentialId: grant.credentialId,
|
|
owner: (await users.findById(grant.ownerId))?.username ?? "",
|
|
permissionLevel: grant.permissionLevel,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
syncLogger.warn("Could not include a shared credential in sync", {
|
|
operation: "sync_shared_credential",
|
|
credentialId: grant.credentialId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/** Stores a read-only copy of something shared with the linked account. */
|
|
async function writeSharedCopy(
|
|
entityType: "hosts" | "sshCredentials",
|
|
userId: string,
|
|
wire: SyncRow,
|
|
): Promise<void> {
|
|
const { getEntity } = await import("../plugins/sync-registry.js");
|
|
const { writeWireRow, createResolvers } = await import("./store.js");
|
|
const entity = getEntity(entityType);
|
|
if (!entity) return;
|
|
const { shared, ...rest } = wire;
|
|
await writeWireRow(entity, userId, rest, createResolvers(userId), {
|
|
sharedSource: JSON.stringify(shared ?? {}),
|
|
});
|
|
}
|
|
|
|
async function eraseSharedCopy(
|
|
entityType: "hosts" | "sshCredentials",
|
|
userId: string,
|
|
syncId: string,
|
|
): Promise<void> {
|
|
const { getEntity } = await import("../plugins/sync-registry.js");
|
|
const { deleteStoredRow, findStoredRow } = await import("./store.js");
|
|
const entity = getEntity(entityType);
|
|
if (!entity) return;
|
|
const row = await findStoredRow(entity, userId, syncId);
|
|
if (!row?.sharedSource) return;
|
|
await deleteStoredRow(entity, userId, syncId);
|
|
}
|