Files
Termix/src/backend/plugins/sync-registry.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

208 lines
6.0 KiB
TypeScript

/**
* Which entities sync between a linked desktop and its server.
*
* Core registers its own at boot and a plugin registers its own through
* ctx.sync, so the list is built rather than hardcoded. The wire names are
* part of the protocol: records on both sides match on them, so renaming one
* orphans data.
*
* Order is dependency order, not declaration order. A row referencing another
* entity is resolved by that entity's syncId, so the target has to be written
* first. That is why every registration carries an explicit order.
*/
import type {
SyncEntityRegistration,
SyncRow,
} from "@termix-ssh/plugin-sdk/backend";
/** Extras core sets for its own entities. A plugin may set the first four. */
export interface CoreSyncEntityOptions {
/** Server-to-desktop only. A desktop never pushes it. */
readOnly?: boolean;
/**
* Builds the user's rows instead of reading `table`, for data that is not
* a table row the user owns (hosts shared with them, plugin settings).
* Rows come back already on the wire: plaintext, with a syncId.
*/
load?: (userId: string) => Promise<SyncRow[]>;
/** Writes an inbound row, for an entity with `load`. */
write?: (userId: string, wire: SyncRow) => Promise<void>;
/** Deletes a row by syncId, for an entity with `load`. */
erase?: (userId: string, syncId: string) => Promise<void>;
/**
* Whether `load` would have returned this syncId if it existed. A row
* `load` cannot see right now (its plugin is off) is not a delete.
*/
covers?: (syncId: string) => boolean;
/** Keys kept off the content hash and out of writes. */
hashIgnore?: readonly string[];
/**
* Widens reference lookups past rows the user owns, e.g. a credential
* shared with them is a valid target for their host's credentialId.
*/
canReference?: (
userId: string,
ownerId: string,
id: number,
) => Promise<boolean>;
}
export interface RegisteredSyncEntity
extends SyncEntityRegistration, CoreSyncEntityOptions {
/** Which plugin registered it, or "core". */
owner: string;
order: number;
userColumn: string;
readOnlyFields: readonly string[];
encryptedFields: readonly string[];
}
/** What a plugin registration may carry. Anything else is dropped. */
const PLUGIN_KEYS = [
"type",
"table",
"userColumn",
"encryptedFields",
"references",
"order",
"readOnlyFields",
"singleton",
"answersTo",
"shouldSync",
"permissions",
"serialize",
"deserialize",
"afterWrite",
"remove",
"readOnly",
"load",
"write",
"erase",
] as const;
function pluginEntity(entity: SyncEntityRegistration): SyncEntityRegistration {
const picked: Record<string, unknown> = {};
for (const key of PLUGIN_KEYS) {
if (entity[key] !== undefined) picked[key] = entity[key];
}
const out = picked as unknown as SyncEntityRegistration;
if (out.load || out.write || out.erase) {
if (!out.load || !out.write || !out.erase) {
throw new Error(
`Sync entity "${entity.type}" needs load, write and erase together`,
);
}
// A plugin only ever syncs its own rows both ways; rows it builds for
// a user come down as read only copies.
if (out.readOnly !== true) {
throw new Error(
`Sync entity "${entity.type}" with load must be readOnly`,
);
}
} else if (!out.table) {
throw new Error(`Sync entity "${entity.type}" needs a table or load`);
}
return out;
}
const entities = new Map<string, RegisteredSyncEntity>();
const listeners = new Set<() => void>();
/** Core's own entities register under this owner. */
export const CORE_OWNER = "core";
export function registerEntity(
owner: string,
entity: SyncEntityRegistration,
core: CoreSyncEntityOptions = {},
): () => void {
if (!entity.type) {
throw new Error("A sync entity needs a type");
}
const existing = entities.get(entity.type);
if (existing && existing.owner !== owner) {
throw new Error(
`Sync entity "${entity.type}" is already registered by "${existing.owner}"`,
);
}
const registered: RegisteredSyncEntity = {
...(owner === CORE_OWNER ? entity : pluginEntity(entity)),
...(owner === CORE_OWNER ? core : {}),
owner,
order: entity.order ?? 100,
userColumn: entity.userColumn ?? "userId",
readOnlyFields: entity.readOnlyFields ?? [],
encryptedFields: entity.encryptedFields ?? [],
};
entities.set(entity.type, registered);
notify();
return () => {
// Identity-checked: a plugin that restarted must not revoke the
// registration its own restart installed.
if (entities.get(entity.type) === registered) {
entities.delete(entity.type);
notify();
}
};
}
export function getEntity(type: string): RegisteredSyncEntity | undefined {
const direct = entities.get(type);
if (direct) return direct;
for (const entity of entities.values()) {
if (entity.answersTo?.includes(type)) return entity;
}
return undefined;
}
export function hasEntity(type: string): boolean {
return entities.has(type);
}
/** Every entity, in dependency order. */
export function listEntities(): RegisteredSyncEntity[] {
return [...entities.values()].sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
return a.type.localeCompare(b.type);
});
}
export function listEntityTypes(): string[] {
return listEntities().map((entity) => entity.type);
}
export function unregisterByOwner(owner: string): void {
let changed = false;
for (const [type, entity] of [...entities]) {
if (entity.owner === owner) {
entities.delete(type);
changed = true;
}
}
if (changed) notify();
}
/** Called whenever the set of entities changes (a plugin came or went). */
export function onEntitiesChanged(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function notify(): void {
for (const listener of listeners) {
try {
listener();
} catch {
// A listener's failure is its own problem.
}
}
}
export function resetSyncRegistry(): void {
entities.clear();
}