feat(plugins): publish host.created when a host is added (#1668)

Plugins that keep an inventory of hosts had no way to hear about a new
one short of polling. Core now publishes host.created with the same
{ hostId, userId } payload as host.updated wherever a host row is
created: the host create route, ctx.hosts.create, JSON and SSH config
bulk import, database file import, and sync writing a host it did not
have yet.
This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-10-17 19:03:08 -05:00
1 parent 893e7e5a4b
commit 16f2f509f2
12 files changed
+185 -6

No files matched your search

+4
View File
@@ -38,6 +38,7 @@ import { attachPluginWebSockets } from "../plugins/ws.js";
import pluginRoutes from "./routes/plugins.js"; import pluginRoutes from "./routes/plugins.js";
import { createPluginAssetsRouter } from "../plugins/assets.js"; import { createPluginAssetsRouter } from "../plugins/assets.js";
import { getPluginRuntime } from "../plugins/index.js"; import { getPluginRuntime } from "../plugins/index.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { createCorsMiddleware } from "../utils/cors-config.js"; import { createCorsMiddleware } from "../utils/cors-config.js";
import { createCompressionMiddleware } from "../utils/compression-config.js"; import { createCompressionMiddleware } from "../utils/compression-config.js";
import fs from "fs"; import fs from "fs";
@@ -1388,6 +1389,9 @@ app.post(
} }
} }
await applyDefaultsAfterHostWrites(importedHostIds); await applyDefaultsAfterHostWrites(importedHostIds);
for (const hostId of importedHostIds) {
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
}
} catch { } catch {
apiLogger.info("ssh_data table not found in import file, skipping"); apiLogger.info("ssh_data table not found in import file, skipping");
} }
@@ -140,7 +140,14 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
* Tells status checks and other pollers about hosts an import wrote, the way * Tells status checks and other pollers about hosts an import wrote, the way
* a single host create or update does. * a single host create or update does.
*/ */
export function notifyImportedHosts(userId: string, hostIds: number[]): void { export function notifyImportedHosts(
userId: string,
hostIds: number[],
createdHostIds: number[],
): void {
for (const hostId of createdHostIds) {
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
}
for (const hostId of hostIds) { for (const hostId of hostIds) {
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId }); pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
} }
@@ -588,6 +595,7 @@ export function registerHostBulkRoutes(
const knownAuthTypes = listKnownAuthTypes(); const knownAuthTypes = listKnownAuthTypes();
const writtenHostIds: number[] = []; const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (const { host: hostData, index: i, exportId } of orderedHosts) { for (const { host: hostData, index: i, exportId } of orderedHosts) {
try { try {
const effectiveConnectionType = hostData.connectionType || "ssh"; const effectiveConnectionType = hostData.connectionType || "ssh";
@@ -797,6 +805,7 @@ export function registerHostBulkRoutes(
); );
if (exportId !== undefined) importedIds.set(exportId, saved.id); if (exportId !== undefined) importedIds.set(exportId, saved.id);
savedHostId = saved.id; savedHostId = saved.id;
createdHostIds.push(saved.id);
results.success++; results.success++;
} }
@@ -828,7 +837,7 @@ export function registerHostBulkRoutes(
} }
await applyDefaultsAfterHostWrites(writtenHostIds); await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds); notifyImportedHosts(userId, writtenHostIds, createdHostIds);
res.json({ res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`, message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
@@ -948,6 +957,7 @@ export function registerHostBulkRoutes(
} }
const writtenHostIds: number[] = []; const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (let i = 0; i < hostsToImport.length; i++) { for (let i = 0; i < hostsToImport.length; i++) {
const hostData = normalizeImportedHost( const hostData = normalizeImportedHost(
hostsToImport[i] as Record<string, unknown>, hostsToImport[i] as Record<string, unknown>,
@@ -1023,6 +1033,7 @@ export function registerHostBulkRoutes(
sshDataObj, sshDataObj,
); );
writtenHostIds.push(saved.id); writtenHostIds.push(saved.id);
createdHostIds.push(saved.id);
results.success++; results.success++;
} }
} catch (error) { } catch (error) {
@@ -1034,7 +1045,7 @@ export function registerHostBulkRoutes(
} }
await applyDefaultsAfterHostWrites(writtenHostIds); await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds); notifyImportedHosts(userId, writtenHostIds, createdHostIds);
res.json({ res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`, message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
+1
View File
@@ -486,6 +486,7 @@ router.post(
}); });
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost))); res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
pluginEvents.emit(TOPICS.hostCreated, { hostId: createdHost.id, userId });
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create"); notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
} catch (err) { } catch (err) {
sshLogger.error("Failed to save SSH host to database", err, { sshLogger.error("Failed to save SSH host to database", err, {
+2
View File
@@ -30,6 +30,7 @@ import { hostStatusService } from "../hosts/status/host-status-service.js";
import { parseSshOptions } from "../hosts/ssh-options.js"; import { parseSshOptions } from "../hosts/ssh-options.js";
import { hostTerminalExport } from "../database/routes/host-normalizers.js"; import { hostTerminalExport } from "../database/routes/host-normalizers.js";
import type { DisposableBag } from "./disposables.js"; import type { DisposableBag } from "./disposables.js";
import { pluginEvents, TOPICS } from "./events.js";
import { pluginLogger } from "../utils/logger.js"; import { pluginLogger } from "../utils/logger.js";
type AuditFn = ( type AuditFn = (
@@ -502,6 +503,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
await writeSettings(created.id, writes); await writeSettings(created.id, writes);
await markSettingsOwn(created.id, writes); await markSettingsOwn(created.id, writes);
await audit("hosts_create", `host ${created.id}`, { success: true }); await audit("hosts_create", `host ${created.id}`, { success: true });
pluginEvents.emit(TOPICS.hostCreated, { hostId: created.id, userId });
return ( return (
await toRecords([created as unknown as Record<string, unknown>]) await toRecords([created as unknown as Record<string, unknown>])
)[0]; )[0];
+2
View File
@@ -26,6 +26,8 @@ export const TOPICS = {
hostStatus: "host.status", hostStatus: "host.status",
/** A host's key was accepted again after it changed. */ /** A host's key was accepted again after it changed. */
hostKeyUpdated: "host.key.updated", hostKeyUpdated: "host.key.updated",
/** A host was added and anything inventorying hosts should pick it up. */
hostCreated: "host.created",
/** A host's connection details changed and pollers should re-read them. */ /** A host's connection details changed and pollers should re-read them. */
hostUpdated: "host.updated", hostUpdated: "host.updated",
/** A host was deleted and any poller holding it should drop it. */ /** A host was deleted and any poller holding it should drop it. */
+5 -1
View File
@@ -22,6 +22,7 @@ import {
import { FieldCrypto } from "../utils/field-crypto.js"; import { FieldCrypto } from "../utils/field-crypto.js";
import { syncLogger } from "../utils/logger.js"; import { syncLogger } from "../utils/logger.js";
import { getPluginRuntime } from "../plugins/index.js"; import { getPluginRuntime } from "../plugins/index.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { singletonSyncId } from "./wire.js"; import { singletonSyncId } from "./wire.js";
import { parseDefaultOverrides } from "../../types/host-defaults.js"; import { parseDefaultOverrides } from "../../types/host-defaults.js";
import { import {
@@ -174,7 +175,7 @@ export function registerCoreSyncEntities(): void {
) )
: {}, : {},
}), }),
afterWrite: async ({ id, userId, wire, resolveId }) => { afterWrite: async ({ id, userId, wire, created, resolveId }) => {
if (id === null) return; if (id === null) return;
// The server owns host defaults: it keeps what the host set itself // The server owns host defaults: it keeps what the host set itself
// and resolves the rest. A desktop takes the server's values as sent. // and resolves the rest. A desktop takes the server's values as sent.
@@ -195,6 +196,9 @@ export function registerCoreSyncEntities(): void {
const { SharedHostSecretsManager } = const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js"); await import("../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().resyncHost(id); await SharedHostSecretsManager.getInstance().resyncHost(id);
if (created) {
pluginEvents.emit(TOPICS.hostCreated, { hostId: id, userId });
}
}, },
remove: async (row, userId) => { remove: async (row, userId) => {
const { deleteOwnedHost } = await import("../hosts/delete-host.js"); const { deleteOwnedHost } = await import("../hosts/delete-host.js");
@@ -48,6 +48,7 @@ function exportFile(): string {
async function invoke(path: string, user: unknown = { isExternal: false }) { async function invoke(path: string, user: unknown = { isExternal: false }) {
const writes: string[] = []; const writes: string[] = [];
const events: Array<[string, unknown]> = [];
const hosts: Array<Record<string, unknown>> = []; const hosts: Array<Record<string, unknown>> = [];
const importHostProtocolLogins = vi.fn(); const importHostProtocolLogins = vi.fn();
const handler = runInNewContext( const handler = runInNewContext(
@@ -98,6 +99,11 @@ async function invoke(path: string, user: unknown = { isExternal: false }) {
importHostDefaults: async () => ({ imported: 0, skipped: 0 }), importHostDefaults: async () => ({ imported: 0, skipped: 0 }),
DatabaseSaveTrigger: { forceSave: async () => {} }, DatabaseSaveTrigger: { forceSave: async () => {} },
getErrorMessage: (error: Error) => error.message, getErrorMessage: (error: Error) => error.message,
pluginEvents: {
emit: (topic: string, payload: unknown) =>
events.push([topic, payload]),
},
TOPICS: { hostCreated: "host.created" },
}, },
); );
const res = { json: vi.fn(), status: vi.fn() }; const res = { json: vi.fn(), status: vi.fn() };
@@ -106,7 +112,7 @@ async function invoke(path: string, user: unknown = { isExternal: false }) {
{ userId: "user-1", file: { path, originalname: "export.db" } }, { userId: "user-1", file: { path, originalname: "export.db" } },
res, res,
); );
return { res, writes, hosts, importHostProtocolLogins }; return { res, writes, hosts, events, importHostProtocolLogins };
} }
it("points imported hosts at the credentials imported with them", async () => { it("points imported hosts at the credentials imported with them", async () => {
@@ -122,6 +128,17 @@ it("points imported hosts at the credentials imported with them", async () => {
]); ]);
}); });
it("publishes host.created once for each imported host", async () => {
const { events } = await invoke(exportFile());
expect(events).toEqual(
[501, 502, 503, 504].map((hostId) => [
"host.created",
{ hostId, userId: "user-1" },
]),
);
});
it("removes the uploaded file when the user is gone", async () => { it("removes the uploaded file when the user is gone", async () => {
const path = exportFile(); const path = exportFile();
const { res } = await invoke(path, null); const { res } = await invoke(path, null);
@@ -143,7 +143,7 @@ describe("notifyImportedHosts", () => {
seen.push(payload); seen.push(payload);
}); });
try { try {
notifyImportedHosts("user-1", [4, 7]); notifyImportedHosts("user-1", [4, 7], []);
} finally { } finally {
off(); off();
} }
@@ -84,6 +84,7 @@ const { hosts, sshCredentials } =
const { saveLevel } = await import("../../../hosts/defaults/service.js"); const { saveLevel } = await import("../../../hosts/defaults/service.js");
const { default: hostRouter } = const { default: hostRouter } =
await import("../../../database/routes/host.js"); await import("../../../database/routes/host.js");
const { pluginEvents, TOPICS } = await import("../../../plugins/events.js");
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
@@ -192,3 +193,24 @@ describe("host update authType", () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
}); });
describe("host.created", () => {
it("is published once when a host is created, and not on update", async () => {
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
try {
const created = await request(app)
.post("/host/db/host")
.send({ ...base, authType: "password", password: "pw" });
expect(created.status).toBe(200);
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, name: "renamed" });
expect(seen).toEqual([{ hostId: created.body.id, userId: USER }]);
} finally {
off();
}
});
});
@@ -37,11 +37,14 @@ vi.mock("../../../database/repositories/factory.js", () => ({
})); }));
import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js"; import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js";
import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload"; import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload";
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
let handler: RequestHandler; let handler: RequestHandler;
let sshConfigHandler: RequestHandler;
const router = { const router = {
post: (path: string, ...handlers: RequestHandler[]) => { post: (path: string, ...handlers: RequestHandler[]) => {
if (path === "/bulk-import") handler = handlers.at(-1)!; if (path === "/bulk-import") handler = handlers.at(-1)!;
if (path === "/ssh-config-import") sshConfigHandler = handlers.at(-1)!;
}, },
put: () => {}, put: () => {},
patch: () => {}, patch: () => {},
@@ -202,3 +205,53 @@ it("does not save dependent hosts when importing their jump host fails", async (
); );
expect(mocks.create).toHaveBeenCalledTimes(1); expect(mocks.create).toHaveBeenCalledTimes(1);
}); });
function collectCreated() {
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
return { seen, off };
}
it("publishes host.created once for each host a JSON import creates", async () => {
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
mocks.create.mockResolvedValueOnce({ id: 501 });
const { seen, off } = collectCreated();
try {
await importHosts([host, { ...host, ip: "192.0.2.2" }], true);
} finally {
off();
}
// The overwritten host already existed, so only the new one is announced.
expect(seen).toEqual([{ hostId: 501, userId: "user-1" }]);
});
it("publishes host.created once for each host an SSH config import creates", async () => {
mocks.create
.mockResolvedValueOnce({ id: 601 })
.mockResolvedValueOnce({ id: 602 });
const { seen, off } = collectCreated();
try {
await sshConfigHandler(
{
userId: "user-1",
body: {
content:
"Host a\n HostName 10.0.0.1\n User root\nHost b\n HostName 10.0.0.2\n User root\n",
},
} as unknown as Request,
{
json: vi.fn(),
status: vi.fn().mockReturnThis(),
} as unknown as Response,
vi.fn(),
);
} finally {
off();
}
expect(seen).toEqual([
{ hostId: 601, userId: "user-1" },
{ hostId: 602, userId: "user-1" },
]);
});
@@ -207,6 +207,7 @@ vi.mock("../../hosts/delete-host.js", () => ({
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js"); const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
const { PluginCapabilityError } = const { PluginCapabilityError } =
await import("@termix-ssh/plugin-sdk/backend"); await import("@termix-ssh/plugin-sdk/backend");
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
function manifest(capabilities: string[]) { function manifest(capabilities: string[]) {
return { return {
@@ -436,6 +437,29 @@ describe("ctx.hosts", () => {
]); ]);
}); });
it("publishes host.created for a host it creates", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
try {
await hosts.create({
ip: "10.0.0.5",
port: 22,
username: "root",
authType: "password",
});
} finally {
off();
}
expect(seen).toEqual([{ hostId: 99, userId: "user-1" }]);
});
it("refuses a field the SDK does not take instead of dropping it", async () => { it("refuses a field the SDK does not take instead of dropping it", async () => {
h.granted = new Set(["hosts:write"]); h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({ const hosts = createPluginHosts({
+39
View File
@@ -163,6 +163,7 @@ const { createResolvers, writeWireRow } = await import("../../sync/store.js");
const { getEntity } = await import("../../plugins/sync-registry.js"); const { getEntity } = await import("../../plugins/sync-registry.js");
const { FieldCrypto } = await import("../../utils/field-crypto.js"); const { FieldCrypto } = await import("../../utils/field-crypto.js");
const { syncLogger } = await import("../../utils/logger.js"); const { syncLogger } = await import("../../utils/logger.js");
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
const app = express(); const app = express();
app.use(express.json({ limit: "5mb" })); app.use(express.json({ limit: "5mb" }));
@@ -338,6 +339,44 @@ describe("linking a desktop", () => {
}); });
}); });
describe("a synced host", () => {
it("publishes host.created when it is written for the first time only", async () => {
const [desk] = desktops;
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
const wire = {
syncId: "host-new",
name: "new",
ip: "10.0.0.2",
port: 22,
username: "root",
authType: "password",
};
try {
await on(desk, async () => {
const resolvers = createResolvers(desk.userId);
const first = await writeWireRow(
getEntity("hosts")!,
desk.userId,
wire,
resolvers,
);
await writeWireRow(
getEntity("hosts")!,
desk.userId,
{ ...wire, name: "renamed" },
resolvers,
);
expect(seen).toEqual([{ hostId: first.id, userId: desk.userId }]);
});
} finally {
off();
}
});
});
describe("after linking", () => { describe("after linking", () => {
it("sends edits and deletes both ways", async () => { it("sends edits and deletes both ways", async () => {
const [desk] = desktops; const [desk] = desktops;