mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: finish the automations plugin (B17)
This commit is contained in:
1 parent
993a91c899
commit
6b4d1432ab
96 files changed
+25106
-5077
No files matched your search
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* ctx.notify and ctx.fetch: core's notification channels and core's guarded
|
||||
* outbound HTTP. Channels belong to a user, so notify always runs as the
|
||||
* acting user and never sees a channel's config.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginFetch,
|
||||
PluginNotify,
|
||||
PluginNotifyResult,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { getActor } from "./actor.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
details: string,
|
||||
outcome: { success: boolean; errorMessage?: string },
|
||||
) => Promise<void>;
|
||||
|
||||
interface Deps {
|
||||
manifest: PluginManifest;
|
||||
audit: AuditFn;
|
||||
}
|
||||
|
||||
function actingUser(): string {
|
||||
const actor = getActor();
|
||||
if (!actor) {
|
||||
throw new Error(
|
||||
"ctx.notify needs an acting user: call it inside a request or ctx.asUser",
|
||||
);
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
|
||||
/** Runs fn behind the capability check, with an audit line either way. */
|
||||
async function audited<T>(
|
||||
deps: Deps,
|
||||
capability: string,
|
||||
action: string,
|
||||
details: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
await assertCapability(
|
||||
deps.manifest.id,
|
||||
capability,
|
||||
deps.manifest.capabilities,
|
||||
);
|
||||
const result = await fn();
|
||||
await deps.audit(action, details, { success: true });
|
||||
return result;
|
||||
} catch (error) {
|
||||
await deps.audit(action, details, {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function createPluginNotify(deps: Deps): PluginNotify {
|
||||
const listOwn = async (userId: string) => {
|
||||
const { createCurrentNotificationChannelRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentNotificationChannelRepository().listNotificationChannels(
|
||||
userId,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
channels: () =>
|
||||
audited(
|
||||
deps,
|
||||
"notify:send",
|
||||
"notify_channels",
|
||||
"listed channels",
|
||||
async () => {
|
||||
const rows = await listOwn(actingUser());
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
enabled: !!row.enabled,
|
||||
}));
|
||||
},
|
||||
),
|
||||
|
||||
send: (channelIds, notification) =>
|
||||
audited(
|
||||
deps,
|
||||
"notify:send",
|
||||
"notify_send",
|
||||
`${channelIds.length} channel(s)`,
|
||||
async () => {
|
||||
const rows = await listOwn(actingUser());
|
||||
const { deliverNotification } =
|
||||
await import("../utils/notification-sender.js");
|
||||
const result: PluginNotifyResult = { delivered: 0, failures: [] };
|
||||
for (const row of rows) {
|
||||
if (!channelIds.includes(row.id) || !row.enabled) continue;
|
||||
try {
|
||||
await deliverNotification(row, {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
severity: notification.severity ?? "warning",
|
||||
context: notification.context,
|
||||
});
|
||||
result.delivered++;
|
||||
} catch (error) {
|
||||
result.failures.push({
|
||||
channelId: row.id,
|
||||
name: row.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginFetch(deps: Deps): PluginFetch {
|
||||
return (url, init = {}) =>
|
||||
audited(deps, "network:outbound", "fetch", "outbound request", async () => {
|
||||
const { safeOutboundFetch } =
|
||||
await import("../utils/safe-outbound-fetch.js");
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(),
|
||||
Math.max(init.timeoutMs ?? 30_000, 1),
|
||||
);
|
||||
try {
|
||||
return await safeOutboundFetch(
|
||||
url,
|
||||
{
|
||||
method: init.method ?? "GET",
|
||||
headers: init.headers,
|
||||
body: init.body,
|
||||
signal: controller.signal,
|
||||
},
|
||||
(init.allowPrivateHosts ?? []).map((host) => host.toLowerCase()),
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
|
||||
import { createPluginHosts } from "./ctx-hosts.js";
|
||||
import { createPluginSchedule } from "./schedule.js";
|
||||
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
|
||||
import { createPluginCredentials } from "./ctx-credentials.js";
|
||||
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
|
||||
|
||||
@@ -730,6 +731,8 @@ export function createPluginContext(
|
||||
),
|
||||
),
|
||||
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
notify: createPluginNotify({ manifest, audit: auditCall }),
|
||||
fetch: createPluginFetch({ manifest, audit: auditCall }),
|
||||
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
|
||||
desktop: {
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
/**
|
||||
* The process-wide event bus behind ctx.events.
|
||||
*
|
||||
* This formalises the ad-hoc publish paths that already existed:
|
||||
* - hosts/automation-events.ts (generic named internal events)
|
||||
* - hosts/host-session-status.ts (the "host.session.status" topic)
|
||||
*
|
||||
* Each of those lazily imported the automations engine and swallowed errors,
|
||||
* for two reasons that still apply and are preserved here:
|
||||
* Core publishes here (hosts/internal-events.ts for generic named events,
|
||||
* hosts/host-session-status.ts for "host.session.status", and the topics in
|
||||
* TOPICS below) and plugins subscribe through ctx.events. Two rules:
|
||||
*
|
||||
* 1. Fire-and-forget. A failing subscriber must never disturb the caller. A
|
||||
* metrics poll or a host delete does not fail because an automation threw.
|
||||
* 2. No static import of the automations layer. It reads repositories, which
|
||||
* several hosts modules also pull in, so a static edge would close a
|
||||
* cycle. Subscribers register themselves here instead of being reached
|
||||
* into.
|
||||
* metrics poll or a host delete does not fail because a listener threw.
|
||||
* 2. No static import of a subscriber. Subscribers register themselves here
|
||||
* instead of being reached into, so core never names a plugin.
|
||||
*
|
||||
* Topics are dotted strings. The internal ones the server itself publishes are
|
||||
* listed in TOPICS; a plugin may emit and subscribe to any topic, but only
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
* The heavy dependencies are imported lazily inside each function rather than
|
||||
* at module scope: several pull in the repository layer, and a static import
|
||||
* here would close the cycle hosts/automation-events.ts documents.
|
||||
* here would close an import cycle through the repositories.
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
|
||||
@@ -38,7 +38,10 @@ export type { PluginSettingsScope };
|
||||
* to it means deciding that every plugin holding settings:read-core may see
|
||||
* that value, so it is a review decision rather than a convenience.
|
||||
*/
|
||||
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = ["app_name"];
|
||||
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
|
||||
"app_name",
|
||||
"notification_private_endpoint_allowlist",
|
||||
];
|
||||
|
||||
type ChangeListener = (value: unknown) => void;
|
||||
|
||||
|
||||
Reference in new issue
Block a user