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
|
- 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
|
- 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
|
- 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
|
### 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
|
- 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
|
- 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
|
- 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
|
# 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
|
## 1.0.15
|
||||||
|
|
||||||
Plugin API 1.7.0.
|
Plugin API 1.7.0.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Termix plugin guide
|
# 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
|
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.
|
`@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` | `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.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 |
|
| `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 |
|
| `users.getLocale` | `users:read` | A user's UI language |
|
||||||
| `system` | `system:tls` | The server's HTTPS certificate |
|
| `system` | `system:tls` | The server's HTTPS certificate |
|
||||||
| `plugins.list()` | `plugins:read` | Which plugins are installed |
|
| `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
|
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"`.
|
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
|
## Testing
|
||||||
|
|
||||||
```ts
|
```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) |
|
| `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:provide` | high | Add login methods, second factors, SSH auth types |
|
||||||
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
|
| `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 |
|
| `device:serial` | high | Open serial and USB ports on the server |
|
||||||
| `desktop:local` | high | Use the user's own computer, desktop app only |
|
| `desktop:local` | high | Use the user's own computer, desktop app only |
|
||||||
| `hosts:export` | high | Export hosts with their saved logins |
|
| `hosts:export` | high | Export hosts with their saved logins |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"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.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -152,6 +152,7 @@
|
|||||||
"users:write",
|
"users:write",
|
||||||
"auth:provide",
|
"auth:provide",
|
||||||
"auth:policy",
|
"auth:policy",
|
||||||
|
"sync:provide",
|
||||||
"system:tls",
|
"system:tls",
|
||||||
"device:serial",
|
"device:serial",
|
||||||
"hosts:write",
|
"hosts:write",
|
||||||
|
|||||||
@@ -236,6 +236,429 @@ export interface SyncEntityRegistration {
|
|||||||
*/
|
*/
|
||||||
export interface PluginSync {
|
export interface PluginSync {
|
||||||
registerEntity: (entity: SyncEntityRegistration) => void;
|
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("db:core-refs", "high"),
|
||||||
entry("auth:provide", "high"),
|
entry("auth:provide", "high"),
|
||||||
entry("auth:policy", "high"),
|
entry("auth:policy", "high"),
|
||||||
|
entry("sync:provide", "high"),
|
||||||
entry("device:serial", "high"),
|
entry("device:serial", "high"),
|
||||||
entry("notify:hub", "high"),
|
entry("notify:hub", "high"),
|
||||||
entry("ui:layout", "high"),
|
entry("ui:layout", "high"),
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export {
|
|||||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||||
* version by the server.
|
* 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. */
|
/** The API major, the default engine.api for a new plugin. */
|
||||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ import type {
|
|||||||
PluginSshKeyCredential,
|
PluginSshKeyCredential,
|
||||||
PluginSshKeyCredentialInput,
|
PluginSshKeyCredentialInput,
|
||||||
} from "./backend.js";
|
} from "./backend.js";
|
||||||
import type { SyncEntityRegistration } from "./backend.js";
|
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
|
||||||
import type { PluginDatabase } from "./backend.js";
|
import type { PluginDatabase } from "./backend.js";
|
||||||
import type {
|
import type {
|
||||||
PluginExternalClientRequest,
|
PluginExternalClientRequest,
|
||||||
@@ -104,6 +104,8 @@ export interface FakeScheduledJob {
|
|||||||
|
|
||||||
export interface FakeContextOptions {
|
export interface FakeContextOptions {
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
|
/** What ctx.sync.host answers, for a sync provider's tests. */
|
||||||
|
syncHost?: Partial<PluginSyncHost>;
|
||||||
manifest?: Partial<PluginManifest>;
|
manifest?: Partial<PluginManifest>;
|
||||||
/** Acting user returned by currentActor and used by asUser. */
|
/** Acting user returned by currentActor and used by asUser. */
|
||||||
actor?: string;
|
actor?: string;
|
||||||
@@ -756,6 +758,12 @@ export function createFakeContext(
|
|||||||
registerEntity: (entity) => {
|
registerEntity: (entity) => {
|
||||||
syncEntities.push(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: {
|
registry: {
|
||||||
@@ -1582,6 +1590,8 @@ export function createFakeContext(
|
|||||||
|
|
||||||
export interface MockContextOptions {
|
export interface MockContextOptions {
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
|
/** What ctx.sync.host answers, for a sync provider's tests. */
|
||||||
|
syncHost?: Partial<PluginSyncHost>;
|
||||||
manifest?: Partial<PluginManifest>;
|
manifest?: Partial<PluginManifest>;
|
||||||
/** Capabilities the plugin is granted. Anything else throws. */
|
/** Capabilities the plugin is granted. Anything else throws. */
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
@@ -1744,6 +1754,7 @@ export function createMockCtx(
|
|||||||
|
|
||||||
const base = createFakeContext({
|
const base = createFakeContext({
|
||||||
pluginId: options.pluginId,
|
pluginId: options.pluginId,
|
||||||
|
syncHost: options.syncHost,
|
||||||
actor: options.actor,
|
actor: options.actor,
|
||||||
settings: options.settings,
|
settings: options.settings,
|
||||||
coreSettings: options.coreSettings,
|
coreSettings: options.coreSettings,
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
|||||||
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
||||||
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||||
import syncRoutes from "../sync/server/routes.js";
|
import { syncForwarder } from "../sync/provider.js";
|
||||||
import syncLinkRoutes from "../sync/client/routes.js";
|
|
||||||
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
|
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
|
||||||
import dashboardRoutes from "./routes/dashboard-routes.js";
|
import dashboardRoutes from "./routes/dashboard-routes.js";
|
||||||
import {
|
import {
|
||||||
@@ -1625,8 +1624,7 @@ app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
|||||||
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||||
app.use("/ui-preferences", uiPreferencesRoutes);
|
app.use("/ui-preferences", uiPreferencesRoutes);
|
||||||
registerAuditLogRoutes(app, authenticateJWT);
|
registerAuditLogRoutes(app, authenticateJWT);
|
||||||
app.use("/sync", syncRoutes);
|
app.use("/sync", syncForwarder);
|
||||||
app.use("/sync", syncLinkRoutes);
|
|
||||||
app.use("/dashboard", dashboardRoutes);
|
app.use("/dashboard", dashboardRoutes);
|
||||||
app.use("/plugins", pluginRoutes);
|
app.use("/plugins", pluginRoutes);
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -61,15 +61,23 @@ export function isNativeTokenExportRequest(req: Request): boolean {
|
|||||||
* during testing, or a household sharing one install) -- a user in that
|
* during testing, or a household sharing one install) -- a user in that
|
||||||
* state deserves to get into the app they installed, not a confusing,
|
* state deserves to get into the app they installed, not a confusing,
|
||||||
* unexplained dead end. So this always returns a single, deterministic
|
* unexplained dead end. So this always returns a single, deterministic
|
||||||
* user: the admin account if one exists, else the earliest-registered
|
* user: the account the user switched to if it still exists, else the admin
|
||||||
* account. It never returns null.
|
* account if one exists, else the earliest-registered account. It never
|
||||||
|
* returns null when there are users.
|
||||||
*/
|
*/
|
||||||
export function resolveDesktopAutoSessionUser(
|
export function resolveDesktopAutoSessionUser(
|
||||||
allUsers: UserRecord[],
|
allUsers: UserRecord[],
|
||||||
|
preferredId?: string | null,
|
||||||
): UserRecord | null {
|
): UserRecord | null {
|
||||||
if (allUsers.length === 0) return null;
|
if (allUsers.length === 0) return null;
|
||||||
if (allUsers.length === 1) return allUsers[0];
|
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);
|
const admin = allUsers.find((user) => user.isAdmin);
|
||||||
if (admin) return admin;
|
if (admin) return admin;
|
||||||
|
|
||||||
|
|||||||
@@ -576,8 +576,8 @@ async function describeDesktopLink(userId: string) {
|
|||||||
if (process.env.ELECTRON_EMBEDDED !== "true") return null;
|
if (process.env.ELECTRON_EMBEDDED !== "true") return null;
|
||||||
try {
|
try {
|
||||||
const { getLink } = await import("../../sync/client/link-store.js");
|
const { getLink } = await import("../../sync/client/link-store.js");
|
||||||
const link = await getLink();
|
const link = await getLink(userId);
|
||||||
if (!link || link.userId !== userId) return null;
|
if (!link) return null;
|
||||||
return {
|
return {
|
||||||
serverUrl: link.serverUrl,
|
serverUrl: link.serverUrl,
|
||||||
serverName: link.serverName,
|
serverName: link.serverName,
|
||||||
@@ -743,7 +743,11 @@ router.post("/internal/auto-session", async (req, res) => {
|
|||||||
|
|
||||||
const userRepository = createCurrentUserRepository();
|
const userRepository = createCurrentUserRepository();
|
||||||
const allUsers = await userRepository.listAll();
|
const allUsers = await userRepository.listAll();
|
||||||
const userRecord = resolveDesktopAutoSessionUser(allUsers);
|
const { getSavedActiveUserId } = await import("../../sync/accounts.js");
|
||||||
|
const userRecord = resolveDesktopAutoSessionUser(
|
||||||
|
allUsers,
|
||||||
|
await getSavedActiveUserId(),
|
||||||
|
);
|
||||||
if (!userRecord) {
|
if (!userRecord) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: "No local users exist",
|
error: "No local users exist",
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ import {
|
|||||||
} from "@termix-ssh/plugin-sdk/backend";
|
} from "@termix-ssh/plugin-sdk/backend";
|
||||||
import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db";
|
import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db";
|
||||||
import * as syncRegistry from "./sync-registry.js";
|
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 {
|
import {
|
||||||
needsExplicitPersist,
|
needsExplicitPersist,
|
||||||
resolveDatabaseDialect,
|
resolveDatabaseDialect,
|
||||||
@@ -189,6 +192,34 @@ export function createPluginContext(
|
|||||||
handle: PluginHandle,
|
handle: PluginHandle,
|
||||||
): PluginContext {
|
): PluginContext {
|
||||||
const pluginId = manifest.id;
|
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 = (
|
const auditCall = (
|
||||||
action: string,
|
action: string,
|
||||||
details: string,
|
details: string,
|
||||||
@@ -520,12 +551,7 @@ export function createPluginContext(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
sync: {
|
sync: syncApi,
|
||||||
registerEntity: (entity) => {
|
|
||||||
const dispose = syncRegistry.registerEntity(pluginId, entity);
|
|
||||||
handle.bag.add(dispose, `sync entity ${entity.type}`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
kv: {
|
kv: {
|
||||||
get: (key) => kvGet(key),
|
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") {
|
if (process.env.ELECTRON_EMBEDDED === "true") {
|
||||||
try {
|
try {
|
||||||
const { startDesktopSync } = await import("./sync/client/engine.js");
|
|
||||||
startDesktopSync();
|
|
||||||
const { startDesktopUpdateCheck } =
|
const { startDesktopUpdateCheck } =
|
||||||
await import("./updates/desktop-update-check.js");
|
await import("./updates/desktop-update-check.js");
|
||||||
startDesktopUpdateCheck();
|
startDesktopUpdateCheck();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
systemLogger.warn("Desktop sync failed to start", {
|
systemLogger.warn("Desktop update check failed to start", {
|
||||||
operation: "sync_start",
|
operation: "update_check_start",
|
||||||
error: getErrorMessage(error),
|
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
|
* The desktop's links to servers, one row each in sync_link and at most one
|
||||||
* token, proxy headers, basic auth) are stored encrypted with the system key.
|
* 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 { eq } from "drizzle-orm";
|
||||||
|
import { getActiveUserId } from "../accounts.js";
|
||||||
import { syncLink } from "../../database/db/schema.js";
|
import { syncLink } from "../../database/db/schema.js";
|
||||||
import { createCurrentRepositoryContext } from "../../database/repositories/factory.js";
|
import { createCurrentRepositoryContext } from "../../database/repositories/factory.js";
|
||||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||||
@@ -48,6 +51,8 @@ export interface SyncLink {
|
|||||||
account: LinkAccount | null;
|
account: LinkAccount | null;
|
||||||
/** Entity types switched off on this device. */
|
/** Entity types switched off on this device. */
|
||||||
disabledTypes: string[];
|
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. */
|
/** Entity types this device has fully pulled at least once. */
|
||||||
knownTypes: string[];
|
knownTypes: string[];
|
||||||
cursor: number;
|
cursor: number;
|
||||||
@@ -90,7 +95,10 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
|||||||
sessionToken = null;
|
sessionToken = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const scope = parseJson<{ disabled?: string[] }>(row.scope, {});
|
const scope = parseJson<{
|
||||||
|
disabled?: string[];
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
}>(row.scope, {});
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
@@ -105,6 +113,8 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
|||||||
remoteUsername: row.remoteUsername,
|
remoteUsername: row.remoteUsername,
|
||||||
account: parseJson<LinkAccount | null>(row.account, null),
|
account: parseJson<LinkAccount | null>(row.account, null),
|
||||||
disabledTypes: Array.isArray(scope.disabled) ? scope.disabled : [],
|
disabledTypes: Array.isArray(scope.disabled) ? scope.disabled : [],
|
||||||
|
options:
|
||||||
|
scope.options && typeof scope.options === "object" ? scope.options : {},
|
||||||
knownTypes: parseJson<string[]>(row.knownTypes, []),
|
knownTypes: parseJson<string[]>(row.knownTypes, []),
|
||||||
cursor: Number(row.cursor) || 0,
|
cursor: Number(row.cursor) || 0,
|
||||||
status: (row.status as LinkStatus) || "idle",
|
status: (row.status as LinkStatus) || "idle",
|
||||||
@@ -114,8 +124,20 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLink(): Promise<SyncLink | null> {
|
export async function listLinks(): Promise<SyncLink[]> {
|
||||||
const [row] = await db().select().from(syncLink).limit(1);
|
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;
|
return row ? fromRow(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +155,7 @@ export interface NewLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createLink(link: NewLink): Promise<SyncLink> {
|
export async function createLink(link: NewLink): Promise<SyncLink> {
|
||||||
await db().delete(syncLink);
|
await db().delete(syncLink).where(eq(syncLink.userId, link.userId));
|
||||||
await db()
|
await db()
|
||||||
.insert(syncLink)
|
.insert(syncLink)
|
||||||
.values({
|
.values({
|
||||||
@@ -157,7 +179,7 @@ export async function createLink(link: NewLink): Promise<SyncLink> {
|
|||||||
linkedAt: new Date().toISOString(),
|
linkedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
await DatabaseSaveTrigger.forceSave("sync_link");
|
await DatabaseSaveTrigger.forceSave("sync_link");
|
||||||
return (await getLink())!;
|
return (await getLink(link.userId))!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LinkPatch {
|
export interface LinkPatch {
|
||||||
@@ -171,6 +193,7 @@ export interface LinkPatch {
|
|||||||
remoteUsername?: string | null;
|
remoteUsername?: string | null;
|
||||||
account?: LinkAccount | null;
|
account?: LinkAccount | null;
|
||||||
disabledTypes?: string[];
|
disabledTypes?: string[];
|
||||||
|
options?: Record<string, unknown>;
|
||||||
knownTypes?: string[];
|
knownTypes?: string[];
|
||||||
cursor?: number;
|
cursor?: number;
|
||||||
status?: LinkStatus;
|
status?: LinkStatus;
|
||||||
@@ -178,8 +201,11 @@ export interface LinkPatch {
|
|||||||
lastSyncAt?: string | null;
|
lastSyncAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
|
export async function updateLink(
|
||||||
const current = await getLink();
|
patch: LinkPatch,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<SyncLink | null> {
|
||||||
|
const current = await getLink(userId);
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
const set: Partial<Row> = {};
|
const set: Partial<Row> = {};
|
||||||
if (patch.serverName !== undefined) set.serverName = patch.serverName;
|
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) {
|
if (patch.account !== undefined) {
|
||||||
set.account = patch.account ? JSON.stringify(patch.account) : null;
|
set.account = patch.account ? JSON.stringify(patch.account) : null;
|
||||||
}
|
}
|
||||||
if (patch.disabledTypes !== undefined) {
|
if (patch.disabledTypes !== undefined || patch.options !== undefined) {
|
||||||
set.scope = JSON.stringify({ disabled: patch.disabledTypes });
|
set.scope = JSON.stringify({
|
||||||
|
disabled: patch.disabledTypes ?? current.disabledTypes,
|
||||||
|
options: patch.options ?? current.options,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (patch.knownTypes !== undefined) {
|
if (patch.knownTypes !== undefined) {
|
||||||
set.knownTypes = JSON.stringify(patch.knownTypes);
|
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));
|
await db().update(syncLink).set(set).where(eq(syncLink.id, current.id));
|
||||||
DatabaseSaveTrigger.triggerSave("sync_link");
|
DatabaseSaveTrigger.triggerSave("sync_link");
|
||||||
}
|
}
|
||||||
return getLink();
|
return getLink(current.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteLink(): Promise<void> {
|
export async function deleteLink(userId?: string): Promise<void> {
|
||||||
await db().delete(syncLink);
|
const owner = userId ?? (await getActiveUserId());
|
||||||
|
if (!owner) return;
|
||||||
|
await db().delete(syncLink).where(eq(syncLink.userId, owner));
|
||||||
await DatabaseSaveTrigger.forceSave("sync_link");
|
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 type { NextFunction, Request, Response } from "express";
|
||||||
import { markChanged } from "./feed.js";
|
import { markChanged } from "./feed.js";
|
||||||
|
import { notifyLocalWrite } from "./write-listeners.js";
|
||||||
|
|
||||||
const READS = new Set(["GET", "HEAD", "OPTIONS"]);
|
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
|
* Any successful write may change what a linked desktop should have, so it
|
||||||
@@ -23,7 +17,7 @@ export function syncChangeWatcher(
|
|||||||
res.on("finish", () => {
|
res.on("finish", () => {
|
||||||
if (res.statusCode >= 400) return;
|
if (res.statusCode >= 400) return;
|
||||||
markChanged();
|
markChanged();
|
||||||
for (const listener of writeListeners) listener();
|
notifyLocalWrite();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
next();
|
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", () => {
|
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", () => {
|
it("returns the sole local user regardless of having a real password", () => {
|
||||||
const user = makeUser({ passwordHash: "$2a$10$realbcryptvaluehere" });
|
const user = makeUser({ passwordHash: "$2a$10$realbcryptvaluehere" });
|
||||||
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
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 } =
|
const { TestSqliteDatabase } =
|
||||||
await import("../../database/repositories/test-support.js");
|
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");
|
await import("../../../sync/client/link-store.js");
|
||||||
|
const { setActiveUserId } = await import("../../../sync/accounts.js");
|
||||||
|
|
||||||
let db: InstanceType<typeof TestSqliteDatabase>;
|
let db: InstanceType<typeof TestSqliteDatabase>;
|
||||||
|
|
||||||
@@ -96,3 +97,79 @@ describe("the desktop's link", () => {
|
|||||||
expect(await updateLink({ cursor: 1 })).toBeNull();
|
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> {
|
async function applyHostDefaults(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { getLink } = await import("../sync/client/link-store.js");
|
const { listLinks } = await import("../sync/client/link-store.js");
|
||||||
if (await getLink().catch(() => null)) return;
|
if ((await listLinks().catch(() => [])).length > 0) return;
|
||||||
const { recomputeEverything } =
|
const { recomputeEverything } =
|
||||||
await import("../hosts/defaults/recompute.js");
|
await import("../hosts/defaults/recompute.js");
|
||||||
recomputeEverything("plugins started");
|
recomputeEverything("plugins started");
|
||||||
|
|||||||
@@ -1151,6 +1151,10 @@
|
|||||||
"title": "Set sign in rules",
|
"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."
|
"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": {
|
"system:tls": {
|
||||||
"title": "Manage certificates",
|
"title": "Manage certificates",
|
||||||
"consequence": "It can replace the certificate Termix serves over HTTPS and answer domain checks from certificate authorities."
|
"consequence": "It can replace the certificate Termix serves over HTTPS and answer domain checks from certificate authorities."
|
||||||
|
|||||||
Reference in new issue
Block a user