feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

This commit is contained in:
LukeGus committed 2026-09-26 00:41:25 -05:00
1 parent 7c02daccf4
commit 6cd9a2846d
242 files changed
+39127 -8558

No files matched your search

+20 -52
View File
@@ -2,6 +2,7 @@ import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { listCredentialTypes } from "../../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js";
import { deleteOwnedCredential } from "../../hosts/delete-credential.js";
import express, { type Request, type Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
@@ -22,8 +23,9 @@ import {
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
import { parseSharedSource } from "./host-normalizers.js";
/** Built-in password and key, plus any type a plugin offers for credentials. */
function getCredentialTypes(): string[] {
@@ -32,6 +34,7 @@ function getCredentialTypes(): string[] {
}
const router = express.Router();
router.use(rejectSharedCopyWrites("credential", /^\/(\d+)$/));
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
@@ -816,59 +819,11 @@ router.delete(
try {
const credentialId = parseInt(id);
const credentialToDelete =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
credentialId,
);
if (!credentialToDelete) {
const deleted = await deleteOwnedCredential(userId, credentialId);
if (!deleted) {
return res.status(404).json({ error: "Credential not found" });
}
const hostsUsingCredential =
await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser(
userId,
credentialId,
);
if (hostsUsingCredential.length > 0) {
await createCurrentHostRepository().updateManyForUser(
userId,
hostsUsingCredential.map((host) => host.id),
{
credentialId: null,
password: null,
key: null,
keyPassword: null,
authType: "password",
},
);
}
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const sharedSecretsManager = SharedHostSecretsManager.getInstance();
await sharedSecretsManager.deleteForCredential(credentialId);
await createCurrentCredentialRepository().deleteForUser(
userId,
credentialId,
);
if (credentialToDelete.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"sshCredentials",
credentialToDelete.syncId,
);
}
// Shares stay in place; re-snapshot so recipients fall back to whatever
// auth the host still has (or lose the stale credential copy).
for (const host of hostsUsingCredential) {
await sharedSecretsManager.resyncHost(host.id);
}
authLogger.success("SSH credential deleted", {
operation: "credential_delete_success",
userId,
@@ -882,7 +837,7 @@ router.delete(
action: "delete_credential",
resourceType: "credential",
resourceId: id,
resourceName: String(credentialToDelete.name ?? id),
resourceName: String(deleted.name ?? id),
ipAddress: cdIp,
userAgent: cdUa,
success: true,
@@ -1138,6 +1093,19 @@ function formatCredentialOutput(
lastUsed: credential.lastUsed,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
...sharedCopyFields(credential.sharedSource),
};
}
/** A read-only copy of a credential shared with a linked desktop's account. */
function sharedCopyFields(value: unknown): Record<string, unknown> {
const shared = parseSharedSource(value);
if (!shared) return {};
return {
isShared: true,
sharedCopy: true,
ownerUsername: shared.owner || null,
permissionLevel: shared.permissionLevel === "manage" ? "manage" : "use",
};
}
@@ -21,6 +21,17 @@ export function isLoopbackRequest(req: Request): boolean {
);
}
/**
* Whether this backend may hand out the desktop auto-session at all: the
* embedded desktop backend, or a development backend the desktop app points
* at. A production server never does, whoever asks.
*/
export function allowsDesktopAutoSession(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return env.ELECTRON_EMBEDDED === "true" || env.NODE_ENV !== "production";
}
export function extractBearerOrCookieToken(req: Request): string | undefined {
const cookieToken = (req as Request & { cookies?: Record<string, string> })
.cookies?.jwt;
@@ -9,7 +9,6 @@ import {
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
@@ -178,6 +177,9 @@ export function registerHostFolderRoutes(
* credentialId:
* type: integer
* nullable: true
* localOnly:
* type: boolean
* description: Desktop only. Keeps the folder and its hosts on this device instead of syncing them.
* responses:
* 200:
* description: Folder metadata updated successfully.
@@ -193,7 +195,7 @@ export function registerHostFolderRoutes(
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, color, icon, credentialId } = req.body;
const { name, color, icon, credentialId, localOnly } = req.body;
if (!isNonEmptyString(userId) || !name) {
return res.status(400).json({ error: "Folder name is required" });
@@ -234,6 +236,13 @@ export function registerHostFolderRoutes(
icon,
normalizedCredentialId,
);
if (typeof localOnly === "boolean") {
await createCurrentHostFolderRepository().setLocalOnly(
userId,
name,
localOnly,
);
}
if (!created) {
databaseLogger.info("Updating SSH folder", {
@@ -411,17 +420,9 @@ export function registerHostFolderRoutes(
);
}
const { hostSyncIds, folderSyncIds } =
await hostFolderRepository.deleteHostsAndFolderRecords(
userId,
folderName,
);
const tombstoneRepository = createCurrentSyncTombstoneRepository();
await tombstoneRepository.recordMany(userId, "hosts", hostSyncIds);
await tombstoneRepository.recordMany(
await hostFolderRepository.deleteHostsAndFolderRecords(
userId,
"sshFolders",
folderSyncIds,
folderName,
);
try {
@@ -383,11 +383,35 @@ export function sanitizeHostForRecipient(
return reduced;
}
/** Who shared a host that a linked desktop holds a read-only copy of. */
export function parseSharedSource(
value: unknown,
): { owner?: string; permissionLevel?: string } | null {
if (typeof value !== "string" || !value) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
export function transformHostResponse(
host: Record<string, unknown>,
): Record<string, unknown> {
const shared = parseSharedSource(host.sharedSource);
return {
...host,
sharedSource: undefined,
localOnly: !!host.localOnly,
sharedCopy: !!shared,
...(shared
? {
isShared: true,
ownerUsername: shared.owner || undefined,
permissionLevel: shared.permissionLevel || "connect",
}
: {}),
tags:
typeof host.tags === "string"
? host.tags
+12 -4
View File
@@ -67,8 +67,10 @@ import {
requiresPersonalHostAuthentication,
resolveRecipientSharedHostAuthentication,
} from "../../utils/shared-host-auth-resolver.js";
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
const router = express.Router();
router.use(rejectSharedCopyWrites("host", /^\/db\/host\/(\d+)$/));
const upload = multer({ storage: multer.memoryStorage() });
@@ -199,6 +201,7 @@ router.post(
socks5Password,
socks5ProxyChain,
connectionOrigin,
localOnly,
portKnockSequence,
overrideCredentialUsername,
enableSsh,
@@ -311,6 +314,7 @@ router.post(
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null,
...(typeof localOnly === "boolean" ? { localOnly } : {}),
portKnockSequence: portKnockSequence
? JSON.stringify(portKnockSequence)
: null,
@@ -795,6 +799,7 @@ router.put(
socks5Password,
socks5ProxyChain,
connectionOrigin,
localOnly,
portKnockSequence,
overrideCredentialUsername,
enableSsh,
@@ -908,6 +913,7 @@ router.put(
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null,
...(typeof localOnly === "boolean" ? { localOnly } : {}),
portKnockSequence: portKnockSequence
? JSON.stringify(portKnockSequence)
: null,
@@ -1454,14 +1460,16 @@ router.get(
const result = await Promise.all(
data.map(async (row: Record<string, unknown>) => {
const transformed = transformHostResponse(row);
const baseHost = {
...transformHostResponse(row),
isShared: !!row.isShared,
permissionLevel: row.permissionLevel || undefined,
...transformed,
isShared: !!row.isShared || !!transformed.sharedCopy,
permissionLevel:
row.permissionLevel || transformed.permissionLevel || undefined,
sharedExpiresAt: row.expiresAt || undefined,
ownerUsername: row.isShared
? ownerUsernames.get(row.userId as string) || undefined
: undefined,
: transformed.ownerUsername,
};
const resolved =
+19
View File
@@ -306,6 +306,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
}
});
async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
if (process.env.ELECTRON_EMBEDDED !== "true") return false;
const { getLink } = await import("../../sync/client/link-store.js");
if (!(await getLink())) return false;
const { isServerManaged } = await import("../../sync/client/plugins.js");
return isServerManaged(pluginId);
}
/**
* @openapi
* /plugins/{id}/state:
@@ -338,6 +346,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin.
* 409:
* description: This desktop is linked to a server, which decides whether the plugin runs.
*/
router.patch(
"/:id/state",
@@ -360,6 +370,15 @@ router.patch(
return;
}
// A linked desktop runs what its server runs.
if (await isManagedByLinkedServer(pluginId)) {
res.status(409).json({
error: "Managed by the linked server",
code: "MANAGED_BY_SERVER",
});
return;
}
// Persist first: if the start or stop below throws, the recorded
// intent still matches what the user asked for, and the next boot
// acts on it rather than silently reverting.
@@ -1,112 +0,0 @@
/**
* Core's own sync entities, registered the same way a plugin registers one.
*
* These were a hardcoded ENTITY_CONFIG in sync.ts, a SyncEntityType union in
* the tombstone repository, a REFERENCES map in sync-references.ts and a
* frozen array in electron/. Four lists that had to agree and had no test
* saying they did. Now there is one registration per entity and the rest is
* derived.
*
* The order values reproduce the dependency order the Electron array encoded:
* anything referenced by another entity syncs first.
*/
import {
hosts,
sshCredentials,
sshFolders,
userPreferences,
} from "../db/schema.js";
import {
CORE_OWNER,
registerEntity,
listEntities,
} from "../../plugins/sync-registry.js";
const CREDENTIAL_REFERENCE = {
field: "credentialId",
syncField: "credentialSyncId",
entityType: "sshCredentials",
} as const;
const HOST_REFERENCES = [
CREDENTIAL_REFERENCE,
{
field: "rdpCredentialId",
syncField: "rdpCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vncCredentialId",
syncField: "vncCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "telnetCredentialId",
syncField: "telnetCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "parentHostId",
syncField: "parentHostSyncId",
entityType: "hosts",
},
] as const;
let registered = false;
/**
* Registers core's entities. Idempotent, because the sync routes and the
* Electron entity endpoint both need the registry primed and neither can
* assume it ran first.
*/
export function registerCoreSyncEntities(): void {
if (registered && listEntities().length > 0) return;
registered = true;
// 10, 20, 30... so a plugin can slot an entity between two core ones
// without every order value having to be renumbered.
registerEntity(CORE_OWNER, {
type: "sshCredentials",
table: sshCredentials,
order: 10,
});
// vaultProfiles (order 20) moved to the vault plugin. Hosts no longer
// reference it: a host's profile is the plugin's profileId host setting.
registerEntity(CORE_OWNER, {
type: "sshFolders",
table: sshFolders,
order: 30,
references: [CREDENTIAL_REFERENCE],
});
registerEntity(CORE_OWNER, {
type: "hosts",
table: hosts,
order: 50,
// connectionOrigin says how this device reached the host, which is never
// true of the other side.
readOnlyFields: ["connectionOrigin"],
references: HOST_REFERENCES,
encryptedFields: ["ssh_data"],
});
// dashboardServiceLinks (order 70) and homepageItems (order 80) moved to
// the homepage plugin, which registers them itself at activation.
registerEntity(CORE_OWNER, {
type: "userPreferences",
table: userPreferences,
order: 90,
singleton: true,
readOnlyFields: ["storageMode"],
});
}
/** The tables a reference may point at, derived from the registrations. */
export const ENCRYPTED_ENTITY_TABLES: Readonly<Record<string, string>> = {
hosts: "ssh_data",
sshCredentials: "ssh_credentials",
};
@@ -1,84 +0,0 @@
/**
* Host plugin settings over remote sync. A host row travels with a
* pluginSettings map of its non-secret values; a plugin whose value refers to
* one of its own rows by local id translates it with a registered
* ctx.registry.provide("<id>.hostSettingsSync", { exportValue, importValue }).
*/
import { consume } from "../../plugins/registry.js";
import {
declaredFields,
getAllSettings,
setSetting,
} from "../../plugins/settings.js";
import { databaseLogger } from "../../utils/logger.js";
import {
hostSettingsPlugins,
type HostPluginSettings,
} from "./host-plugin-settings.js";
export interface PluginHostSettingsSync {
exportValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
importValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
}
/** What a host carries to the other side of a sync pair. */
export async function exportHostPluginSettings(
hostId: number,
): Promise<HostPluginSettings> {
const result: HostPluginSettings = {};
for (const manifest of hostSettingsPlugins()) {
const fields = declaredFields(manifest, "host").filter(
(field) => field.type !== "secret",
);
if (fields.length === 0) continue;
const values = await getAllSettings(manifest, "host", hostId, {
redactSecrets: true,
});
const hook = consume<PluginHostSettingsSync>(
`${manifest.id}.hostSettingsSync`,
);
const own: Record<string, unknown> = {};
for (const field of fields) {
const value = values[field.key];
own[field.key] = hook?.exportValue
? await hook.exportValue(field.key, value)
: value;
}
result[manifest.id] = own;
}
return result;
}
/** Writes what a synced host carried, translated back to local ids. */
export async function importHostPluginSettings(
hostId: number,
carried: unknown,
): Promise<void> {
if (!carried || typeof carried !== "object") return;
const byPlugin = carried as HostPluginSettings;
for (const manifest of hostSettingsPlugins()) {
const own = byPlugin[manifest.id];
if (!own || typeof own !== "object") continue;
const hook = consume<PluginHostSettingsSync>(
`${manifest.id}.hostSettingsSync`,
);
for (const field of declaredFields(manifest, "host")) {
if (field.type === "secret" || !(field.key in own)) continue;
try {
const value = hook?.importValue
? await hook.importValue(field.key, own[field.key])
: own[field.key];
await setSetting(manifest, "host", hostId, field.key, value);
} catch (error) {
databaseLogger.warn("Could not apply a synced host plugin setting", {
operation: "sync_host_plugin_settings",
pluginId: manifest.id,
hostId,
key: field.key,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
}
@@ -1,123 +0,0 @@
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
import { getEntity } from "../../plugins/sync-registry.js";
/**
* An entity another entity's rows may point at.
*
* A plain string rather than a union: the set is whatever is registered, and a
* plugin can both register an entity and reference one. sync.ts resolves the
* name through the registry, so an unknown one is an error there rather than a
* silent fallthrough.
*/
export type SyncReferenceEntity = string;
interface SyncReference {
field: string;
syncField: string;
entityType: SyncReferenceEntity;
}
export function orderSyncRows(
entityType: SyncEntityType,
rows: Record<string, unknown>[],
): Record<string, unknown>[] {
// Only an entity that references itself needs its rows ordered among
// themselves. Today that is hosts, through parentHostId.
const selfReference = referencesFor(entityType).find(
(reference) => reference.entityType === entityType,
);
if (!selfReference) return rows;
const bySyncId = new Map(
rows
.filter((row) => typeof row.syncId === "string")
.map((row) => [row.syncId as string, row]),
);
const ordered: Record<string, unknown>[] = [];
const visited = new Set<Record<string, unknown>>();
const visiting = new Set<Record<string, unknown>>();
const visit = (row: Record<string, unknown>) => {
if (visited.has(row)) return;
if (visiting.has(row)) return;
visiting.add(row);
const parentSyncId = row[selfReference.syncField];
if (typeof parentSyncId === "string") {
const parent = bySyncId.get(parentSyncId);
if (parent) visit(parent);
}
visiting.delete(row);
visited.add(row);
ordered.push(row);
};
rows.forEach(visit);
return ordered;
}
function referencesFor(entityType: SyncEntityType): readonly SyncReference[] {
return (getEntity(entityType)?.references ?? []) as readonly SyncReference[];
}
export async function serializeSyncReferences(
entityType: SyncEntityType,
row: Record<string, unknown>,
resolveSyncId: (
entityType: SyncReferenceEntity,
id: number,
) => Promise<string | null>,
): Promise<Record<string, unknown>> {
const serialized = { ...row };
const custom = getEntity(entityType)?.serialize;
if (custom) return custom(serialized, resolveSyncId);
for (const reference of referencesFor(entityType)) {
const id = serialized[reference.field];
serialized[reference.syncField] =
typeof id === "number"
? await resolveSyncId(reference.entityType, id)
: null;
delete serialized[reference.field];
}
return serialized;
}
export async function deserializeSyncReferences(
entityType: SyncEntityType,
row: Record<string, unknown>,
resolveId: (
entityType: SyncReferenceEntity,
syncId: string,
) => Promise<number | null>,
): Promise<Record<string, unknown>> {
const deserialized = { ...row };
const custom = getEntity(entityType)?.deserialize;
if (custom) return custom(deserialized, resolveId);
for (const reference of referencesFor(entityType)) {
const syncId = deserialized[reference.syncField];
delete deserialized[reference.syncField];
delete deserialized[reference.field];
if (syncId == null) {
deserialized[reference.field] = null;
continue;
}
if (typeof syncId !== "string") {
throw new Error(`Invalid ${reference.syncField}`);
}
const id = await resolveId(reference.entityType, syncId);
if (id === null) {
throw new Error(
`Missing ${reference.entityType} dependency ${reference.syncField}=${syncId}`,
);
}
deserialized[reference.field] = id;
}
return deserialized;
}
-588
View File
@@ -1,588 +0,0 @@
import express, { type Request, type Response } from "express";
import { and, eq, type SQL } from "drizzle-orm";
import { hosts } from "../db/schema.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { databaseLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import {
createCurrentRepositoryContext,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
import {
getEntity,
hasEntity,
listEntities,
} from "../../plugins/sync-registry.js";
import {
ENCRYPTED_ENTITY_TABLES,
registerCoreSyncEntities,
} from "./sync-entities.js";
import {
deserializeSyncReferences,
orderSyncRows,
serializeSyncReferences,
type SyncReferenceEntity,
} from "./sync-references.js";
import { timestampAtOrAfter } from "../sync-timestamp.js";
import {
insertReturningWhere,
updateReturning,
} from "../repositories/returning.js";
import { validateParentHostId } from "./host-parent-validation.js";
import {
exportHostPluginSettings,
importHostPluginSettings,
} from "./sync-host-plugin-settings.js";
// Primed at import so every consumer of this module - the routes below, the
// reference resolvers, the Electron entity-types endpoint - sees the same
// registry regardless of which one runs first.
registerCoreSyncEntities();
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* What a sync entity looks like once it is registered.
*
* The table was a hand-maintained union of ten concrete drizzle types, which a
* plugin-provided table could never join. It is the same deliberate
* approximation repositories/database-context.ts documents: the query-builder
* surface used here is identical across the engines and across tables, and
* every use already casts to reach .syncId and .updatedAt.
*/
type SyncTable = typeof hosts;
function entityConfig(entityType: SyncEntityType) {
const entity = getEntity(entityType);
if (!entity) {
throw new Error(`Unknown sync entity "${entityType}"`);
}
return {
table: entity.table as SyncTable,
readOnlyFields: entity.readOnlyFields,
singleton: entity.singleton === true,
shouldSync: (row: Record<string, unknown>) => isSyncedRow(entityType, row),
};
}
/**
* Whether a row takes part in sync. An entity can leave some of its rows out
* (a per-install "last session"): they are not pulled, and a push, update or
* delete aimed at one is refused.
*/
export function isSyncedRow(
entityType: string,
row: Record<string, unknown>,
): boolean {
const entity = getEntity(entityType);
if (!entity?.shouldSync) return true;
return entity.shouldSync(row) !== false;
}
type RepositoryContext = ReturnType<typeof createCurrentRepositoryContext>;
export function isValidEntityType(value: unknown): value is SyncEntityType {
if (typeof value !== "string") return false;
registerCoreSyncEntities();
return hasEntity(value);
}
/**
* Locates the stored row a sync payload corresponds to.
*
* Read and write have to agree on this. A singleton entity is keyed on its
* owner rather than a sync id, and `user_preferences` — the only singleton —
* has no `id` column at all, so an update cannot fall back to one: `table.id`
* is undefined there and drizzle emits `WHERE = ?`.
*/
export function locateSyncRow(
entityType: SyncEntityType,
userId: string,
syncId: string,
): SQL {
const { table, singleton } = entityConfig(entityType);
if (singleton) {
return eq(table.userId, userId);
}
return and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
)!;
}
/**
* The syncId of a referenced row.
*
* Generic over the registry rather than an if/else over three known entities.
* The old chain ended in an unguarded else that returned vaultProfiles, so a
* fourth reference target would have silently resolved against the wrong
* table; an unregistered entity is an error here instead.
*/
async function findReferenceSyncId(
context: RepositoryContext,
entityType: SyncReferenceEntity,
id: number,
userId: string,
): Promise<string | null> {
const { table } = entityConfig(entityType as SyncEntityType);
const [row] = await context.drizzle
.select({ syncId: table.syncId })
.from(table)
.where(and(eq(table.id, id), eq(table.userId, userId)))
.limit(1);
return row?.syncId ?? null;
}
async function findReferenceId(
context: RepositoryContext,
entityType: SyncReferenceEntity,
syncId: string,
userId: string,
): Promise<number | null> {
const { table } = entityConfig(entityType as SyncEntityType);
const [row] = await context.drizzle
.select({ id: table.id })
.from(table)
.where(and(eq(table.syncId, syncId), eq(table.userId, userId)))
.limit(1);
return row?.id ?? null;
}
function requireUserDataKey(userId: string): Buffer {
return DataCrypto.validateUserAccess(userId);
}
function decryptIfNeeded(
entityType: SyncEntityType,
row: Record<string, unknown>,
userId: string,
): Record<string, unknown> {
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
if (!tableName) return row;
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) return row;
return DataCrypto.decryptRecord(
tableName,
row,
userId,
userDataKey,
) as Record<string, unknown>;
}
function encryptIfNeeded(
entityType: SyncEntityType,
row: Record<string, unknown>,
userId: string,
): Record<string, unknown> {
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
if (!tableName) return row;
const userDataKey = requireUserDataKey(userId);
return DataCrypto.encryptRecord(
tableName,
row,
userId,
userDataKey,
) as Record<string, unknown>;
}
export function stripWritePayload(
entityType: SyncEntityType,
payload: Record<string, unknown>,
): Record<string, unknown> {
const { readOnlyFields } = entityConfig(entityType);
const clean = { ...payload };
delete clean.id;
delete clean.userId;
delete clean.syncId;
for (const field of readOnlyFields) delete clean[field];
return clean;
}
/**
* @openapi
* /sync/entity-types:
* get:
* summary: List the entity types this server syncs, in dependency order
* description: The desktop app syncs the entities in the order returned here, because an entity referenced by another has to exist on the far side first. A server that predates this endpoint returns 404 and the client falls back to its built-in list.
* tags:
* - Sync
* responses:
* 200:
* description: The ordered entity types.
* 500:
* description: Failed to list entity types.
*/
router.get(
"/entity-types",
authenticateJWT,
async (_req: Request, res: Response) => {
try {
registerCoreSyncEntities();
return res.json({
entityTypes: listEntities().map((entity) => ({
type: entity.type,
order: entity.order,
singleton: entity.singleton === true,
})),
});
} catch (error) {
databaseLogger.error("Failed to list sync entity types", error, {
operation: "sync_entity_types",
});
return res.status(500).json({ error: "Failed to list entity types" });
}
},
);
/**
* @openapi
* /sync/{entityType}:
* get:
* summary: Pull synced rows for an entity type
* description: Returns rows owned by the authenticated user whose updatedAt is newer than `since` (or all rows if omitted). Used by the desktop app's remote sync engine to reconcile the embedded backend against a connected remote server.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* - in: query
* name: since
* schema:
* type: string
* responses:
* 200:
* description: Rows updated since the given timestamp.
* 400:
* description: Unknown entity type.
* 500:
* description: Failed to fetch rows.
*/
router.get(
"/:entityType",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const since =
typeof req.query.since === "string" && req.query.since
? req.query.since
: null;
try {
const { table, singleton, shouldSync } = entityConfig(entityType);
const context = createCurrentRepositoryContext();
const conditions = [eq(table.userId, userId)];
if (since && "updatedAt" in table) {
conditions.push(
timestampAtOrAfter((table as typeof hosts).updatedAt, since),
);
}
const rows = await context.drizzle
.select()
.from(table as typeof hosts)
.where(and(...conditions));
const synced = (rows as Record<string, unknown>[]).filter(shouldSync);
const decrypted = await Promise.all(
synced.map(async (row) => {
const result = await serializeSyncReferences(
entityType,
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
(referenceType, id) =>
findReferenceSyncId(context, referenceType, id, userId),
);
if (entityType === "hosts") {
result.pluginSettings = await exportHostPluginSettings(
row.id as number,
);
}
return singleton
? { ...result, syncId: `${entityType}:singleton` }
: result;
}),
);
res.json({ rows: orderSyncRows(entityType, decrypted) });
} catch (err) {
databaseLogger.error(`Failed to pull sync rows for ${entityType}`, err, {
operation: "sync_pull",
entityType,
userId,
});
res.status(500).json({ error: "Failed to fetch rows" });
}
},
);
/**
* @openapi
* /sync/tombstones:
* post:
* summary: Report a deletion from the other side of a sync pair
* description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent.
* tags:
* - Sync
* responses:
* 200:
* description: Deletion applied (or row already absent).
* 400:
* description: Unknown entity type or missing syncId.
* 500:
* description: Failed to apply deletion.
*/
router.post(
"/tombstones",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.body?.entityType;
const syncId = req.body?.syncId;
if (
!isValidEntityType(entityType) ||
typeof syncId !== "string" ||
!syncId
) {
return res.status(400).json({ error: "Missing entityType or syncId" });
}
try {
const { table, shouldSync } = entityConfig(entityType);
const context = createCurrentRepositoryContext();
const locateRow = locateSyncRow(entityType, userId, syncId);
const [target] = (await context.drizzle
.select()
.from(table as typeof hosts)
.where(locateRow)
.limit(1)) as Record<string, unknown>[];
if (target && !shouldSync(target)) {
return res.status(400).json({ error: "This row is not synced" });
}
await context.drizzle.delete(table as typeof hosts).where(locateRow);
await createCurrentSyncTombstoneRepository().record(
userId,
entityType,
syncId,
);
await DatabaseSaveTrigger.forceSave("sync_tombstone_applied");
res.json({ success: true });
} catch (err) {
databaseLogger.error("Failed to apply sync tombstone", err, {
operation: "sync_tombstone_apply",
entityType,
userId,
});
res.status(500).json({ error: "Failed to apply deletion" });
}
},
);
/**
* @openapi
* /sync/{entityType}:
* post:
* summary: Upsert a synced row by syncId
* description: Creates or updates a row by its syncId. Used by the desktop app's remote sync engine to push local-only or newer rows to the other side of a sync pair.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Row upserted.
* 400:
* description: Unknown entity type or missing syncId.
* 500:
* description: Failed to upsert row.
*/
router.post(
"/:entityType",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const payload = req.body?.row;
const syncId = payload?.syncId;
if (!payload || typeof syncId !== "string" || !syncId) {
return res.status(400).json({ error: "Missing row.syncId" });
}
try {
// singleton is still needed below: those tables have no sync_id column
// for the insert to populate.
const { table, singleton, shouldSync } = entityConfig(entityType);
const context = createCurrentRepositoryContext();
const locateRow = locateSyncRow(entityType, userId, syncId);
if (!shouldSync(payload)) {
return res.status(400).json({ error: "This row is not synced" });
}
const existingRows = await context.drizzle
.select()
.from(table as typeof hosts)
.where(locateRow)
.limit(1);
const existing = existingRows[0] as Record<string, unknown> | undefined;
if (existing && !shouldSync(existing)) {
return res.status(400).json({ error: "This row is not synced" });
}
const resolvedPayload = await deserializeSyncReferences(
entityType,
payload,
(referenceType, referenceSyncId) =>
findReferenceId(context, referenceType, referenceSyncId, userId),
);
if (
entityType === "hosts" &&
typeof resolvedPayload.parentHostId === "number"
) {
const parentError = await validateParentHostId(
userId,
typeof existing?.id === "number" ? existing.id : null,
resolvedPayload.parentHostId,
);
if (parentError) return res.status(400).json({ error: parentError });
}
const carriedPluginSettings = resolvedPayload.pluginSettings;
delete resolvedPayload.pluginSettings;
const writePayload = stripWritePayload(entityType, resolvedPayload);
const encryptedPayload = encryptIfNeeded(
entityType,
writePayload,
userId,
);
// Through the returning helpers, because MySQL has no RETURNING and a
// bare .returning() throws there.
let resultRow: Record<string, unknown>;
if (existing) {
const updatedRows = await updateReturning(
context,
table as typeof hosts,
encryptedPayload,
locateRow,
);
resultRow = updatedRows[0] as Record<string, unknown>;
} else {
const insertedRows = await insertReturningWhere(
context,
table as typeof hosts,
(singleton
? { ...encryptedPayload, userId }
: {
...encryptedPayload,
userId,
syncId,
}) as typeof hosts.$inferInsert,
locateRow,
);
resultRow = insertedRows[0] as Record<string, unknown>;
}
if (entityType === "hosts" && typeof resultRow?.id === "number") {
await importHostPluginSettings(resultRow.id, carriedPluginSettings);
}
await DatabaseSaveTrigger.forceSave("sync_upsert");
res.json({
row: decryptIfNeeded(entityType, resultRow, userId),
created: !existing,
});
} catch (err) {
databaseLogger.error(`Failed to upsert sync row for ${entityType}`, err, {
operation: "sync_upsert",
entityType,
userId,
});
res.status(500).json({ error: "Failed to upsert row" });
}
},
);
/**
* @openapi
* /sync/{entityType}/tombstones:
* get:
* summary: Pull deletion tombstones for an entity type
* description: Returns tombstones recorded since `since` so the other side of a sync pair can apply the same deletions.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* - in: query
* name: since
* schema:
* type: string
* responses:
* 200:
* description: Tombstones recorded since the given timestamp.
* 400:
* description: Unknown entity type.
* 500:
* description: Failed to fetch tombstones.
*/
router.get(
"/:entityType/tombstones",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const since =
typeof req.query.since === "string" && req.query.since
? req.query.since
: null;
try {
const tombstones = await createCurrentSyncTombstoneRepository().listSince(
userId,
entityType,
since,
);
res.json({ tombstones });
} catch (err) {
databaseLogger.error(
`Failed to fetch sync tombstones for ${entityType}`,
err,
{ operation: "sync_tombstones_pull", entityType, userId },
);
res.status(500).json({ error: "Failed to fetch tombstones" });
}
},
);
export default router;
+27 -1
View File
@@ -8,6 +8,7 @@ import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { parseUserAgent } from "../../utils/user-agent-parser.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
import {
allowsDesktopAutoSession,
isLoopbackRequest,
extractBearerOrCookieToken,
isNativeTokenExportRequest,
@@ -545,6 +546,30 @@ router.post("/logout", authenticateJWT, async (req, res) => {
}
});
/**
* On a desktop linked to a server, the account it is signed in to there.
* That account is who the user is; the local profile only mirrors it.
*/
async function describeDesktopLink(userId: string) {
if (process.env.ELECTRON_EMBEDDED !== "true") return null;
try {
const { getLink } = await import("../../sync/client/link-store.js");
const link = await getLink();
if (!link || link.userId !== userId) return null;
return {
serverUrl: link.serverUrl,
serverName: link.serverName,
username: link.account?.username ?? link.remoteUsername,
isAdmin: !!link.account?.isAdmin,
roles: link.account?.roles ?? [],
permissions: link.account?.permissions ?? [],
status: link.status,
};
} catch {
return null;
}
}
/**
* @openapi
* /users/me:
@@ -595,6 +620,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
user.id,
),
show_donation_modal: showDonationModal,
linked: await describeDesktopLink(user.id),
});
} catch (err) {
authLogger.error("Failed to get username", err);
@@ -728,7 +754,7 @@ router.get("/setup-required", async (req, res) => {
*/
router.post("/internal/auto-session", async (req, res) => {
try {
if (!isLoopbackRequest(req)) {
if (!allowsDesktopAutoSession() || !isLoopbackRequest(req)) {
authLogger.warn(
"Rejected non-loopback attempt to access auto-session endpoint",
{ source: req.ip },