diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index e14612201..c93ae8ea1 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -38,6 +38,7 @@ import { attachPluginWebSockets } from "../plugins/ws.js"; import pluginRoutes from "./routes/plugins.js"; import { createPluginAssetsRouter } from "../plugins/assets.js"; import { getPluginRuntime } from "../plugins/index.js"; +import { pluginEvents, TOPICS } from "../plugins/events.js"; import { createCorsMiddleware } from "../utils/cors-config.js"; import { createCompressionMiddleware } from "../utils/compression-config.js"; import fs from "fs"; @@ -1388,6 +1389,9 @@ app.post( } } await applyDefaultsAfterHostWrites(importedHostIds); + for (const hostId of importedHostIds) { + pluginEvents.emit(TOPICS.hostCreated, { hostId, userId }); + } } catch { apiLogger.info("ssh_data table not found in import file, skipping"); } diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index 38f99652c..9886cc906 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -140,7 +140,14 @@ export function parseSSHConfig(content: string): SSHConfigHost[] { * Tells status checks and other pollers about hosts an import wrote, the way * 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) { pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId }); } @@ -588,6 +595,7 @@ export function registerHostBulkRoutes( const knownAuthTypes = listKnownAuthTypes(); const writtenHostIds: number[] = []; + const createdHostIds: number[] = []; for (const { host: hostData, index: i, exportId } of orderedHosts) { try { const effectiveConnectionType = hostData.connectionType || "ssh"; @@ -797,6 +805,7 @@ export function registerHostBulkRoutes( ); if (exportId !== undefined) importedIds.set(exportId, saved.id); savedHostId = saved.id; + createdHostIds.push(saved.id); results.success++; } @@ -828,7 +837,7 @@ export function registerHostBulkRoutes( } await applyDefaultsAfterHostWrites(writtenHostIds); - notifyImportedHosts(userId, writtenHostIds); + notifyImportedHosts(userId, writtenHostIds, createdHostIds); res.json({ message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`, @@ -948,6 +957,7 @@ export function registerHostBulkRoutes( } const writtenHostIds: number[] = []; + const createdHostIds: number[] = []; for (let i = 0; i < hostsToImport.length; i++) { const hostData = normalizeImportedHost( hostsToImport[i] as Record, @@ -1023,6 +1033,7 @@ export function registerHostBulkRoutes( sshDataObj, ); writtenHostIds.push(saved.id); + createdHostIds.push(saved.id); results.success++; } } catch (error) { @@ -1034,7 +1045,7 @@ export function registerHostBulkRoutes( } await applyDefaultsAfterHostWrites(writtenHostIds); - notifyImportedHosts(userId, writtenHostIds); + notifyImportedHosts(userId, writtenHostIds, createdHostIds); res.json({ message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`, diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 63f472617..4f6001b82 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -486,6 +486,7 @@ router.post( }); res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost))); + pluginEvents.emit(TOPICS.hostCreated, { hostId: createdHost.id, userId }); notifyStatsHostUpdated(createdHost.id as number, userId, "host_create"); } catch (err) { sshLogger.error("Failed to save SSH host to database", err, { diff --git a/src/backend/plugins/ctx-hosts.ts b/src/backend/plugins/ctx-hosts.ts index d4ea27262..c94ec7e71 100644 --- a/src/backend/plugins/ctx-hosts.ts +++ b/src/backend/plugins/ctx-hosts.ts @@ -30,6 +30,7 @@ import { hostStatusService } from "../hosts/status/host-status-service.js"; import { parseSshOptions } from "../hosts/ssh-options.js"; import { hostTerminalExport } from "../database/routes/host-normalizers.js"; import type { DisposableBag } from "./disposables.js"; +import { pluginEvents, TOPICS } from "./events.js"; import { pluginLogger } from "../utils/logger.js"; type AuditFn = ( @@ -502,6 +503,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { await writeSettings(created.id, writes); await markSettingsOwn(created.id, writes); await audit("hosts_create", `host ${created.id}`, { success: true }); + pluginEvents.emit(TOPICS.hostCreated, { hostId: created.id, userId }); return ( await toRecords([created as unknown as Record]) )[0]; diff --git a/src/backend/plugins/events.ts b/src/backend/plugins/events.ts index 9b5554896..9751be450 100644 --- a/src/backend/plugins/events.ts +++ b/src/backend/plugins/events.ts @@ -26,6 +26,8 @@ export const TOPICS = { hostStatus: "host.status", /** A host's key was accepted again after it changed. */ 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. */ hostUpdated: "host.updated", /** A host was deleted and any poller holding it should drop it. */ diff --git a/src/backend/sync/entities.ts b/src/backend/sync/entities.ts index e44f0e5f1..ee916cd58 100644 --- a/src/backend/sync/entities.ts +++ b/src/backend/sync/entities.ts @@ -22,6 +22,7 @@ import { import { FieldCrypto } from "../utils/field-crypto.js"; import { syncLogger } from "../utils/logger.js"; import { getPluginRuntime } from "../plugins/index.js"; +import { pluginEvents, TOPICS } from "../plugins/events.js"; import { singletonSyncId } from "./wire.js"; import { parseDefaultOverrides } from "../../types/host-defaults.js"; 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; // 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. @@ -195,6 +196,9 @@ export function registerCoreSyncEntities(): void { const { SharedHostSecretsManager } = await import("../utils/shared-host-secrets-manager.js"); await SharedHostSecretsManager.getInstance().resyncHost(id); + if (created) { + pluginEvents.emit(TOPICS.hostCreated, { hostId: id, userId }); + } }, remove: async (row, userId) => { const { deleteOwnedHost } = await import("../hosts/delete-host.js"); diff --git a/src/backend/tests/database/import-route.test.ts b/src/backend/tests/database/import-route.test.ts index 085a7e03d..5670594fc 100644 --- a/src/backend/tests/database/import-route.test.ts +++ b/src/backend/tests/database/import-route.test.ts @@ -48,6 +48,7 @@ function exportFile(): string { async function invoke(path: string, user: unknown = { isExternal: false }) { const writes: string[] = []; + const events: Array<[string, unknown]> = []; const hosts: Array> = []; const importHostProtocolLogins = vi.fn(); const handler = runInNewContext( @@ -98,6 +99,11 @@ async function invoke(path: string, user: unknown = { isExternal: false }) { importHostDefaults: async () => ({ imported: 0, skipped: 0 }), DatabaseSaveTrigger: { forceSave: async () => {} }, 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() }; @@ -106,7 +112,7 @@ async function invoke(path: string, user: unknown = { isExternal: false }) { { userId: "user-1", file: { path, originalname: "export.db" } }, res, ); - return { res, writes, hosts, importHostProtocolLogins }; + return { res, writes, hosts, events, importHostProtocolLogins }; } 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 () => { const path = exportFile(); const { res } = await invoke(path, null); diff --git a/src/backend/tests/database/routes/host-bulk-routes.test.ts b/src/backend/tests/database/routes/host-bulk-routes.test.ts index 42832f7d6..969120fa1 100644 --- a/src/backend/tests/database/routes/host-bulk-routes.test.ts +++ b/src/backend/tests/database/routes/host-bulk-routes.test.ts @@ -143,7 +143,7 @@ describe("notifyImportedHosts", () => { seen.push(payload); }); try { - notifyImportedHosts("user-1", [4, 7]); + notifyImportedHosts("user-1", [4, 7], []); } finally { off(); } diff --git a/src/backend/tests/database/routes/host-create-auth-type.test.ts b/src/backend/tests/database/routes/host-create-auth-type.test.ts index a958e39fe..de7c0ed73 100644 --- a/src/backend/tests/database/routes/host-create-auth-type.test.ts +++ b/src/backend/tests/database/routes/host-create-auth-type.test.ts @@ -84,6 +84,7 @@ const { hosts, sshCredentials } = const { saveLevel } = await import("../../../hosts/defaults/service.js"); const { default: hostRouter } = await import("../../../database/routes/host.js"); +const { pluginEvents, TOPICS } = await import("../../../plugins/events.js"); const app = express(); app.use(express.json()); @@ -192,3 +193,24 @@ describe("host update authType", () => { 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(); + } + }); +}); diff --git a/src/backend/tests/database/routes/host-import-terminal-settings.test.ts b/src/backend/tests/database/routes/host-import-terminal-settings.test.ts index fa999baf6..63f96b6f1 100644 --- a/src/backend/tests/database/routes/host-import-terminal-settings.test.ts +++ b/src/backend/tests/database/routes/host-import-terminal-settings.test.ts @@ -37,11 +37,14 @@ vi.mock("../../../database/repositories/factory.js", () => ({ })); import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js"; import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload"; +import { pluginEvents, TOPICS } from "../../../plugins/events.js"; let handler: RequestHandler; +let sshConfigHandler: RequestHandler; const router = { post: (path: string, ...handlers: RequestHandler[]) => { if (path === "/bulk-import") handler = handlers.at(-1)!; + if (path === "/ssh-config-import") sshConfigHandler = handlers.at(-1)!; }, put: () => {}, patch: () => {}, @@ -202,3 +205,53 @@ it("does not save dependent hosts when importing their jump host fails", async ( ); 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" }, + ]); +}); diff --git a/src/backend/tests/plugins/ctx-hosts.test.ts b/src/backend/tests/plugins/ctx-hosts.test.ts index a3f74e30d..e9d40c753 100644 --- a/src/backend/tests/plugins/ctx-hosts.test.ts +++ b/src/backend/tests/plugins/ctx-hosts.test.ts @@ -207,6 +207,7 @@ vi.mock("../../hosts/delete-host.js", () => ({ const { createPluginHosts } = await import("../../plugins/ctx-hosts.js"); const { PluginCapabilityError } = await import("@termix-ssh/plugin-sdk/backend"); +const { pluginEvents, TOPICS } = await import("../../plugins/events.js"); function manifest(capabilities: string[]) { 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 () => { h.granted = new Set(["hosts:write"]); const hosts = createPluginHosts({ diff --git a/src/backend/tests/sync/two-way.test.ts b/src/backend/tests/sync/two-way.test.ts index 15795f8ff..35c7915e6 100644 --- a/src/backend/tests/sync/two-way.test.ts +++ b/src/backend/tests/sync/two-way.test.ts @@ -163,6 +163,7 @@ const { createResolvers, writeWireRow } = await import("../../sync/store.js"); const { getEntity } = await import("../../plugins/sync-registry.js"); const { FieldCrypto } = await import("../../utils/field-crypto.js"); const { syncLogger } = await import("../../utils/logger.js"); +const { pluginEvents, TOPICS } = await import("../../plugins/events.js"); const app = express(); 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", () => { it("sends edits and deletes both ways", async () => { const [desk] = desktops;