feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox

This commit is contained in:
LukeGus committed 2026-09-23 13:06:06 -05:00
1 parent f9283ec83f
commit 218c5088e7
20 files changed
+194 -1329

No files matched your search

+48 -23
View File
@@ -1029,29 +1029,29 @@ deciding, not the mechanism.
Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| Member | Capability | Status |
| ------------------------------------------------ | ------------------------------------ | ------ |
| `ctx.pluginId`, `ctx.manifest` | none | **A1** |
| `ctx.log.*` | none | **A1** |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
| `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` | per-service RBAC | **A1** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9** |
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
| `ctx.notify.*` | `notify:send` | A6 |
| `ctx.auth.*` | `auth:provide` | **A8** |
| `ctx.fetch` | `network:outbound` | B |
| Member | Capability | Status |
| ------------------------------------------------ | ------------------------------------ | ----------------------- |
| `ctx.pluginId`, `ctx.manifest` | none | **A1** |
| `ctx.log.*` | none | **A1** |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
| `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` | per-service RBAC | **A1** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9** |
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, extended **B5** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
| `ctx.notify.*` | `notify:send` | A6 |
| `ctx.auth.*` | `auth:provide` | **A8** |
| `ctx.fetch` | `network:outbound` | B |
`ctx.ssh` was pulled forward from B so plugin transports go through core's
connect pipeline instead of importing ssh2 helpers from core:
@@ -1100,6 +1100,31 @@ that needs to actually connect uses `ctx.ssh.connect(hostId)` or
`withConnection(hostId, ...)`, which resolve the full host (secrets included)
through the one connect pipeline. fleets never holds a `PluginSshHost` itself.
**B5** added `create`, `update` and `listOwned`, for a plugin that creates or
maintains hosts on the user's behalf rather than only sharing access to
existing ones (proxmox's discovery and sync flow is the first caller):
- `create(host)` inserts a host owned by the acting user, encrypted the same
way the host editor's own create route does. Takes `PluginHostCreateInput`
(name, ip, port, username, authType required; connection flags, tags,
folder, jumpHosts and the rest optional) and returns the decrypted
`PluginHostRecord`. Needs `hosts:write`.
- `update(hostId, patch)` updates a host the acting user owns, refusing one it
does not (sharing a write onto someone else's host goes through `share()`,
never `update()`). Returns `null` for a host that does not exist or is not
owned by the caller. Needs `hosts:write`.
- `listOwned()` returns every host the acting user owns as full
`PluginHostRecord`s (credentialId, jumpHosts and the rest `list()`'s
narrower `PluginHostSummary` leaves out), for a plugin scanning its own
hosts in the background, such as proxmox's auto-sync sweep. Needs
`hosts:write`, matching `create`/`update`: this is the same "full host
detail" surface those calls need to read back, not a wider read grant than
`hosts:read` gives through `list`/`get`.
`PluginHostRecord` carries no secret auth material (password, key, vault
token): a plugin that creates a host picks an `authType` and, for
`"credential"`, a `credentialId` it does not need to see the contents of.
### The actor
Never comes from plugin code. It is held in `AsyncLocalStorage` and set two
+17
View File
@@ -23,3 +23,20 @@ it's done.
is the SDK copy fleets now uses; core's copy was left in place rather than
turned into a re-export, since host-metrics's own conversion step is where
its imports should move wholesale. Owner: host-metrics's Phase B step.
- **B5 (proxmox):** `useConnectionRetry`, `runAdaptivePolling` and `cn` are
duplicated in `plugins/proxmox/src/frontend/stats/` rather than shared with
core's `src/ui/lib/` originals, matching the same "add to the SDK once a
second plugin needs it" call B3 made for `ManagerCardShell`. host-metrics's
own conversion needs the same two hooks for its stats tab, so promote both
into `@termix/plugin-sdk/frontend` (and `cn` into `@termix/plugin-sdk/ui`)
the next time either is touched. Owner: D1, or host-metrics's Phase B step
if it lands first.
- **B5 (proxmox):** `runDueProxmoxAutoSyncs` in
`plugins/proxmox/src/backend/routes.ts` still reaches
`createCurrentPluginSettingsRepository`/`createCurrentHostRepository` by
relative import instead of `ctx`, because it needs to scan every host's
Proxmox settings across every user before it knows which actor to run each
sync as, and neither `ctx.settings` nor `ctx.hosts` expose a cross-user
bulk read today. A `ctx.settings.listHostsWithKey` or similar SDK addition
would close this, if a second plugin's background scan ever needs the same
shape. Owner: D1.
+74
View File
@@ -409,6 +409,56 @@ export interface PluginHostSummary {
authType: string;
}
/**
* A host record wide enough for a plugin that creates or updates hosts on the
* user's behalf (import, discovery, sync), decrypted for that user. Unlike
* PluginHostSummary this carries the fields a plugin needs to set up a
* connectable host, not just what a list view shows. Still no secret auth
* material (password, key, vault tokens): a plugin that creates a host picks
* an authType and, for "credential", a credentialId it does not need to see
* the contents of.
*/
export interface PluginHostRecord {
id: number;
userId: string;
name: string | null;
ip: string;
port: number;
username: string;
authType: string;
credentialId?: number | null;
overrideCredentialUsername?: boolean | null;
connectionType?: string | null;
tags: string | null;
folder: string | null;
jumpHosts?: unknown;
enableSsh?: boolean | null;
enableRdp?: boolean | null;
enableTerminal?: boolean | null;
enableFileManager?: boolean | null;
enableTunnel?: boolean | null;
enableDocker?: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
[key: string]: unknown;
}
/** Fields a plugin may set when creating a host it will own or manage. */
export type PluginHostCreateInput = Partial<
Omit<PluginHostRecord, "id" | "userId">
> & {
name: string;
ip: string;
port: number;
username: string;
authType: string;
};
/** Fields a plugin may change on a host it already created or was granted access to. */
export type PluginHostUpdateInput = Partial<
Omit<PluginHostRecord, "id" | "userId">
>;
/** The same shape canAccessHost returns internally, without secrets. */
export interface PluginHostAccess {
hasAccess: boolean;
@@ -462,6 +512,30 @@ export interface PluginHosts {
hostId: number,
level: PluginHostShareLevel,
) => Promise<PluginHostAccess>;
/**
* Creates a host owned by the acting user, encrypted the same way the host
* editor's own create route does it. Needs hosts:write. For a plugin that
* imports or discovers hosts on the user's behalf (B5's proxmox discovery).
*/
create: (host: PluginHostCreateInput) => Promise<PluginHostRecord>;
/**
* Updates a host the acting user owns. Refuses a host it does not own
* (sharing a write onto someone else's host goes through share(), not
* update()). Needs hosts:write.
*/
update: (
hostId: number,
patch: PluginHostUpdateInput,
) => Promise<PluginHostRecord | null>;
/**
* Every host the acting user owns, decrypted and wide (PluginHostRecord,
* not the narrow list() summary). For a plugin that scans its own hosts in
* the background (auto-sync) and needs fields list() does not carry, such
* as credentialId or jumpHosts. Needs hosts:write, matching create/update:
* this is the same "full host detail" surface a create/update caller needs
* to read back, not a wider read grant than hosts:read gives via list/get.
*/
listOwned: () => Promise<PluginHostRecord[]>;
/**
* Grants access to a host the caller manages, snapshotting shared secrets
* for each target the same way the host editor's own share action does.
+3
View File
@@ -20,6 +20,7 @@ export type PluginColumnType =
| "varchar"
| "integer"
| "bigint"
| "real"
| "boolean"
| "timestamp"
| "json"
@@ -77,6 +78,8 @@ export const varchar = (length: number = KEY_LENGTH) =>
export const integer = () => builder({ type: "integer" });
export const bigint = () => builder({ type: "bigint" });
/** Floating point. A plain number on every engine. Never indexable. */
export const real = () => builder({ type: "real" });
export const boolean = () => builder({ type: "boolean" });
/** Stored as text on every engine. See src/backend/database/repositories/sql-timestamp.ts. */
+3
View File
@@ -62,6 +62,9 @@ function columnType(
return dialect === "mysql" ? "int" : "integer";
case "bigint":
return dialect === "sqlite" ? "integer" : "bigint";
case "real":
if (dialect === "sqlite") return "real";
return dialect === "mysql" ? "double" : "double precision";
case "boolean":
return dialect === "sqlite" ? "integer" : "boolean";
case "varchar":
+4
View File
@@ -12,6 +12,7 @@ import {
sqliteTable,
text as sqliteText,
integer as sqliteInteger,
real as sqliteReal,
index as sqliteIndex,
uniqueIndex as sqliteUniqueIndex,
type AnySQLiteColumn,
@@ -55,6 +56,9 @@ export function buildTable(
case "refHost":
built = sqliteInteger(name);
break;
case "real":
built = sqliteReal(name);
break;
case "boolean":
built = sqliteInteger(name, { mode: "boolean" });
break;
+44
View File
@@ -24,6 +24,9 @@ import type {
PluginSshAuthProvider,
PluginSshHost,
PluginHostSummary,
PluginHostRecord,
PluginHostCreateInput,
PluginHostUpdateInput,
PluginHostAccess,
PluginHostShareResult,
PluginShareableUser,
@@ -174,6 +177,8 @@ export function createFakeContext(
const hostsById = new Map<number, PluginHostSummary>(
(options.hosts ?? []).map((h) => [h.id, h]),
);
const hostRecordsById = new Map<number, PluginHostRecord>();
let nextHostId = Math.max(0, ...(options.hosts ?? []).map((h) => h.id)) + 1;
const services = new Map<string, object>();
const auth: FakeAuthRegistrations = {
sshAuthProviders: [],
@@ -421,6 +426,33 @@ export function createFakeContext(
permissionLevel: "manage",
};
},
create: async (
host: PluginHostCreateInput,
): Promise<PluginHostRecord> => {
const id = nextHostId++;
const record: PluginHostRecord = {
tags: null,
folder: null,
...host,
id,
userId: actor ?? "unknown",
};
hostRecordsById.set(id, record);
return record;
},
update: async (
hostId: number,
patch: PluginHostUpdateInput,
): Promise<PluginHostRecord | null> => {
const existing = hostRecordsById.get(hostId);
if (!existing) return null;
const updated = { ...existing, ...patch };
hostRecordsById.set(hostId, updated);
return updated;
},
listOwned: async (): Promise<PluginHostRecord[]> => [
...hostRecordsById.values(),
],
share: async (
hostId,
targets,
@@ -706,6 +738,18 @@ export function createMockCtx(
require("hosts:read");
return ctx.hosts.checkAccess(hostId, level);
},
create: async (host) => {
require("hosts:write");
return ctx.hosts.create(host);
},
update: async (hostId, patch) => {
require("hosts:write");
return ctx.hosts.update(hostId, patch);
},
listOwned: async () => {
require("hosts:write");
return ctx.hosts.listOwned();
},
share: async (hostId, targets, permissionLevel, durationHours) => {
require("hosts:write");
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);