feat: finish the automations plugin (B17)

This commit is contained in:
LukeGus committed 2026-09-24 13:05:44 -05:00
1 parent 993a91c899
commit 6b4d1432ab
96 files changed
+25106 -5077

No files matched your search

+150
View File
@@ -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);
}
});
}
+3
View File
@@ -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: {
+6 -11
View File
@@ -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
+1 -1
View File
@@ -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";
+4 -1
View File
@@ -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;