mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: unify alerts into one plugin, channels, rules and inbox
This commit is contained in:
1 parent
fbe736bc20
commit
7c02daccf4
129 files changed
+20957
-2687
No files matched your search
@@ -1,17 +1,20 @@
|
||||
/**
|
||||
* 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.
|
||||
* ctx.notify and ctx.fetch: alerts through whichever plugin serves as the
|
||||
* hub, and core's guarded outbound HTTP. Core resolves who an alert is for,
|
||||
* so a plugin names an audience and never a list of users it could not see.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginFetch,
|
||||
PluginNotification,
|
||||
PluginNotificationAudience,
|
||||
PluginNotify,
|
||||
PluginNotifyResult,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { assertCapability, capabilityRefused } from "./permissions.js";
|
||||
import { getActor } from "./actor.js";
|
||||
import { activeNotifyHub, registerNotifyHub } from "./notify-hub.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
@@ -22,8 +25,16 @@ type AuditFn = (
|
||||
interface Deps {
|
||||
manifest: PluginManifest;
|
||||
audit: AuditFn;
|
||||
/** Where serve's revoke goes, so the hub goes away with the plugin. */
|
||||
bag?: { add: (dispose: () => void, label: string) => void };
|
||||
}
|
||||
|
||||
const EMPTY_RESULT: PluginNotifyResult = {
|
||||
recipients: 0,
|
||||
delivered: 0,
|
||||
failures: [],
|
||||
};
|
||||
|
||||
function actingUser(): string {
|
||||
const actor = getActor();
|
||||
if (!actor) {
|
||||
@@ -60,14 +71,43 @@ async function audited<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/** The user ids an audience names right now. */
|
||||
export async function resolveAudience(
|
||||
audience: PluginNotificationAudience | undefined,
|
||||
): Promise<string[]> {
|
||||
if (!audience) return [actingUser()];
|
||||
const { createCurrentUserRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const users = createCurrentUserRepository();
|
||||
if (audience === "admins") {
|
||||
return (await users.listAll())
|
||||
.filter((user) => user.isAdmin)
|
||||
.map((user) => user.id);
|
||||
}
|
||||
if ("userId" in audience) {
|
||||
return (await users.findById(audience.userId)) ? [audience.userId] : [];
|
||||
}
|
||||
const { PermissionManager } = await import("../utils/permission-manager.js");
|
||||
const permissions = PermissionManager.getInstance();
|
||||
const recipients: string[] = [];
|
||||
for (const user of await users.listAll()) {
|
||||
if (await permissions.hasPermission(user.id, audience.permission)) {
|
||||
recipients.push(user.id);
|
||||
}
|
||||
}
|
||||
return recipients;
|
||||
}
|
||||
|
||||
function describeAudience(notification: PluginNotification): string {
|
||||
const { audience } = notification;
|
||||
if (!audience) return "acting user";
|
||||
if (audience === "admins") return "admins";
|
||||
if ("userId" in audience) return `user ${audience.userId}`;
|
||||
return `permission ${audience.permission}`;
|
||||
}
|
||||
|
||||
export function createPluginNotify(deps: Deps): PluginNotify {
|
||||
const listOwn = async (userId: string) => {
|
||||
const { createCurrentNotificationChannelRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentNotificationChannelRepository().listNotificationChannels(
|
||||
userId,
|
||||
);
|
||||
};
|
||||
const pluginId = deps.manifest.id;
|
||||
|
||||
return {
|
||||
channels: () =>
|
||||
@@ -77,48 +117,39 @@ export function createPluginNotify(deps: Deps): PluginNotify {
|
||||
"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,
|
||||
}));
|
||||
const userId = actingUser();
|
||||
const hub = await activeNotifyHub();
|
||||
return hub ? hub.channels(userId) : [];
|
||||
},
|
||||
),
|
||||
|
||||
send: (channelIds, notification) =>
|
||||
send: (notification) =>
|
||||
audited(
|
||||
deps,
|
||||
"notify:send",
|
||||
"notify_send",
|
||||
`${channelIds.length} channel(s)`,
|
||||
`${notification.category ?? pluginId} to ${describeAudience(notification)}`,
|
||||
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;
|
||||
const recipients = await resolveAudience(notification.audience);
|
||||
const hub = await activeNotifyHub();
|
||||
if (!hub || recipients.length === 0) return { ...EMPTY_RESULT };
|
||||
return hub.deliver({ source: pluginId, recipients, notification });
|
||||
},
|
||||
),
|
||||
|
||||
serve: (hub) => {
|
||||
if (!deps.manifest.capabilities.includes("notify:hub")) {
|
||||
throw capabilityRefused(pluginId, "notify:hub", "notify_serve");
|
||||
}
|
||||
const revoke = registerNotifyHub({
|
||||
pluginId,
|
||||
declared: deps.manifest.capabilities,
|
||||
hub,
|
||||
});
|
||||
deps.bag?.add(revoke, "alert hub");
|
||||
void deps.audit("notify_serve", "serving alerts", { success: true });
|
||||
return revoke;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -858,7 +858,11 @@ export function createPluginContext(
|
||||
),
|
||||
),
|
||||
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
notify: createPluginNotify({ manifest, audit: auditCall }),
|
||||
notify: createPluginNotify({
|
||||
manifest,
|
||||
audit: auditCall,
|
||||
bag: handle.bag,
|
||||
}),
|
||||
fetch: createPluginFetch({ manifest, audit: auditCall }),
|
||||
process: createPluginProcess({
|
||||
manifest,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The one plugin that stores alerts and delivers them to channels. Core only
|
||||
* resolves who an alert is for and hands it over; with no hub running an
|
||||
* alert reaches nobody.
|
||||
*/
|
||||
|
||||
import type { PluginNotifyHub } from "@termix/plugin-sdk/backend";
|
||||
import { hasCapability } from "./permissions.js";
|
||||
|
||||
interface HubRegistration {
|
||||
pluginId: string;
|
||||
declared: readonly string[];
|
||||
hub: PluginNotifyHub;
|
||||
}
|
||||
|
||||
let current: HubRegistration | null = null;
|
||||
|
||||
export function registerNotifyHub(registration: HubRegistration): () => void {
|
||||
if (current && current.pluginId !== registration.pluginId) {
|
||||
throw new Error(
|
||||
`Plugin ${registration.pluginId} cannot serve alerts: ${current.pluginId} already does`,
|
||||
);
|
||||
}
|
||||
current = registration;
|
||||
return () => {
|
||||
if (current === registration) current = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** The running hub, or null while none is registered or its grant is gone. */
|
||||
export async function activeNotifyHub(): Promise<PluginNotifyHub | null> {
|
||||
const registration = current;
|
||||
if (!registration) return null;
|
||||
const granted = await hasCapability(
|
||||
registration.pluginId,
|
||||
"notify:hub",
|
||||
registration.declared,
|
||||
);
|
||||
return granted ? registration.hub : null;
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearNotifyHub(): void {
|
||||
current = null;
|
||||
}
|
||||
Reference in new issue
Block a user