Files
Termix/src/backend/sync/server/push.ts
T
f90621a54e v26.10.6 (#1739)
* feat: let the login page send the token back to the mobile app

* feat: add host-scripts entry to the plugin sdk

* feat: let plugins say how they show in the mobile app

* feat: frame host for running one plugin view outside the shell

* fix: build the SDK host modules in production mode

* chore: note the SDK host build fix in the changelog

* feat: list plugin engine ranges for the mobile app

* feat: host color and icon columns that sync

* chore: update translations and plugin pins

* fix: test

* fix: show source address when rejecting proxy auth

* fix: deleting your own account removes all of its data

* fix(deps): update body-parser to 2.4.0 (#1736)

* fix: body-parser 보안 패치 반영

* chore: 자동 검사를 막던 기존 JSON 서식 정리

---------

Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>

* fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735)

* fix: honor shared credentials on shared hosts (#1730)

* fix: honor shared credentials on shared hosts and in personal SSH auth

* fix: snapshot shared SSH auth from a credential shared with the host owner

* fix: size DataView list columns by the table's own width (#1713)

* fix: size DataView list columns by the table's own width

The list grid was built from every column, so columns hidden below md/lg
left empty tracks and the visible cells landed under the wrong headers.
Each width now gets a template of only its visible columns (CSS variables
+ container queries on the table, so a narrow panel counts, not the window),
the header uses the same gap as the rows, and rows use a minimum height so
wrapped cells no longer overlap.

* test: wait for the title bar offset effect before asserting

The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height
is set in a later passive effect, so reading it right after findByText raced
(3-5 of 40 runs under load, on main too).

* chore: rerun CI against dev-26.10.6

* fix: unlock passphrase keys once per connect burst (#1737)

* feat: give ssh transports the host origin and sync id (#1717)

* fix: wake other clients after a sync push (Plugin-Sync#1)

* feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4)

* feat: rail items can show a status dot (Plugin-Sync#5)

* fix: add the desktop shared copy banner text (#1709)

* feat: sidebar preference for alternating rows (#1712)

* fix: striped rows text

* feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36)

* fix: show the logo in the macos menu bar (#1728)

* fix: number shortcuts skip the home tab (#1724)

* fix: show plugins that could not be downloaded as failed (#1714)

* fix: pass the host id as text when opening its editor

* fix: retry a plugin bundle that was not ready yet (#1708)

* fix: let commonjs plugins fall back to core packages on the desktop

* fix: let non docker installs switch nginx to https (#1711)

* feat: remove a folder share from its hosts too (#1719)

* chore: changelog for merged pull requests

* fix: accept the release .sig when installing a plugin from a file

* feat: folder logins for shared hosts, read only plugin sync, full desktop builds

* fix: test

* fix: stop dev runner restart loops and narrow plugin watchers

* chore: update translations and plugin pins

* chore: lint, format, and bump version to 26.10.6

---------

Co-authored-by: renovys <renovy@gmail.com>
Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>
Co-authored-by: Sebastian Cao <cycsmail@gmail.com>
Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com>
Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
2026-10-29 17:59:55 -05:00

297 lines
9.0 KiB
TypeScript

/**
* Applying a desktop's pushed changes on the server.
*
* Each op names the revision its sender last saw. If the server has moved on
* since, the op is a conflict and the server's version is sent back instead
* of being overwritten. Every op gets its own result, so one bad row never
* holds up the rest of the batch.
*/
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
import { getEntity } from "../../plugins/sync-registry.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { syncLogger } from "../../utils/logger.js";
import {
hasHostLocks,
hostLock,
manualCreateBlockedBy,
} from "../../hosts/locks.js";
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
import {
createResolvers,
deleteStoredRow,
loadWireRow,
writeWireRow,
} from "../store.js";
import { allocateSeq, getRecord, putRecord, recordKey } from "../records.js";
import { orderSelfReferences } from "../wire.js";
import { markChanged, notifyListeners, reconcileUser } from "./feed.js";
export interface PushOp {
entityType: string;
syncId: string;
baseRevision: number;
op: "upsert" | "delete";
row?: SyncRow;
}
export type PushResult =
| { syncId: string; entityType: string; status: "ok"; revision: number }
| {
syncId: string;
entityType: string;
status: "conflict";
revision: number;
deleted: boolean;
row: SyncRow | null;
}
| {
syncId: string;
entityType: string;
status: "rejected";
reason: string;
};
export const MAX_PUSH_OPS = 500;
/** A host a plugin manages can't be changed from a desktop either. */
async function hostPushLocked(
userId: string,
syncId: string,
exists: boolean,
): Promise<boolean> {
if (!hasHostLocks()) return false;
if (!exists) return manualCreateBlockedBy() !== null;
const hostId =
await createCurrentHostResolutionRepository().findHostIdBySyncIdForUser(
syncId,
userId,
);
return hostId !== null && hostId !== undefined && hostLock(hostId) !== null;
}
function isPushOp(value: unknown): value is PushOp {
if (!value || typeof value !== "object") return false;
const op = value as Record<string, unknown>;
return (
typeof op.entityType === "string" &&
typeof op.syncId === "string" &&
op.syncId.length > 0 &&
op.syncId.length <= 191 &&
typeof op.baseRevision === "number" &&
(op.op === "upsert" || op.op === "delete") &&
(op.op === "delete" || (!!op.row && typeof op.row === "object"))
);
}
export function parsePushOps(body: unknown): PushOp[] | null {
const ops = (body as { ops?: unknown })?.ops;
if (!Array.isArray(ops) || ops.length > MAX_PUSH_OPS) return null;
return ops.every(isPushOp) ? ops : null;
}
/** Upserts in dependency order, then deletes in reverse, children first. */
export function orderOps(ops: PushOp[]): PushOp[] {
const order = (op: PushOp) => getEntity(op.entityType)?.order ?? 1_000_000;
const upserts = ops
.filter((op) => op.op === "upsert")
.sort((a, b) => order(a) - order(b));
const deletes = ops
.filter((op) => op.op === "delete")
.sort((a, b) => order(b) - order(a));
const orderedUpserts: PushOp[] = [];
const byType = new Map<string, PushOp[]>();
for (const op of upserts) {
const list = byType.get(op.entityType) ?? [];
list.push(op);
byType.set(op.entityType, list);
}
for (const [type, list] of byType) {
const entity = getEntity(type);
if (!entity) {
orderedUpserts.push(...list);
continue;
}
const bySyncId = new Map(list.map((op) => [op.syncId, op]));
const rows = orderSelfReferences(
entity,
list.map((op) => ({ ...(op.row as SyncRow), syncId: op.syncId })),
);
for (const row of rows) {
orderedUpserts.push(bySyncId.get(row.syncId as string)!);
}
}
return [...orderedUpserts, ...deletes];
}
/** Call inside withUserLock. */
export async function applyPush(
userId: string,
ops: PushOp[],
): Promise<PushResult[]> {
const { wire } = await reconcileUser(userId, { force: true });
const permissions = PermissionManager.getInstance();
let missed = false;
const resolvers = createResolvers(userId, () => {
missed = true;
});
const results = new Map<string, PushResult>();
// Rows written before something they point at, for a second pass.
const retry: PushOp[] = [];
for (const op of orderOps(ops)) {
const key = recordKey(op.entityType, op.syncId);
const base = { syncId: op.syncId, entityType: op.entityType };
const entity = getEntity(op.entityType);
if (!entity) {
results.set(key, { ...base, status: "rejected", reason: "unknown_type" });
continue;
}
if (entity.readOnly) {
results.set(key, { ...base, status: "rejected", reason: "read_only" });
continue;
}
const record = await getRecord(userId, op.entityType, op.syncId);
const currentRevision = record?.revision ?? 0;
if (op.baseRevision !== currentRevision) {
const row = record?.deleted ? null : (wire.get(key) ?? null);
// A live record with no row failed to serialize; a conflict without a
// row would read as a delete on the desktop.
if (record && !record.deleted && !row) {
results.set(key, {
...base,
status: "rejected",
reason: "unavailable",
});
continue;
}
results.set(key, {
...base,
status: "conflict",
revision: currentRevision,
deleted: !!record?.deleted,
row,
});
continue;
}
const exists = !!record && !record.deleted;
const permission =
op.op === "delete"
? entity.permissions?.delete
: exists
? entity.permissions?.update
: entity.permissions?.create;
if (permission && !(await permissions.hasPermission(userId, permission))) {
results.set(key, { ...base, status: "rejected", reason: "permission" });
continue;
}
if (
op.entityType === "hosts" &&
(await hostPushLocked(userId, op.syncId, exists))
) {
results.set(key, { ...base, status: "rejected", reason: "locked" });
continue;
}
try {
if (op.op === "delete") {
await deleteStoredRow(entity, userId, op.syncId);
await putRecord(userId, op.entityType, op.syncId, {
revision: currentRevision + 1,
seq: await allocateSeq(),
hash: null,
deleted: true,
});
wire.delete(key);
results.set(key, {
...base,
status: "ok",
revision: currentRevision + 1,
});
continue;
}
const row = { ...(op.row as SyncRow), syncId: op.syncId };
if (entity.shouldSync && entity.shouldSync(row) === false) {
results.set(key, { ...base, status: "rejected", reason: "not_synced" });
continue;
}
missed = false;
await writeWireRow(entity, userId, row, resolvers);
if (missed) retry.push(op);
const written = await loadWireRow(entity, userId, op.syncId, resolvers);
await putRecord(userId, op.entityType, op.syncId, {
revision: currentRevision + 1,
seq: await allocateSeq(),
hash: written?.hash ?? null,
deleted: false,
});
if (written) wire.set(key, written.wire);
results.set(key, {
...base,
status: "ok",
revision: currentRevision + 1,
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
syncLogger.warn("Rejected a pushed sync change", {
operation: "sync_push",
userId,
entityType: op.entityType,
syncId: op.syncId,
error: reason,
});
results.set(key, {
...base,
status: "rejected",
reason: `invalid: ${reason}`.slice(0, 500),
});
}
}
for (const op of retry) {
const entity = getEntity(op.entityType)!;
try {
await writeWireRow(
entity,
userId,
{ ...(op.row as SyncRow), syncId: op.syncId },
resolvers,
);
const written = await loadWireRow(entity, userId, op.syncId, resolvers);
const record = await getRecord(userId, op.entityType, op.syncId);
if (record && written) {
await putRecord(userId, op.entityType, op.syncId, {
revision: record.revision,
seq: record.seq,
hash: written.hash,
deleted: false,
});
}
} catch {
// The first write stands.
}
}
// Refresh the snapshot so the next pull serves what was just written.
await reconcileUser(userId, { force: true });
// The reconcile finds nothing new, since the push wrote the records itself,
// so tell this user's other clients, and other users who may share a row.
if ([...results.values()].some((result) => result.status === "ok")) {
notifyListeners(userId);
markChanged();
}
return ops.map(
(op) =>
results.get(recordKey(op.entityType, op.syncId)) ?? {
syncId: op.syncId,
entityType: op.entityType,
status: "rejected",
reason: "duplicate",
},
);
}