mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
This commit is contained in:
1 parent
87cc80b291
commit
25816084aa
28 files changed
+1797
-49
No files matched your search
@@ -19,9 +19,14 @@
|
||||
- Plugins can set login rules: sign out after a time without activity, limit how long a session lasts, turn off remember me and require a second factor
|
||||
- A user who must have a second factor but has none can sign in only to set one up
|
||||
- Admins can redirect HTTP to HTTPS and send HSTS while HTTPS is on. Off by default so reverse proxies keep working
|
||||
- A plugin can answer all sync requests and use the replica store, change feed and linked server list, so sync no longer lives in the core app
|
||||
- The desktop app can hold several accounts, each with its own copy of a server's data. The app signs in as the active one and its auto session follows a switch
|
||||
- The linked server list holds one link per account, with room for more connection options per server
|
||||
- Another Termix server can manage this one with an API key, which can now ask for a one minute ticket to open plugin sockets as its user
|
||||
|
||||
### Changed
|
||||
|
||||
- Server and desktop sync moved out of the core app into a plugin. Existing links and offline copies keep working
|
||||
- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release
|
||||
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
|
||||
- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.16
|
||||
|
||||
Plugin API 1.8.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `ctx.sync.provide` and the `sync:provide` capability, so a plugin can answer every /sync request
|
||||
|
||||
## 1.0.15
|
||||
|
||||
Plugin API 1.7.0.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.15 | Plugin API 1.7.0
|
||||
SDK 1.0.16 | Plugin API 1.8.0
|
||||
|
||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||
@@ -227,6 +227,7 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
|
||||
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
|
||||
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
|
||||
| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors |
|
||||
| `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops |
|
||||
| `users.getLocale` | `users:read` | A user's UI language |
|
||||
| `system` | `system:tls` | The server's HTTPS certificate |
|
||||
| `plugins.list()` | `plugins:read` | Which plugins are installed |
|
||||
@@ -707,6 +708,14 @@ lists them too). `evaluate` returns `allowRememberDevice`, `maxSessionSeconds`,
|
||||
refuses the login. A user who must have a second factor but has none gets a session that can only set one
|
||||
up, and `/users/me` reports `restriction: "second-factor-setup"`.
|
||||
|
||||
Sync is answered by one plugin. `sync:provide` is high risk: `ctx.sync.provide({ handle })` takes over every
|
||||
request under `/sync` (an Express style handler, 404 when nothing provides it), and `ctx.sync.host` hands
|
||||
over the neutral parts core keeps: the replica store (`replica`), the change feed and push applier
|
||||
(`feed`), the linked server list (`links`, one per local user), desktop accounts (`accounts`), sessions
|
||||
(`sessions`), host lookups, plugin mirroring and the proxy settings the desktop app needs. A test passes its
|
||||
own `syncHost` to `createMockCtx`. An API key can ask `POST /users/ws-ticket/api-key` for a one minute
|
||||
ticket that opens plugin sockets as its user, which is how one server opens sessions through another.
|
||||
|
||||
## Testing
|
||||
|
||||
```ts
|
||||
@@ -904,6 +913,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
||||
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
|
||||
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
||||
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
|
||||
| `sync:provide` | high | Run sync: answer every /sync request |
|
||||
| `device:serial` | high | Open serial and USB ports on the server |
|
||||
| `desktop:local` | high | Use the user's own computer, desktop app only |
|
||||
| `hosts:export` | high | Export hosts with their saved logins |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.15",
|
||||
"version": "1.0.16",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"users:write",
|
||||
"auth:provide",
|
||||
"auth:policy",
|
||||
"sync:provide",
|
||||
"system:tls",
|
||||
"device:serial",
|
||||
"hosts:write",
|
||||
|
||||
@@ -236,6 +236,429 @@ export interface SyncEntityRegistration {
|
||||
*/
|
||||
export interface PluginSync {
|
||||
registerEntity: (entity: SyncEntityRegistration) => void;
|
||||
/** Needs sync:provide. Takes over /sync/v2 and /sync/link. Removed on deactivate. */
|
||||
provide: (provider: PluginSyncProvider) => () => void;
|
||||
/** Needs sync:provide. The replica store, change feed and link registry. */
|
||||
readonly host: PluginSyncHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin that owns server and desktop sync. Core forwards every request
|
||||
* under /sync to `handle` (an Express style handler) and answers 404 when no
|
||||
* plugin provides it. Call `next()` to let core answer instead.
|
||||
*/
|
||||
export interface PluginSyncProvider {
|
||||
handle: (
|
||||
req: unknown,
|
||||
res: unknown,
|
||||
next: (error?: unknown) => void,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** A kind of data that syncs, as the sync provider sees it. */
|
||||
export interface SyncEntityInfo {
|
||||
type: string;
|
||||
owner: string;
|
||||
order: number;
|
||||
readOnly: boolean;
|
||||
/** Whether the row would show in a load even if it exists. */
|
||||
covers?: (syncId: string) => boolean;
|
||||
}
|
||||
|
||||
/** A row ready for the wire, with its content hash. */
|
||||
export interface SyncWireRow {
|
||||
syncId: string;
|
||||
wire: SyncRow;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface SyncRecordInfo {
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
revision: number;
|
||||
hash: string | null;
|
||||
deleted: boolean;
|
||||
error: string | null;
|
||||
errorHash: string | null;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export interface SyncConflictInfo {
|
||||
id: number;
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
localRow: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SyncAccountInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface SyncProxyHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SyncBasicAuth {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** Extra per server connection options. Kept open so new ones need no migration. */
|
||||
export type SyncLinkOptions = Record<string, unknown>;
|
||||
|
||||
/** One server a desktop is linked to, with a replica of its data. */
|
||||
export interface SyncLinkInfo {
|
||||
id: number;
|
||||
/** The local user the replica belongs to. */
|
||||
userId: string;
|
||||
serverUrl: string;
|
||||
serverName: string | null;
|
||||
serverVersion: string | null;
|
||||
sessionToken: string | null;
|
||||
customHeaders: SyncProxyHeader[];
|
||||
basicAuth: SyncBasicAuth | null;
|
||||
allowInvalidCertificate: boolean;
|
||||
remoteUserId: string | null;
|
||||
remoteUsername: string | null;
|
||||
account: {
|
||||
remoteUserId?: string;
|
||||
username?: string;
|
||||
isAdmin?: boolean;
|
||||
roles?: string[];
|
||||
permissions?: string[];
|
||||
} | null;
|
||||
disabledTypes: string[];
|
||||
/** Extra connection options for this server, kept open for new ones. */
|
||||
options: SyncLinkOptions;
|
||||
knownTypes: string[];
|
||||
cursor: number;
|
||||
status: "idle" | "syncing" | "offline" | "error" | "signed_out";
|
||||
lastError: string | null;
|
||||
linkedAt: string;
|
||||
lastSyncAt: string | null;
|
||||
}
|
||||
|
||||
export interface SyncLinkCreate {
|
||||
userId: string;
|
||||
serverUrl: string;
|
||||
serverName?: string | null;
|
||||
serverVersion?: string | null;
|
||||
sessionToken: string;
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
remoteUserId?: string | null;
|
||||
remoteUsername?: string | null;
|
||||
}
|
||||
|
||||
export type SyncLinkPatch = Partial<
|
||||
Pick<
|
||||
SyncLinkInfo,
|
||||
| "serverName"
|
||||
| "serverVersion"
|
||||
| "sessionToken"
|
||||
| "customHeaders"
|
||||
| "basicAuth"
|
||||
| "allowInvalidCertificate"
|
||||
| "remoteUserId"
|
||||
| "remoteUsername"
|
||||
| "account"
|
||||
| "disabledTypes"
|
||||
| "options"
|
||||
| "knownTypes"
|
||||
| "cursor"
|
||||
| "status"
|
||||
| "lastError"
|
||||
| "lastSyncAt"
|
||||
>
|
||||
>;
|
||||
|
||||
export type SyncRemoteErrorKind =
|
||||
"offline" | "tls" | "proxy" | "basic_auth" | "signed_out" | "server";
|
||||
|
||||
export interface SyncRemoteError extends Error {
|
||||
readonly kind: SyncRemoteErrorKind;
|
||||
readonly status?: number;
|
||||
}
|
||||
|
||||
export interface SyncRemoteTarget {
|
||||
serverUrl: string;
|
||||
sessionToken?: string | null;
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncRemoteRequest {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface SyncPushOp {
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
baseRevision: number;
|
||||
op: "upsert" | "delete";
|
||||
row?: SyncRow;
|
||||
}
|
||||
|
||||
export type SyncPushResult =
|
||||
| { 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 interface SyncRemotePlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
source: string;
|
||||
artifact: boolean;
|
||||
enabled: boolean;
|
||||
active: boolean;
|
||||
desktop: string;
|
||||
syncEntities: string[];
|
||||
granted: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sync provider gets from core: the replica store, the change feed,
|
||||
* the linked server registry, sessions and a few helpers. Core keeps all of
|
||||
* it neutral; the provider decides when and how to use it. Needs sync:provide.
|
||||
*/
|
||||
export interface PluginSyncHost {
|
||||
/** True on the desktop app's own backend. */
|
||||
readonly embedded: boolean;
|
||||
info: { appName: () => Promise<string>; version: () => string };
|
||||
|
||||
entities: {
|
||||
list: () => SyncEntityInfo[];
|
||||
get: (type: string) => SyncEntityInfo | undefined;
|
||||
/** Registers core's own entities once. */
|
||||
ensureCore: () => Promise<void>;
|
||||
onChanged: (listener: () => void) => () => void;
|
||||
onLocalWrite: (listener: () => void) => () => void;
|
||||
};
|
||||
|
||||
/** Rows and bookkeeping of one local user's replica. */
|
||||
replica: {
|
||||
resolvers: (userId: string, onMissing?: () => void) => unknown;
|
||||
loadRows: (
|
||||
type: string,
|
||||
userId: string,
|
||||
resolvers: unknown,
|
||||
) => Promise<{ rows: Map<string, SyncWireRow>; failed: Set<string> }>;
|
||||
loadRow: (
|
||||
type: string,
|
||||
userId: string,
|
||||
syncId: string,
|
||||
resolvers: unknown,
|
||||
) => Promise<SyncWireRow | null>;
|
||||
writeRow: (
|
||||
type: string,
|
||||
userId: string,
|
||||
wire: SyncRow,
|
||||
resolvers: unknown,
|
||||
) => Promise<void>;
|
||||
deleteRow: (type: string, userId: string, syncId: string) => Promise<void>;
|
||||
orderSelfReferences: (type: string, rows: SyncRow[]) => SyncRow[];
|
||||
counts: (userId: string) => Promise<Record<string, number>>;
|
||||
wipe: (userId: string) => Promise<void>;
|
||||
regenerateSyncIds: (userId: string) => Promise<void>;
|
||||
removeSharedCopies: (userId: string) => Promise<void>;
|
||||
records: {
|
||||
list: (userId: string) => Promise<Map<string, SyncRecordInfo>>;
|
||||
put: (
|
||||
userId: string,
|
||||
type: string,
|
||||
syncId: string,
|
||||
values: { revision: number; hash: string | null; deleted: boolean },
|
||||
) => Promise<void>;
|
||||
setError: (
|
||||
userId: string,
|
||||
type: string,
|
||||
syncId: string,
|
||||
reason: string,
|
||||
hash: string,
|
||||
) => Promise<void>;
|
||||
clearErrors: (userId: string) => Promise<void>;
|
||||
listErrors: (userId: string) => Promise<SyncRecordInfo[]>;
|
||||
deleteForUser: (userId: string) => Promise<void>;
|
||||
key: (type: string, syncId: string) => string;
|
||||
};
|
||||
conflicts: {
|
||||
save: (
|
||||
userId: string,
|
||||
type: string,
|
||||
syncId: string,
|
||||
row: SyncRow,
|
||||
revision: number,
|
||||
) => Promise<void>;
|
||||
list: (userId: string) => Promise<SyncConflictInfo[]>;
|
||||
get: (userId: string, id: number) => Promise<SyncConflictInfo | null>;
|
||||
delete: (userId: string, id: number) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
/** The server side: what changed for an account and what a desktop pushed. */
|
||||
feed: {
|
||||
withUserLock: <T>(userId: string, run: () => Promise<T>) => Promise<T>;
|
||||
changes: (
|
||||
userId: string,
|
||||
cursor: number,
|
||||
limit: number,
|
||||
types?: string[],
|
||||
) => Promise<{
|
||||
records: Array<{
|
||||
seq: number;
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
revision: number;
|
||||
deleted: boolean;
|
||||
row: SyncRow | null;
|
||||
}>;
|
||||
hasMore: boolean;
|
||||
nextCursor: number;
|
||||
entityTypes: Array<{
|
||||
type: string;
|
||||
owner: string;
|
||||
order: number;
|
||||
readOnly: boolean;
|
||||
}>;
|
||||
}>;
|
||||
parsePushOps: (body: unknown) => Promise<SyncPushOp[] | null>;
|
||||
applyPush: (userId: string, ops: SyncPushOp[]) => Promise<SyncPushResult[]>;
|
||||
/** Streams a change notice to the account's open event streams. */
|
||||
listen: (userId: string, res: unknown) => Promise<() => void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The linked server registry, kept in core so it is there before plugins.
|
||||
* One link per local user. Without a user, the active account is meant.
|
||||
*/
|
||||
links: {
|
||||
list: () => Promise<SyncLinkInfo[]>;
|
||||
get: (userId?: string) => Promise<SyncLinkInfo | null>;
|
||||
create: (link: SyncLinkCreate) => Promise<SyncLinkInfo>;
|
||||
update: (
|
||||
patch: SyncLinkPatch,
|
||||
userId?: string,
|
||||
) => Promise<SyncLinkInfo | null>;
|
||||
remove: (userId?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
/** Desktop accounts: the local user and the replica user of each server. */
|
||||
accounts: {
|
||||
list: () => Promise<SyncAccountInfo[]>;
|
||||
/** A new empty account, ready to be linked to a server. */
|
||||
create: () => Promise<string>;
|
||||
/** Which account the app signs in as from now on. */
|
||||
setActive: (userId: string) => Promise<void>;
|
||||
activeId: () => Promise<string | null>;
|
||||
/** Deletes an account and all its data. The last one cannot go. */
|
||||
remove: (userId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
http: {
|
||||
json: <T>(
|
||||
target: SyncRemoteTarget,
|
||||
pathname: string,
|
||||
request?: SyncRemoteRequest,
|
||||
) => Promise<T>;
|
||||
fetch: (
|
||||
target: SyncRemoteTarget,
|
||||
pathname: string,
|
||||
request?: SyncRemoteRequest,
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body: AsyncIterable<Uint8Array> | null;
|
||||
}>;
|
||||
normalizeServerUrl: (input: string) => string | null;
|
||||
isRemoteError: (error: unknown) => error is SyncRemoteError;
|
||||
};
|
||||
|
||||
sessions: {
|
||||
/** A long lived session for a linked desktop. Shows in the account's sessions. */
|
||||
issueDesktop: (
|
||||
userId: string,
|
||||
options: { days: number; deviceInfo: string },
|
||||
) => Promise<string>;
|
||||
revoke: (sessionId: string) => Promise<void>;
|
||||
find: (
|
||||
sessionId: string,
|
||||
) => Promise<{ expiresAt: string; deviceInfo: string | null } | null>;
|
||||
findUser: (
|
||||
userId: string,
|
||||
) => Promise<{ id: string; username: string } | null>;
|
||||
/** The user and session behind a token, or null when it is not valid. */
|
||||
verify: (
|
||||
token: string,
|
||||
) => Promise<{ userId: string; sessionId?: string } | null>;
|
||||
/** Sets the sign in cookie on a response, like a login does. A name sets another cookie. */
|
||||
setCookie: (
|
||||
req: unknown,
|
||||
res: unknown,
|
||||
value: string,
|
||||
options?: { name?: string; days?: number },
|
||||
) => void;
|
||||
/** Removes the sign in cookie, without ending the session behind it. */
|
||||
clearCookie: (req: unknown, res: unknown, name?: string) => void;
|
||||
/** Express middleware that signs the request in and sets userId and sessionId. */
|
||||
middleware: () => unknown;
|
||||
/** Express middleware that lets a request through only on a desktop. */
|
||||
desktopOnly: () => unknown;
|
||||
};
|
||||
|
||||
hosts: {
|
||||
findForSync: (
|
||||
userId: string,
|
||||
syncId: string,
|
||||
) => Promise<{ id: number; name: string | null } | null>;
|
||||
statusBySyncId: (userId: string) => Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
plugins: {
|
||||
listForDesktop: () => Promise<{
|
||||
plugins: SyncRemotePlugin[];
|
||||
registryKeys: unknown;
|
||||
}>;
|
||||
/** Sends a plugin archive; false when there is no such plugin to share. */
|
||||
sendPackage: (pluginId: string, res: unknown) => Promise<boolean>;
|
||||
mirror: (link: SyncLinkInfo) => Promise<void>;
|
||||
lastRemote: () => SyncRemotePlugin[];
|
||||
localNames: () => Map<string, string>;
|
||||
};
|
||||
|
||||
desktop: {
|
||||
/** Tells the app's main process how to reach the linked origin. */
|
||||
applyProxy: (link: SyncLinkInfo | null) => Promise<void>;
|
||||
deviceName: () => string;
|
||||
};
|
||||
|
||||
alerts: {
|
||||
send: (alert: {
|
||||
title: string;
|
||||
body: string;
|
||||
severity: "info" | "warning" | "error";
|
||||
category: string;
|
||||
dedupeKey: string;
|
||||
link?: { tab: string };
|
||||
}) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,6 +48,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("db:core-refs", "high"),
|
||||
entry("auth:provide", "high"),
|
||||
entry("auth:policy", "high"),
|
||||
entry("sync:provide", "high"),
|
||||
entry("device:serial", "high"),
|
||||
entry("notify:hub", "high"),
|
||||
entry("ui:layout", "high"),
|
||||
|
||||
@@ -37,7 +37,7 @@ export {
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = "1.7.0";
|
||||
export const PLUGIN_API_VERSION = "1.8.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
|
||||
@@ -68,7 +68,7 @@ import type {
|
||||
PluginSshKeyCredential,
|
||||
PluginSshKeyCredentialInput,
|
||||
} from "./backend.js";
|
||||
import type { SyncEntityRegistration } from "./backend.js";
|
||||
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
|
||||
import type { PluginDatabase } from "./backend.js";
|
||||
import type {
|
||||
PluginExternalClientRequest,
|
||||
@@ -104,6 +104,8 @@ export interface FakeScheduledJob {
|
||||
|
||||
export interface FakeContextOptions {
|
||||
pluginId?: string;
|
||||
/** What ctx.sync.host answers, for a sync provider's tests. */
|
||||
syncHost?: Partial<PluginSyncHost>;
|
||||
manifest?: Partial<PluginManifest>;
|
||||
/** Acting user returned by currentActor and used by asUser. */
|
||||
actor?: string;
|
||||
@@ -756,6 +758,12 @@ export function createFakeContext(
|
||||
registerEntity: (entity) => {
|
||||
syncEntities.push(entity);
|
||||
},
|
||||
provide: () => () => {},
|
||||
get host() {
|
||||
if (!options.syncHost)
|
||||
throw new Error("Pass syncHost to use ctx.sync.host");
|
||||
return options.syncHost as PluginSyncHost;
|
||||
},
|
||||
},
|
||||
|
||||
registry: {
|
||||
@@ -1582,6 +1590,8 @@ export function createFakeContext(
|
||||
|
||||
export interface MockContextOptions {
|
||||
pluginId?: string;
|
||||
/** What ctx.sync.host answers, for a sync provider's tests. */
|
||||
syncHost?: Partial<PluginSyncHost>;
|
||||
manifest?: Partial<PluginManifest>;
|
||||
/** Capabilities the plugin is granted. Anything else throws. */
|
||||
capabilities?: string[];
|
||||
@@ -1744,6 +1754,7 @@ export function createMockCtx(
|
||||
|
||||
const base = createFakeContext({
|
||||
pluginId: options.pluginId,
|
||||
syncHost: options.syncHost,
|
||||
actor: options.actor,
|
||||
settings: options.settings,
|
||||
coreSettings: options.coreSettings,
|
||||
|
||||
@@ -27,8 +27,7 @@ import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
||||
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
||||
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import syncRoutes from "../sync/server/routes.js";
|
||||
import syncLinkRoutes from "../sync/client/routes.js";
|
||||
import { syncForwarder } from "../sync/provider.js";
|
||||
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
|
||||
import dashboardRoutes from "./routes/dashboard-routes.js";
|
||||
import {
|
||||
@@ -1625,8 +1624,7 @@ app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
||||
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||
app.use("/ui-preferences", uiPreferencesRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
app.use("/sync", syncRoutes);
|
||||
app.use("/sync", syncLinkRoutes);
|
||||
app.use("/sync", syncForwarder);
|
||||
app.use("/dashboard", dashboardRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
app.use(
|
||||
|
||||
@@ -61,15 +61,23 @@ export function isNativeTokenExportRequest(req: Request): boolean {
|
||||
* during testing, or a household sharing one install) -- a user in that
|
||||
* state deserves to get into the app they installed, not a confusing,
|
||||
* unexplained dead end. So this always returns a single, deterministic
|
||||
* user: the admin account if one exists, else the earliest-registered
|
||||
* account. It never returns null.
|
||||
* user: the account the user switched to if it still exists, else the admin
|
||||
* account if one exists, else the earliest-registered account. It never
|
||||
* returns null when there are users.
|
||||
*/
|
||||
export function resolveDesktopAutoSessionUser(
|
||||
allUsers: UserRecord[],
|
||||
preferredId?: string | null,
|
||||
): UserRecord | null {
|
||||
if (allUsers.length === 0) return null;
|
||||
if (allUsers.length === 1) return allUsers[0];
|
||||
|
||||
// An account the user switched to on purpose, while it still exists.
|
||||
const preferred = preferredId
|
||||
? allUsers.find((user) => user.id === preferredId)
|
||||
: undefined;
|
||||
if (preferred) return preferred;
|
||||
|
||||
const admin = allUsers.find((user) => user.isAdmin);
|
||||
if (admin) return admin;
|
||||
|
||||
|
||||
@@ -576,8 +576,8 @@ 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;
|
||||
const link = await getLink(userId);
|
||||
if (!link) return null;
|
||||
return {
|
||||
serverUrl: link.serverUrl,
|
||||
serverName: link.serverName,
|
||||
@@ -743,7 +743,11 @@ router.post("/internal/auto-session", async (req, res) => {
|
||||
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const allUsers = await userRepository.listAll();
|
||||
const userRecord = resolveDesktopAutoSessionUser(allUsers);
|
||||
const { getSavedActiveUserId } = await import("../../sync/accounts.js");
|
||||
const userRecord = resolveDesktopAutoSessionUser(
|
||||
allUsers,
|
||||
await getSavedActiveUserId(),
|
||||
);
|
||||
if (!userRecord) {
|
||||
return res.status(403).json({
|
||||
error: "No local users exist",
|
||||
|
||||
@@ -54,6 +54,9 @@ import {
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db";
|
||||
import * as syncRegistry from "./sync-registry.js";
|
||||
import { provideSync } from "../sync/provider.js";
|
||||
import { createSyncHost } from "../sync/host.js";
|
||||
import type { PluginSyncHost } from "@termix-ssh/plugin-sdk/backend";
|
||||
import {
|
||||
needsExplicitPersist,
|
||||
resolveDatabaseDialect,
|
||||
@@ -189,6 +192,34 @@ export function createPluginContext(
|
||||
handle: PluginHandle,
|
||||
): PluginContext {
|
||||
const pluginId = manifest.id;
|
||||
let syncHost: PluginSyncHost | undefined;
|
||||
const syncApi: PluginContext["sync"] = {
|
||||
registerEntity: (entity) => {
|
||||
const dispose = syncRegistry.registerEntity(pluginId, entity);
|
||||
handle.bag.add(dispose, `sync entity ${entity.type}`);
|
||||
},
|
||||
provide: (provider) => {
|
||||
if (!manifest.capabilities.includes("sync:provide")) {
|
||||
throw capabilityRefused(pluginId, "sync:provide");
|
||||
}
|
||||
const remove = provideSync(pluginId, provider);
|
||||
const drop = handle.bag.add(remove, "sync provider");
|
||||
return () => {
|
||||
drop();
|
||||
remove();
|
||||
};
|
||||
},
|
||||
} as PluginContext["sync"];
|
||||
// Not enumerable, so tools that walk the context do not trip the check.
|
||||
Object.defineProperty(syncApi, "host", {
|
||||
enumerable: false,
|
||||
get() {
|
||||
if (!manifest.capabilities.includes("sync:provide")) {
|
||||
throw capabilityRefused(pluginId, "sync:provide");
|
||||
}
|
||||
return (syncHost ??= createSyncHost());
|
||||
},
|
||||
});
|
||||
const auditCall = (
|
||||
action: string,
|
||||
details: string,
|
||||
@@ -520,12 +551,7 @@ export function createPluginContext(
|
||||
},
|
||||
},
|
||||
|
||||
sync: {
|
||||
registerEntity: (entity) => {
|
||||
const dispose = syncRegistry.registerEntity(pluginId, entity);
|
||||
handle.bag.add(dispose, `sync entity ${entity.type}`);
|
||||
},
|
||||
},
|
||||
sync: syncApi,
|
||||
|
||||
kv: {
|
||||
get: (key) => kvGet(key),
|
||||
|
||||
@@ -265,17 +265,15 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// After plugins, so their sync entities are registered before a pass.
|
||||
// Sync itself starts with the plugin that provides it.
|
||||
if (process.env.ELECTRON_EMBEDDED === "true") {
|
||||
try {
|
||||
const { startDesktopSync } = await import("./sync/client/engine.js");
|
||||
startDesktopSync();
|
||||
const { startDesktopUpdateCheck } =
|
||||
await import("./updates/desktop-update-check.js");
|
||||
startDesktopUpdateCheck();
|
||||
} catch (error) {
|
||||
systemLogger.warn("Desktop sync failed to start", {
|
||||
operation: "sync_start",
|
||||
systemLogger.warn("Desktop update check failed to start", {
|
||||
operation: "update_check_start",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Accounts on a desktop. Each local user is one account: the plain local one,
|
||||
* or the replica of a linked server. Exactly one is active, and the app signs
|
||||
* in as it. Switching only changes which user the desktop auto session picks,
|
||||
* so no data is shared between accounts.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { resolveDesktopAutoSessionUser } from "../database/routes/desktop-auto-session.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
|
||||
const ACTIVE_KEY = "desktop_active_user";
|
||||
|
||||
/** The user the desktop signs in as. Falls back to the usual single-user pick. */
|
||||
export async function getActiveUserId(): Promise<string | null> {
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
if (users.length === 0) return null;
|
||||
const saved = await createCurrentSettingsRepository().get(ACTIVE_KEY);
|
||||
return resolveDesktopAutoSessionUser(users, saved)?.id ?? null;
|
||||
}
|
||||
|
||||
export async function setActiveUserId(userId: string): Promise<void> {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
if (!user) throw new Error("No such account");
|
||||
await createCurrentSettingsRepository().set(ACTIVE_KEY, userId);
|
||||
}
|
||||
|
||||
/** Saved choice only, for the auto session route. */
|
||||
export async function getSavedActiveUserId(): Promise<string | null> {
|
||||
return createCurrentSettingsRepository().get(ACTIVE_KEY);
|
||||
}
|
||||
|
||||
export interface DesktopAccount {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export async function listAccounts(): Promise<DesktopAccount[]> {
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
const active = await getActiveUserId();
|
||||
return users.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
isAdmin: !!user.isAdmin,
|
||||
active: user.id === active,
|
||||
}));
|
||||
}
|
||||
|
||||
/** A new empty account for another server to be linked to. */
|
||||
export async function createAccount(): Promise<string> {
|
||||
const users = createCurrentUserRepository();
|
||||
const id = crypto.randomUUID();
|
||||
await users.create({
|
||||
id,
|
||||
username: `server-${id.slice(0, 8)}`,
|
||||
passwordHash: "",
|
||||
isAdmin: true,
|
||||
});
|
||||
try {
|
||||
await createCurrentRoleRepository().assignRoleNameToUser({
|
||||
userId: id,
|
||||
roleName: "admin",
|
||||
grantedBy: id,
|
||||
});
|
||||
} catch {
|
||||
// The account still works; admin is also set on the user row.
|
||||
}
|
||||
await AuthManager.getInstance().registerUser(
|
||||
id,
|
||||
crypto.randomBytes(32).toString("hex"),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Deletes an account and everything it owns. The last account stays. */
|
||||
export async function removeAccount(userId: string): Promise<void> {
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
if (users.length <= 1) throw new Error("The last account cannot be removed");
|
||||
const active = await getActiveUserId();
|
||||
const { deleteUserAndRelatedData } =
|
||||
await import("../database/routes/delete-user-data.js");
|
||||
await deleteUserAndRelatedData(userId);
|
||||
if (active === userId) {
|
||||
const next = users.find((user) => user.id !== userId);
|
||||
if (next) await setActiveUserId(next.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import os from "os";
|
||||
|
||||
export function defaultDeviceName(): string {
|
||||
return os.hostname() || "Termix Desktop";
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* The desktop's link to a server, one row in sync_link. Secrets (the session
|
||||
* token, proxy headers, basic auth) are stored encrypted with the system key.
|
||||
* The desktop's links to servers, one row each in sync_link and at most one
|
||||
* per local user, since each link has its own replica user. Secrets (the
|
||||
* session token, proxy headers, basic auth) are stored encrypted with the
|
||||
* system key. Without a user, a call means the active account's link.
|
||||
*/
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActiveUserId } from "../accounts.js";
|
||||
import { syncLink } from "../../database/db/schema.js";
|
||||
import { createCurrentRepositoryContext } from "../../database/repositories/factory.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
@@ -48,6 +51,8 @@ export interface SyncLink {
|
||||
account: LinkAccount | null;
|
||||
/** Entity types switched off on this device. */
|
||||
disabledTypes: string[];
|
||||
/** Extra connection options for this server, kept open for new ones. */
|
||||
options: Record<string, unknown>;
|
||||
/** Entity types this device has fully pulled at least once. */
|
||||
knownTypes: string[];
|
||||
cursor: number;
|
||||
@@ -90,7 +95,10 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
||||
sessionToken = null;
|
||||
}
|
||||
}
|
||||
const scope = parseJson<{ disabled?: string[] }>(row.scope, {});
|
||||
const scope = parseJson<{
|
||||
disabled?: string[];
|
||||
options?: Record<string, unknown>;
|
||||
}>(row.scope, {});
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
@@ -105,6 +113,8 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
||||
remoteUsername: row.remoteUsername,
|
||||
account: parseJson<LinkAccount | null>(row.account, null),
|
||||
disabledTypes: Array.isArray(scope.disabled) ? scope.disabled : [],
|
||||
options:
|
||||
scope.options && typeof scope.options === "object" ? scope.options : {},
|
||||
knownTypes: parseJson<string[]>(row.knownTypes, []),
|
||||
cursor: Number(row.cursor) || 0,
|
||||
status: (row.status as LinkStatus) || "idle",
|
||||
@@ -114,8 +124,20 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLink(): Promise<SyncLink | null> {
|
||||
const [row] = await db().select().from(syncLink).limit(1);
|
||||
export async function listLinks(): Promise<SyncLink[]> {
|
||||
const rows = await db().select().from(syncLink);
|
||||
return Promise.all(rows.map(fromRow));
|
||||
}
|
||||
|
||||
/** The link of one local user, or of the active account when none is given. */
|
||||
export async function getLink(userId?: string): Promise<SyncLink | null> {
|
||||
const owner = userId ?? (await getActiveUserId());
|
||||
if (!owner) return null;
|
||||
const [row] = await db()
|
||||
.select()
|
||||
.from(syncLink)
|
||||
.where(eq(syncLink.userId, owner))
|
||||
.limit(1);
|
||||
return row ? fromRow(row) : null;
|
||||
}
|
||||
|
||||
@@ -133,7 +155,7 @@ export interface NewLink {
|
||||
}
|
||||
|
||||
export async function createLink(link: NewLink): Promise<SyncLink> {
|
||||
await db().delete(syncLink);
|
||||
await db().delete(syncLink).where(eq(syncLink.userId, link.userId));
|
||||
await db()
|
||||
.insert(syncLink)
|
||||
.values({
|
||||
@@ -157,7 +179,7 @@ export async function createLink(link: NewLink): Promise<SyncLink> {
|
||||
linkedAt: new Date().toISOString(),
|
||||
});
|
||||
await DatabaseSaveTrigger.forceSave("sync_link");
|
||||
return (await getLink())!;
|
||||
return (await getLink(link.userId))!;
|
||||
}
|
||||
|
||||
export interface LinkPatch {
|
||||
@@ -171,6 +193,7 @@ export interface LinkPatch {
|
||||
remoteUsername?: string | null;
|
||||
account?: LinkAccount | null;
|
||||
disabledTypes?: string[];
|
||||
options?: Record<string, unknown>;
|
||||
knownTypes?: string[];
|
||||
cursor?: number;
|
||||
status?: LinkStatus;
|
||||
@@ -178,8 +201,11 @@ export interface LinkPatch {
|
||||
lastSyncAt?: string | null;
|
||||
}
|
||||
|
||||
export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
|
||||
const current = await getLink();
|
||||
export async function updateLink(
|
||||
patch: LinkPatch,
|
||||
userId?: string,
|
||||
): Promise<SyncLink | null> {
|
||||
const current = await getLink(userId);
|
||||
if (!current) return null;
|
||||
const set: Partial<Row> = {};
|
||||
if (patch.serverName !== undefined) set.serverName = patch.serverName;
|
||||
@@ -210,8 +236,11 @@ export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
|
||||
if (patch.account !== undefined) {
|
||||
set.account = patch.account ? JSON.stringify(patch.account) : null;
|
||||
}
|
||||
if (patch.disabledTypes !== undefined) {
|
||||
set.scope = JSON.stringify({ disabled: patch.disabledTypes });
|
||||
if (patch.disabledTypes !== undefined || patch.options !== undefined) {
|
||||
set.scope = JSON.stringify({
|
||||
disabled: patch.disabledTypes ?? current.disabledTypes,
|
||||
options: patch.options ?? current.options,
|
||||
});
|
||||
}
|
||||
if (patch.knownTypes !== undefined) {
|
||||
set.knownTypes = JSON.stringify(patch.knownTypes);
|
||||
@@ -224,10 +253,12 @@ export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
|
||||
await db().update(syncLink).set(set).where(eq(syncLink.id, current.id));
|
||||
DatabaseSaveTrigger.triggerSave("sync_link");
|
||||
}
|
||||
return getLink();
|
||||
return getLink(current.userId);
|
||||
}
|
||||
|
||||
export async function deleteLink(): Promise<void> {
|
||||
await db().delete(syncLink);
|
||||
export async function deleteLink(userId?: string): Promise<void> {
|
||||
const owner = userId ?? (await getActiveUserId());
|
||||
if (!owner) return;
|
||||
await db().delete(syncLink).where(eq(syncLink.userId, owner));
|
||||
await DatabaseSaveTrigger.forceSave("sync_link");
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* What the sync provider plugin gets from core through ctx.sync.host: the
|
||||
* replica store, the change feed, the linked server registry, sessions and
|
||||
* a few helpers. Core stays neutral; the plugin decides what to do with it.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import * as tar from "tar";
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import type { PluginSyncHost, SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { getLocalVersion } from "../utils/app-version.js";
|
||||
import { syncLogger } from "../utils/logger.js";
|
||||
import { sendCoreAlert } from "../notify/core-notify.js";
|
||||
import { getPluginRuntime } from "../plugins/index.js";
|
||||
import {
|
||||
getEntity,
|
||||
listEntities,
|
||||
onEntitiesChanged,
|
||||
type RegisteredSyncEntity,
|
||||
} from "../plugins/sync-registry.js";
|
||||
import {
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentPluginPermissionGrantRepository,
|
||||
createCurrentPluginRepository,
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentSessionRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { resolveBrandingSettings } from "../database/routes/branding-settings.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import {
|
||||
createResolvers,
|
||||
deleteStoredRow,
|
||||
listStoredRows,
|
||||
loadWireRow,
|
||||
loadWireRows,
|
||||
regenerateSyncIds,
|
||||
writeWireRow,
|
||||
type Resolvers,
|
||||
} from "./store.js";
|
||||
import {
|
||||
clearRecordErrors,
|
||||
deleteConflict,
|
||||
deleteRecordsForUser,
|
||||
getConflict,
|
||||
listChanges,
|
||||
listConflicts,
|
||||
listRecordErrors,
|
||||
listRecords,
|
||||
putRecord,
|
||||
recordKey,
|
||||
saveConflict,
|
||||
setRecordError,
|
||||
} from "./records.js";
|
||||
import { orderSelfReferences } from "./wire.js";
|
||||
import { onLocalWrite } from "./server/write-listeners.js";
|
||||
import {
|
||||
createLink,
|
||||
deleteLink,
|
||||
getLink,
|
||||
listLinks,
|
||||
updateLink,
|
||||
} from "./client/link-store.js";
|
||||
import {
|
||||
createAccount,
|
||||
getActiveUserId,
|
||||
listAccounts,
|
||||
removeAccount,
|
||||
setActiveUserId,
|
||||
} from "./accounts.js";
|
||||
import {
|
||||
normalizeServerUrl,
|
||||
RemoteError,
|
||||
remoteFetch,
|
||||
remoteJson,
|
||||
} from "./client/http.js";
|
||||
import { applyElectronProxyConfig } from "./client/electron-proxy.js";
|
||||
import { lastRemotePlugins, mirrorPlugins } from "./client/plugins.js";
|
||||
import { defaultDeviceName } from "./client/device.js";
|
||||
|
||||
function entityOf(type: string): RegisteredSyncEntity {
|
||||
const entity = getEntity(type);
|
||||
if (!entity) throw new Error(`Unknown sync entity "${type}"`);
|
||||
return entity;
|
||||
}
|
||||
|
||||
function info(entity: RegisteredSyncEntity) {
|
||||
return {
|
||||
type: entity.type,
|
||||
owner: entity.owner,
|
||||
order: entity.order,
|
||||
readOnly: !!entity.readOnly,
|
||||
covers: entity.covers,
|
||||
};
|
||||
}
|
||||
|
||||
/** Deletes every row that syncs, keeping local-only ones. */
|
||||
async function wipeSyncedData(userId: string): Promise<void> {
|
||||
const entities = [...listEntities()].reverse();
|
||||
for (const entity of entities) {
|
||||
if (entity.readOnly) continue;
|
||||
try {
|
||||
if (entity.load) {
|
||||
for (const row of await entity.load(userId)) {
|
||||
await entity.erase?.(userId, String(row.syncId));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entity.table || entity.singleton) continue;
|
||||
for (const row of await listStoredRows(entity, userId)) {
|
||||
if (entity.shouldSync && entity.shouldSync(row) === false) continue;
|
||||
if (typeof row.syncId !== "string") continue;
|
||||
await deleteStoredRow(entity, userId, row.syncId);
|
||||
}
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not clear synced data", {
|
||||
operation: "sync_wipe",
|
||||
entityType: entity.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes read-only copies of what other users shared with the account. */
|
||||
async function removeSharedCopies(userId: string): Promise<void> {
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
const sharedHosts = await db
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.userId, userId), isNotNull(hosts.sharedSource)));
|
||||
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
|
||||
for (const host of sharedHosts) await deleteOwnedHost(userId, host.id);
|
||||
|
||||
const sharedCredentials = await db
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
isNotNull(sshCredentials.sharedSource),
|
||||
),
|
||||
);
|
||||
const { deleteOwnedCredential } =
|
||||
await import("../hosts/delete-credential.js");
|
||||
for (const credential of sharedCredentials) {
|
||||
await deleteOwnedCredential(userId, credential.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCoreEntities(): Promise<void> {
|
||||
const { registerCoreSyncEntities } = await import("./entities.js");
|
||||
registerCoreSyncEntities();
|
||||
}
|
||||
|
||||
async function counts(userId: string): Promise<Record<string, number>> {
|
||||
await ensureCoreEntities();
|
||||
const result: Record<string, number> = {};
|
||||
for (const entity of listEntities()) {
|
||||
if (entity.readOnly) continue;
|
||||
try {
|
||||
if (entity.load) {
|
||||
result[entity.type] = (await entity.load(userId)).length;
|
||||
continue;
|
||||
}
|
||||
if (!entity.table) continue;
|
||||
result[entity.type] = (await listStoredRows(entity, userId)).filter(
|
||||
(row) => !entity.shouldSync || entity.shouldSync(row) !== false,
|
||||
).length;
|
||||
} catch {
|
||||
result[entity.type] = 0;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function listPluginsForDesktop() {
|
||||
const records = new Map(
|
||||
(await createCurrentPluginRepository().listAll()).map((record) => [
|
||||
record.id,
|
||||
record,
|
||||
]),
|
||||
);
|
||||
const grants = createCurrentPluginPermissionGrantRepository();
|
||||
const plugins = [];
|
||||
for (const plugin of getPluginRuntime().loader.list()) {
|
||||
plugins.push({
|
||||
id: plugin.id,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version,
|
||||
source: plugin.source,
|
||||
// A bundled plugin updated from a signed .tmxplug can be mirrored.
|
||||
artifact: !!plugin.artifact,
|
||||
enabled: records.get(plugin.id)?.state === "enabled",
|
||||
active: plugin.state === "active",
|
||||
desktop: resolvePluginDesktopMode(plugin.manifest),
|
||||
syncEntities: plugin.manifest.contributes?.syncEntities ?? [],
|
||||
granted: (await grants.listByPlugin(plugin.id)).map(
|
||||
(grant) => grant.capability,
|
||||
),
|
||||
});
|
||||
}
|
||||
const { customRegistryKeys } = await import("../plugins/registries.js");
|
||||
return { plugins, registryKeys: await customRegistryKeys() };
|
||||
}
|
||||
|
||||
async function sendPackage(
|
||||
pluginId: string,
|
||||
target: unknown,
|
||||
): Promise<boolean> {
|
||||
const res = target as Response;
|
||||
const plugin = getPluginRuntime()
|
||||
.loader.list()
|
||||
.find((candidate) => candidate.id === pluginId);
|
||||
if (!plugin || (plugin.source !== "user" && !plugin.artifact)) return false;
|
||||
const artifact = plugin.artifact ?? "";
|
||||
res.setHeader("Content-Type", "application/gzip");
|
||||
if (fs.existsSync(artifact)) {
|
||||
const sig = `${artifact}.sig`;
|
||||
if (fs.existsSync(sig)) {
|
||||
res.setHeader(
|
||||
"X-Termix-Signature",
|
||||
Buffer.from(await fs.promises.readFile(sig, "utf8")).toString("base64"),
|
||||
);
|
||||
}
|
||||
fs.createReadStream(artifact).pipe(res);
|
||||
return true;
|
||||
}
|
||||
const temp = path.join(
|
||||
os.tmpdir(),
|
||||
`termix-plugin-${pluginId}-${Date.now()}.tmxplug`,
|
||||
);
|
||||
await tar.c({ gzip: true, cwd: plugin.dir, file: temp }, ["."]);
|
||||
const stream = fs.createReadStream(temp);
|
||||
stream.on("close", () => void fs.promises.rm(temp, { force: true }));
|
||||
stream.pipe(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createSyncHost(): PluginSyncHost {
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
return {
|
||||
embedded: process.env.ELECTRON_EMBEDDED === "true",
|
||||
info: {
|
||||
appName: async () => {
|
||||
try {
|
||||
return (
|
||||
await resolveBrandingSettings(createCurrentSettingsRepository())
|
||||
).appName;
|
||||
} catch {
|
||||
return "Termix";
|
||||
}
|
||||
},
|
||||
version: () => getLocalVersion(),
|
||||
},
|
||||
|
||||
entities: {
|
||||
list: () => listEntities().map(info),
|
||||
get: (type) => {
|
||||
const entity = getEntity(type);
|
||||
return entity ? info(entity) : undefined;
|
||||
},
|
||||
ensureCore: () => ensureCoreEntities(),
|
||||
onChanged: (listener) => onEntitiesChanged(listener),
|
||||
onLocalWrite: (listener) => onLocalWrite(listener),
|
||||
},
|
||||
|
||||
replica: {
|
||||
resolvers: (userId, onMissing) => createResolvers(userId, onMissing),
|
||||
loadRows: async (type, userId, resolvers) => {
|
||||
const result = await loadWireRows(
|
||||
entityOf(type),
|
||||
userId,
|
||||
resolvers as Resolvers,
|
||||
);
|
||||
return { rows: result.rows, failed: result.failed };
|
||||
},
|
||||
loadRow: (type, userId, syncId, resolvers) =>
|
||||
loadWireRow(entityOf(type), userId, syncId, resolvers as Resolvers),
|
||||
writeRow: async (type, userId, wire, resolvers) => {
|
||||
await writeWireRow(
|
||||
entityOf(type),
|
||||
userId,
|
||||
wire,
|
||||
resolvers as Resolvers,
|
||||
);
|
||||
},
|
||||
deleteRow: async (type, userId, syncId) => {
|
||||
await deleteStoredRow(entityOf(type), userId, syncId);
|
||||
},
|
||||
orderSelfReferences: (type, rows) =>
|
||||
orderSelfReferences(entityOf(type), rows as SyncRow[]),
|
||||
counts: (userId) => counts(userId),
|
||||
wipe: (userId) => wipeSyncedData(userId),
|
||||
regenerateSyncIds: async (userId) => {
|
||||
for (const entity of listEntities()) {
|
||||
if (!entity.readOnly) await regenerateSyncIds(entity, userId);
|
||||
}
|
||||
},
|
||||
removeSharedCopies: (userId) => removeSharedCopies(userId),
|
||||
records: {
|
||||
list: (userId) => listRecords(userId) as never,
|
||||
put: (userId, type, syncId, values) =>
|
||||
putRecord(userId, type, syncId, values),
|
||||
setError: (userId, type, syncId, reason, hash) =>
|
||||
setRecordError(userId, type, syncId, reason, hash),
|
||||
clearErrors: (userId) => clearRecordErrors(userId),
|
||||
listErrors: (userId) => listRecordErrors(userId) as never,
|
||||
deleteForUser: (userId) => deleteRecordsForUser(userId),
|
||||
key: (type, syncId) => recordKey(type, syncId),
|
||||
},
|
||||
conflicts: {
|
||||
save: (userId, type, syncId, row, revision) =>
|
||||
saveConflict(userId, type, syncId, row, revision),
|
||||
list: (userId) => listConflicts(userId) as never,
|
||||
get: (userId, id) => getConflict(userId, id) as never,
|
||||
delete: (userId, id) => deleteConflict(userId, id),
|
||||
},
|
||||
},
|
||||
|
||||
feed: {
|
||||
withUserLock: async (userId, run) => {
|
||||
const { withUserLock } = await import("./server/feed.js");
|
||||
return withUserLock(userId, run);
|
||||
},
|
||||
changes: async (userId, cursor, limit, types) => {
|
||||
const { withUserLock, reconcileUser } =
|
||||
await import("./server/feed.js");
|
||||
const page = await withUserLock(userId, async () => {
|
||||
const { wire } = await reconcileUser(userId);
|
||||
const records = await listChanges(userId, cursor, limit + 1, types);
|
||||
return { wire, records };
|
||||
});
|
||||
const hasMore = page.records.length > limit;
|
||||
const records = [];
|
||||
for (const record of page.records.slice(0, limit)) {
|
||||
if (!getEntity(record.entityType)) continue;
|
||||
const row = record.deleted
|
||||
? null
|
||||
: (page.wire.get(recordKey(record.entityType, record.syncId)) ??
|
||||
null);
|
||||
// A live record with no row failed to serialize; it comes again once
|
||||
// it changes, rather than arriving as a delete.
|
||||
if (!record.deleted && !row) continue;
|
||||
records.push({
|
||||
seq: record.seq,
|
||||
entityType: record.entityType,
|
||||
syncId: record.syncId,
|
||||
revision: record.revision,
|
||||
deleted: !!record.deleted,
|
||||
row,
|
||||
});
|
||||
}
|
||||
const slice = page.records.slice(0, limit);
|
||||
return {
|
||||
records,
|
||||
hasMore,
|
||||
nextCursor: slice.length ? slice[slice.length - 1].seq : cursor,
|
||||
entityTypes: listEntities().map((entity) => ({
|
||||
type: entity.type,
|
||||
owner: entity.owner,
|
||||
order: entity.order,
|
||||
readOnly: !!entity.readOnly,
|
||||
})),
|
||||
} as never;
|
||||
},
|
||||
parsePushOps: async (body) => {
|
||||
const { parsePushOps } = await import("./server/push.js");
|
||||
return parsePushOps(body) as never;
|
||||
},
|
||||
applyPush: async (userId, ops) => {
|
||||
const { withUserLock } = await import("./server/feed.js");
|
||||
const { applyPush } = await import("./server/push.js");
|
||||
return withUserLock(userId, () =>
|
||||
applyPush(userId, ops as never),
|
||||
) as never;
|
||||
},
|
||||
listen: async (userId, res) => {
|
||||
const { addListener } = await import("./server/feed.js");
|
||||
return addListener(userId, res as Response);
|
||||
},
|
||||
},
|
||||
|
||||
links: {
|
||||
list: () => listLinks() as never,
|
||||
get: (userId) => getLink(userId) as never,
|
||||
create: (link) => createLink(link) as never,
|
||||
update: (patch, userId) => updateLink(patch, userId) as never,
|
||||
remove: (userId) => deleteLink(userId),
|
||||
},
|
||||
|
||||
accounts: {
|
||||
list: () => listAccounts(),
|
||||
create: () => createAccount(),
|
||||
setActive: (userId) => setActiveUserId(userId),
|
||||
activeId: () => getActiveUserId(),
|
||||
remove: (userId) => removeAccount(userId),
|
||||
},
|
||||
|
||||
http: {
|
||||
json: (target, pathname, request) =>
|
||||
remoteJson(target, pathname, request),
|
||||
fetch: ((target, pathname, request) =>
|
||||
remoteFetch(target, pathname, request)) as never,
|
||||
normalizeServerUrl,
|
||||
isRemoteError: ((error: unknown) =>
|
||||
error instanceof RemoteError) as never,
|
||||
},
|
||||
|
||||
sessions: {
|
||||
issueDesktop: (userId, options) =>
|
||||
authManager.generateJWTToken(userId, {
|
||||
expiresIn: `${options.days}d`,
|
||||
deviceType: "desktop",
|
||||
deviceInfo: options.deviceInfo,
|
||||
}),
|
||||
revoke: async (sessionId) => {
|
||||
await authManager.revokeSession(sessionId);
|
||||
},
|
||||
find: async (sessionId) => {
|
||||
const session =
|
||||
await createCurrentSessionRepository().findById(sessionId);
|
||||
return session
|
||||
? { expiresAt: session.expiresAt, deviceInfo: session.deviceInfo }
|
||||
: null;
|
||||
},
|
||||
findUser: async (userId) => {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
return user ? { id: user.id, username: user.username } : null;
|
||||
},
|
||||
verify: async (token) => {
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
return payload
|
||||
? { userId: payload.userId, sessionId: payload.sessionId }
|
||||
: null;
|
||||
},
|
||||
setCookie: (req, res, value, options) => {
|
||||
(res as Response).cookie(
|
||||
options?.name ?? "jwt",
|
||||
value,
|
||||
authManager.getSecureCookieOptions(
|
||||
req as Request,
|
||||
(options?.days ?? 30) * 24 * 60 * 60 * 1000,
|
||||
),
|
||||
);
|
||||
},
|
||||
clearCookie: (req, res, name) => {
|
||||
(res as Response).clearCookie(
|
||||
name ?? "jwt",
|
||||
authManager.getClearCookieOptions(req as Request),
|
||||
);
|
||||
},
|
||||
middleware: () => authManager.createAuthMiddleware(),
|
||||
desktopOnly: () => (_req: Request, res: Response, next: NextFunction) => {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
},
|
||||
},
|
||||
|
||||
hosts: {
|
||||
findForSync: async (userId, syncId) => {
|
||||
const hostId =
|
||||
await createCurrentHostResolutionRepository().findHostIdBySyncId(
|
||||
syncId,
|
||||
);
|
||||
if (hostId === null) return null;
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess) return null;
|
||||
const row = await createCurrentHostRepository().findById(hostId);
|
||||
return { id: hostId, name: row?.name ?? row?.ip ?? null };
|
||||
},
|
||||
statusBySyncId: async (userId) => {
|
||||
const { hostStatus } =
|
||||
await import("../plugins/host-status-provider.js");
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const statuses = await hostStatus.statusesFor(userId, null);
|
||||
const entries = [...statuses.entries()];
|
||||
const allowed =
|
||||
await PermissionManager.getInstance().filterAccessibleHostIds(
|
||||
userId,
|
||||
entries.map(([id]) => id),
|
||||
);
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [id, entry] of entries) {
|
||||
if (!allowed.has(id)) continue;
|
||||
const row = await hostRepository.findById(id);
|
||||
if (row?.syncId) result[row.syncId] = entry;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
|
||||
plugins: {
|
||||
listForDesktop: listPluginsForDesktop as never,
|
||||
sendPackage,
|
||||
mirror: (link) => mirrorPlugins(link as never),
|
||||
lastRemote: () => lastRemotePlugins() as never,
|
||||
localNames: () => {
|
||||
const names = new Map<string, string>();
|
||||
for (const plugin of getPluginRuntime().loader.list()) {
|
||||
names.set(plugin.id, plugin.manifest.name);
|
||||
}
|
||||
return names;
|
||||
},
|
||||
},
|
||||
|
||||
desktop: {
|
||||
applyProxy: (link) => applyElectronProxyConfig(link as never),
|
||||
deviceName: defaultDeviceName,
|
||||
},
|
||||
|
||||
alerts: {
|
||||
send: (alert) =>
|
||||
sendCoreAlert({
|
||||
title: alert.title,
|
||||
body: alert.body,
|
||||
severity: alert.severity,
|
||||
category: alert.category,
|
||||
dedupeKey: alert.dedupeKey,
|
||||
audience: "admins",
|
||||
link: alert.link,
|
||||
} as never),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* The sync provider: a plugin that answers /sync/v2 and /sync/link through
|
||||
* ctx.sync.provide. Core only forwards. With no provider the paths answer 404,
|
||||
* so a server without it looks like one that never had sync.
|
||||
*/
|
||||
|
||||
import type { NextFunction, Request, RequestHandler, Response } from "express";
|
||||
import type { PluginSyncProvider } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { syncLogger } from "../utils/logger.js";
|
||||
|
||||
interface Entry {
|
||||
pluginId: string;
|
||||
provider: PluginSyncProvider;
|
||||
}
|
||||
|
||||
const providers = new Map<symbol, Entry>();
|
||||
|
||||
export function provideSync(
|
||||
pluginId: string,
|
||||
provider: PluginSyncProvider,
|
||||
): () => void {
|
||||
const key = Symbol(pluginId);
|
||||
providers.set(key, { pluginId, provider });
|
||||
return () => {
|
||||
providers.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearSyncProviders(): void {
|
||||
providers.clear();
|
||||
}
|
||||
|
||||
export function hasSyncProvider(): boolean {
|
||||
return providers.size > 0;
|
||||
}
|
||||
|
||||
/** Sends the request to the first provider that does not pass. */
|
||||
export const syncForwarder: RequestHandler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const entries = [...providers.values()];
|
||||
const run = (index: number): void => {
|
||||
const entry = entries[index];
|
||||
if (!entry) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const pass = (error?: unknown): void => {
|
||||
if (error) next(error);
|
||||
else run(index + 1);
|
||||
};
|
||||
try {
|
||||
const result = entry.provider.handle(req, res, pass);
|
||||
if (result && typeof result.catch === "function") {
|
||||
result.catch((error: unknown) => fail(entry, error, res, next));
|
||||
}
|
||||
} catch (error) {
|
||||
fail(entry, error, res, next);
|
||||
}
|
||||
};
|
||||
run(0);
|
||||
};
|
||||
|
||||
function fail(
|
||||
entry: Entry,
|
||||
error: unknown,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
syncLogger.warn("Sync provider failed", {
|
||||
operation: "sync_provider",
|
||||
pluginId: entry.pluginId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (res.headersSent) next(error);
|
||||
else res.status(500).json({ error: "Sync is not available right now" });
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { markChanged } from "./feed.js";
|
||||
import { notifyLocalWrite } from "./write-listeners.js";
|
||||
|
||||
const READS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
const writeListeners = new Set<() => void>();
|
||||
|
||||
/** Called after every successful write request, for a linked desktop's engine. */
|
||||
export function onLocalWrite(listener: () => void): () => void {
|
||||
writeListeners.add(listener);
|
||||
return () => writeListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Any successful write may change what a linked desktop should have, so it
|
||||
@@ -23,7 +17,7 @@ export function syncChangeWatcher(
|
||||
res.on("finish", () => {
|
||||
if (res.statusCode >= 400) return;
|
||||
markChanged();
|
||||
for (const listener of writeListeners) listener();
|
||||
notifyLocalWrite();
|
||||
});
|
||||
}
|
||||
next();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const writeListeners = new Set<() => void>();
|
||||
|
||||
/** Called after every successful write request, for a linked desktop's engine. */
|
||||
export function onLocalWrite(listener: () => void): () => void {
|
||||
writeListeners.add(listener);
|
||||
return () => writeListeners.delete(listener);
|
||||
}
|
||||
|
||||
export function notifyLocalWrite(): void {
|
||||
for (const listener of writeListeners) listener();
|
||||
}
|
||||
@@ -136,6 +136,22 @@ describe("isNativeTokenExportRequest", () => {
|
||||
});
|
||||
|
||||
describe("resolveDesktopAutoSessionUser", () => {
|
||||
it("returns the account the user switched to, while it exists", () => {
|
||||
const admin = makeUser({ id: "admin", isAdmin: true });
|
||||
const chosen = makeUser({ id: "chosen" });
|
||||
expect(resolveDesktopAutoSessionUser([admin, chosen], "chosen")).toBe(
|
||||
chosen,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the usual pick when the chosen account is gone", () => {
|
||||
const admin = makeUser({ id: "admin", isAdmin: true });
|
||||
const other = makeUser({ id: "other" });
|
||||
expect(resolveDesktopAutoSessionUser([admin, other], "deleted")).toBe(
|
||||
admin,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the sole local user regardless of having a real password", () => {
|
||||
const user = makeUser({ passwordHash: "$2a$10$realbcryptvaluehere" });
|
||||
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
users: [] as Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
registeredAt: string;
|
||||
}>,
|
||||
saved: null as string | null,
|
||||
create: vi.fn(),
|
||||
assignRole: vi.fn(),
|
||||
registerUser: vi.fn(),
|
||||
deleteUserAndRelatedData: vi.fn(),
|
||||
set: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
listAll: async () => mocks.users,
|
||||
findById: async (id: string) =>
|
||||
mocks.users.find((u) => u.id === id) ?? null,
|
||||
create: mocks.create,
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({
|
||||
assignRoleNameToUser: mocks.assignRole,
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async () => mocks.saved,
|
||||
set: async (key: string, value: string) => {
|
||||
mocks.set(key, value);
|
||||
mocks.saved = value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: { getInstance: () => ({ registerUser: mocks.registerUser }) },
|
||||
}));
|
||||
vi.mock("../../database/routes/delete-user-data.js", () => ({
|
||||
deleteUserAndRelatedData: mocks.deleteUserAndRelatedData,
|
||||
}));
|
||||
|
||||
const {
|
||||
createAccount,
|
||||
getActiveUserId,
|
||||
listAccounts,
|
||||
removeAccount,
|
||||
setActiveUserId,
|
||||
} = await import("../../sync/accounts.js");
|
||||
|
||||
function user(
|
||||
id: string,
|
||||
isAdmin = false,
|
||||
registeredAt = "2026-01-01T00:00:00Z",
|
||||
) {
|
||||
return { id, username: id, isAdmin, registeredAt };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.saved = null;
|
||||
mocks.users = [
|
||||
user("local", true),
|
||||
user("work", false, "2026-02-01T00:00:00Z"),
|
||||
];
|
||||
});
|
||||
|
||||
describe("desktop accounts", () => {
|
||||
it("signs in as the usual user until one is chosen", async () => {
|
||||
expect(await getActiveUserId()).toBe("local");
|
||||
await setActiveUserId("work");
|
||||
expect(await getActiveUserId()).toBe("work");
|
||||
});
|
||||
|
||||
it("will not switch to an account that does not exist", async () => {
|
||||
await expect(setActiveUserId("nope")).rejects.toThrow("No such account");
|
||||
expect(mocks.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists the accounts with the active one marked", async () => {
|
||||
await setActiveUserId("work");
|
||||
expect(await listAccounts()).toEqual([
|
||||
expect.objectContaining({ id: "local", active: false }),
|
||||
expect.objectContaining({ id: "work", active: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("makes an empty admin account with its own data key", async () => {
|
||||
const id = await createAccount();
|
||||
expect(mocks.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id, isAdmin: true, passwordHash: "" }),
|
||||
);
|
||||
expect(mocks.assignRole).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: id, roleName: "admin" }),
|
||||
);
|
||||
expect(mocks.registerUser).toHaveBeenCalledWith(id, expect.any(String));
|
||||
});
|
||||
|
||||
it("removes an account and its data", async () => {
|
||||
await removeAccount("work");
|
||||
expect(mocks.deleteUserAndRelatedData).toHaveBeenCalledWith("work");
|
||||
});
|
||||
|
||||
it("moves to another account when the active one is removed", async () => {
|
||||
await setActiveUserId("work");
|
||||
mocks.set.mockClear();
|
||||
await removeAccount("work");
|
||||
expect(mocks.set).toHaveBeenCalledWith("desktop_active_user", "local");
|
||||
});
|
||||
|
||||
it("keeps the last account", async () => {
|
||||
mocks.users = [user("local", true)];
|
||||
await expect(removeAccount("local")).rejects.toThrow("last account");
|
||||
expect(mocks.deleteUserAndRelatedData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -21,8 +21,9 @@ vi.mock("../../../utils/system-crypto.js", () => ({
|
||||
|
||||
const { TestSqliteDatabase } =
|
||||
await import("../../database/repositories/test-support.js");
|
||||
const { createLink, deleteLink, getLink, updateLink } =
|
||||
const { createLink, deleteLink, getLink, listLinks, updateLink } =
|
||||
await import("../../../sync/client/link-store.js");
|
||||
const { setActiveUserId } = await import("../../../sync/accounts.js");
|
||||
|
||||
let db: InstanceType<typeof TestSqliteDatabase>;
|
||||
|
||||
@@ -96,3 +97,79 @@ describe("the desktop's link", () => {
|
||||
expect(await updateLink({ cursor: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("several links", () => {
|
||||
beforeEach(async () => {
|
||||
await db.run(sql`UPDATE users SET is_admin = 1 WHERE id = 'local'`);
|
||||
await db.run(
|
||||
sql`INSERT INTO users (id, username, password_hash) VALUES ('second', 'second', '')`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps one link per local user", async () => {
|
||||
await createLink({
|
||||
userId: "local",
|
||||
serverUrl: "https://a",
|
||||
sessionToken: "a",
|
||||
});
|
||||
await createLink({
|
||||
userId: "second",
|
||||
serverUrl: "https://b",
|
||||
sessionToken: "b",
|
||||
});
|
||||
expect((await listLinks()).map((link) => link.serverUrl).sort()).toEqual([
|
||||
"https://a",
|
||||
"https://b",
|
||||
]);
|
||||
expect((await getLink("second"))?.serverUrl).toBe("https://b");
|
||||
expect((await getLink("local"))?.serverUrl).toBe("https://a");
|
||||
});
|
||||
|
||||
it("answers for the active account when no user is given", async () => {
|
||||
await createLink({
|
||||
userId: "local",
|
||||
serverUrl: "https://a",
|
||||
sessionToken: "a",
|
||||
});
|
||||
await createLink({
|
||||
userId: "second",
|
||||
serverUrl: "https://b",
|
||||
sessionToken: "b",
|
||||
});
|
||||
expect((await getLink())?.serverUrl).toBe("https://a");
|
||||
await setActiveUserId("second");
|
||||
expect((await getLink())?.serverUrl).toBe("https://b");
|
||||
});
|
||||
|
||||
it("changes and removes only the link it is asked about", async () => {
|
||||
await createLink({
|
||||
userId: "local",
|
||||
serverUrl: "https://a",
|
||||
sessionToken: "a",
|
||||
});
|
||||
await createLink({
|
||||
userId: "second",
|
||||
serverUrl: "https://b",
|
||||
sessionToken: "b",
|
||||
});
|
||||
await updateLink({ cursor: 9, status: "offline" }, "second");
|
||||
expect((await getLink("local"))?.cursor).toBe(0);
|
||||
expect((await getLink("second"))?.cursor).toBe(9);
|
||||
await deleteLink("second");
|
||||
expect(await getLink("second")).toBeNull();
|
||||
expect((await getLink("local"))?.serverUrl).toBe("https://a");
|
||||
});
|
||||
|
||||
it("stores connection options without touching the type switches", async () => {
|
||||
await createLink({
|
||||
userId: "local",
|
||||
serverUrl: "https://a",
|
||||
sessionToken: "a",
|
||||
});
|
||||
await updateLink({ disabledTypes: ["hosts"] }, "local");
|
||||
await updateLink({ options: { clientCertificate: "work" } }, "local");
|
||||
const link = await getLink("local");
|
||||
expect(link?.options).toEqual({ clientCertificate: "work" });
|
||||
expect(link?.disabledTypes).toEqual(["hosts"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listChanges: vi.fn(),
|
||||
findHostIdBySyncId: vi.fn(),
|
||||
canAccessHost: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
getLink: vi.fn(),
|
||||
generateJWTToken: vi.fn(),
|
||||
revokeSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware: () => vi.fn(),
|
||||
generateJWTToken: mocks.generateJWTToken,
|
||||
revokeSession: mocks.revokeSession,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
syncLogger: { error: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../utils/app-version.js", () => ({
|
||||
getLocalVersion: () => "26.10.3",
|
||||
}));
|
||||
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() }));
|
||||
vi.mock("../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
|
||||
vi.mock("../../plugins/sync-registry.js", () => ({
|
||||
getEntity: vi.fn(),
|
||||
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
|
||||
onEntitiesChanged: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostRepository: () => ({ findById: mocks.findById }),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostIdBySyncId: mocks.findHostIdBySyncId,
|
||||
}),
|
||||
createCurrentPluginPermissionGrantRepository: vi.fn(),
|
||||
createCurrentPluginRepository: vi.fn(),
|
||||
createCurrentRepositoryContext: vi.fn(),
|
||||
createCurrentSessionRepository: vi.fn(),
|
||||
createCurrentSettingsRepository: vi.fn(),
|
||||
createCurrentUserRepository: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../database/routes/branding-settings.js", () => ({
|
||||
resolveBrandingSettings: async () => ({ appName: "Termix" }),
|
||||
}));
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({ canAccessHost: mocks.canAccessHost }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../sync/entities.js", () => ({
|
||||
registerCoreSyncEntities: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../sync/store.js", () => ({}));
|
||||
vi.mock("../../sync/wire.js", () => ({}));
|
||||
vi.mock("../../sync/records.js", () => ({
|
||||
listChanges: mocks.listChanges,
|
||||
recordKey: (type: string, id: string) => `${type}\u0000${id}`,
|
||||
}));
|
||||
vi.mock("../../sync/server/write-listeners.js", () => ({
|
||||
onLocalWrite: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../sync/accounts.js", () => ({
|
||||
createAccount: vi.fn(),
|
||||
getActiveUserId: vi.fn(),
|
||||
listAccounts: vi.fn(),
|
||||
removeAccount: vi.fn(),
|
||||
setActiveUserId: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../sync/server/feed.js", () => ({
|
||||
withUserLock: (_userId: string, fn: () => unknown) => fn(),
|
||||
reconcileUser: async () => ({ wire: new Map() }),
|
||||
addListener: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../sync/server/push.js", () => ({}));
|
||||
vi.mock("../../sync/client/link-store.js", () => ({
|
||||
getLink: mocks.getLink,
|
||||
listLinks: vi.fn(),
|
||||
createLink: vi.fn(),
|
||||
updateLink: vi.fn(),
|
||||
deleteLink: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../sync/client/http.js", () => ({
|
||||
normalizeServerUrl: vi.fn(),
|
||||
RemoteError: class RemoteError extends Error {},
|
||||
remoteFetch: vi.fn(),
|
||||
remoteJson: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../sync/client/electron-proxy.js", () => ({
|
||||
applyElectronProxyConfig: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../sync/client/plugins.js", () => ({
|
||||
lastRemotePlugins: () => [],
|
||||
mirrorPlugins: vi.fn(),
|
||||
}));
|
||||
|
||||
const { createSyncHost } = await import("../../sync/host.js");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.listChanges.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("host.feed.changes", () => {
|
||||
it("answers an empty page with the entity types past the newest change", async () => {
|
||||
const cursor = Number.MAX_SAFE_INTEGER;
|
||||
const page = await createSyncHost().feed.changes("user-1", cursor, 1000);
|
||||
expect(page).toEqual({
|
||||
records: [],
|
||||
hasMore: false,
|
||||
nextCursor: cursor,
|
||||
entityTypes: [
|
||||
{ type: "hosts", owner: "core", order: 10, readOnly: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("asks for one more row than the limit to know if more remain", async () => {
|
||||
await createSyncHost().feed.changes("user-1", 0, 1000);
|
||||
expect(mocks.listChanges).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
0,
|
||||
1001,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves out records of types this server does not run", async () => {
|
||||
mocks.listChanges.mockResolvedValue([
|
||||
{ seq: 5, entityType: "gone", syncId: "a", revision: 1, deleted: false },
|
||||
]);
|
||||
const page = await createSyncHost().feed.changes("user-1", 0, 10);
|
||||
expect(page.records).toEqual([]);
|
||||
expect(page.nextCursor).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host.hosts.findForSync", () => {
|
||||
beforeEach(() => {
|
||||
mocks.findHostIdBySyncId.mockResolvedValue(41);
|
||||
mocks.canAccessHost.mockImplementation(async (_user, _host, action) => ({
|
||||
hasAccess: action === "connect",
|
||||
}));
|
||||
mocks.findById.mockResolvedValue({ name: "Shared host", ip: "10.0.0.1" });
|
||||
});
|
||||
|
||||
it("resolves the server ID for a connect only recipient", async () => {
|
||||
const found = await createSyncHost().hosts.findForSync(
|
||||
"recipient",
|
||||
"host-sync-id",
|
||||
);
|
||||
expect(mocks.canAccessHost).toHaveBeenCalledWith(
|
||||
"recipient",
|
||||
41,
|
||||
"connect",
|
||||
);
|
||||
expect(found).toEqual({ id: 41, name: "Shared host" });
|
||||
});
|
||||
|
||||
it("does not disclose a host the caller cannot connect to", async () => {
|
||||
mocks.canAccessHost.mockResolvedValue({ hasAccess: false });
|
||||
expect(await createSyncHost().hosts.findForSync("u", "s")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a missing sync ID", async () => {
|
||||
mocks.findHostIdBySyncId.mockResolvedValue(null);
|
||||
expect(await createSyncHost().hosts.findForSync("u", "s")).toBeNull();
|
||||
expect(mocks.canAccessHost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("host.sessions", () => {
|
||||
it("issues a long desktop session that shows in the sessions list", async () => {
|
||||
mocks.generateJWTToken.mockResolvedValue("token");
|
||||
const token = await createSyncHost().sessions.issueDesktop("u1", {
|
||||
days: 365,
|
||||
deviceInfo: "Laptop (sync)",
|
||||
});
|
||||
expect(token).toBe("token");
|
||||
expect(mocks.generateJWTToken).toHaveBeenCalledWith("u1", {
|
||||
expiresIn: "365d",
|
||||
deviceType: "desktop",
|
||||
deviceInfo: "Laptop (sync)",
|
||||
});
|
||||
});
|
||||
|
||||
it("answers 404 outside the desktop app", () => {
|
||||
const previous = process.env.ELECTRON_EMBEDDED;
|
||||
delete process.env.ELECTRON_EMBEDDED;
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
|
||||
const next = vi.fn();
|
||||
(
|
||||
createSyncHost().sessions.desktopOnly() as (
|
||||
req: unknown,
|
||||
res: unknown,
|
||||
next: () => void,
|
||||
) => void
|
||||
)({}, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
if (previous !== undefined) process.env.ELECTRON_EMBEDDED = previous;
|
||||
});
|
||||
});
|
||||
|
||||
describe("host.links", () => {
|
||||
it("reads the linked server registry", async () => {
|
||||
mocks.getLink.mockResolvedValue({ id: 1, serverUrl: "https://a.example" });
|
||||
expect(await createSyncHost().links.get()).toEqual({
|
||||
id: 1,
|
||||
serverUrl: "https://a.example",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
syncLogger: { warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const { clearSyncProviders, hasSyncProvider, provideSync, syncForwarder } =
|
||||
await import("../../sync/provider.js");
|
||||
|
||||
function response() {
|
||||
const res = {
|
||||
headersSent: false,
|
||||
status: vi.fn(() => res),
|
||||
json: vi.fn(() => res),
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
beforeEach(() => clearSyncProviders());
|
||||
|
||||
describe("sync forwarder", () => {
|
||||
it("passes through when nothing provides sync", () => {
|
||||
const next = vi.fn();
|
||||
syncForwarder({} as never, response() as never, next);
|
||||
expect(hasSyncProvider()).toBe(false);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("hands the request to the provider", () => {
|
||||
const handle = vi.fn();
|
||||
provideSync("a", { handle });
|
||||
const next = vi.fn();
|
||||
syncForwarder({} as never, response() as never, next);
|
||||
expect(handle).toHaveBeenCalledOnce();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves on when the provider calls next", () => {
|
||||
provideSync("a", { handle: (_req, _res, pass) => pass() });
|
||||
const second = vi.fn();
|
||||
provideSync("b", { handle: second });
|
||||
syncForwarder({} as never, response() as never, vi.fn());
|
||||
expect(second).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("answers 500 when the provider throws", async () => {
|
||||
provideSync("a", {
|
||||
handle: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
const res = response();
|
||||
syncForwarder({} as never, res as never, vi.fn());
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
});
|
||||
|
||||
it("stops forwarding after the provider is removed", () => {
|
||||
const remove = provideSync("a", { handle: vi.fn() });
|
||||
remove();
|
||||
const next = vi.fn();
|
||||
syncForwarder({} as never, response() as never, next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
@@ -48,8 +48,8 @@ export const runPluginDataMigrations = DatabaseSaveTrigger.batched(
|
||||
*/
|
||||
async function applyHostDefaults(): Promise<void> {
|
||||
try {
|
||||
const { getLink } = await import("../sync/client/link-store.js");
|
||||
if (await getLink().catch(() => null)) return;
|
||||
const { listLinks } = await import("../sync/client/link-store.js");
|
||||
if ((await listLinks().catch(() => [])).length > 0) return;
|
||||
const { recomputeEverything } =
|
||||
await import("../hosts/defaults/recompute.js");
|
||||
recomputeEverything("plugins started");
|
||||
|
||||
@@ -1151,6 +1151,10 @@
|
||||
"title": "Set sign in rules",
|
||||
"consequence": "It can sign people out when idle, limit session length, turn off remember me and require a second factor."
|
||||
},
|
||||
"sync:provide": {
|
||||
"title": "Run sync",
|
||||
"consequence": "It can answer all sync requests, so it sees the data that devices and servers sync."
|
||||
},
|
||||
"system:tls": {
|
||||
"title": "Manage certificates",
|
||||
"consequence": "It can replace the certificate Termix serves over HTTPS and answer domain checks from certificate authorities."
|
||||
|
||||
Reference in new issue
Block a user