feat: unify alerts into one plugin, channels, rules and inbox

This commit is contained in:
LukeGus committed 2026-09-25 16:26:37 -05:00
1 parent fbe736bc20
commit 7c02daccf4
129 files changed
+20957 -2687

No files matched your search

+4 -1
View File
@@ -237,9 +237,12 @@ export async function runLogin(
await unlockUser(user, identity, deviceType);
if ("password" in identity && identity.password) {
// A 2.8 key still wrapped by the password only opens here, so this is the
// first chance to move that user's TOTP secret into the plugin.
// first chance to move that user's TOTP secret and channel configs.
const { runTotpMigration } = await import("../upgrade/totp-migration.js");
await runTotpMigration(user.id);
const { runNotificationChannelMigration } =
await import("../upgrade/notification-channel-migration.js");
await runNotificationChannelMigration(user.id);
}
await syncSharedCredentialsForUserRoles(
user.id,
-4
View File
@@ -59,10 +59,6 @@ export async function runCoreBootMigrations(): Promise<void> {
await import("./utils/crypto-migration/private-shared-ssh-auth-migration.js");
await runPrivateSharedSshAuthMigration();
const { runChannelConfigEncryptionMigration } =
await import("./utils/crypto-migration/channel-config-encryption.js");
await runChannelConfigEncryptionMigration();
const { runExternalIdentityMigration } =
await import("./upgrade/external-identity-migration.js");
await runExternalIdentityMigration();
-59
View File
@@ -7,7 +7,6 @@ import multer from "multer";
import cookieParser from "cookie-parser";
import userRoutes from "./routes/users.js";
import hostRoutes from "./routes/host.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
import rbacRoutes from "./routes/rbac.js";
@@ -17,7 +16,6 @@ import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
import uiPreferencesRoutes from "./routes/ui-preferences.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
import syncRoutes from "./routes/sync.js";
import dashboardRoutes from "./routes/dashboard-routes.js";
import {
@@ -48,7 +46,6 @@ import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
import {
createCurrentCredentialRepository,
createCurrentDismissedAlertRepository,
createCurrentHostRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
@@ -866,13 +863,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE dismissed_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
alert_id TEXT NOT NULL,
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE ssh_credential_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_id INTEGER NOT NULL,
@@ -1008,21 +998,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
// Rows the user owns in plugin tables travel with the export.
await writeUserPluginTables(exportDb, userId);
const dismissedAlertRepository = createCurrentDismissedAlertRepository();
const alerts = await dismissedAlertRepository.listByUserId(userId);
const insertAlert = exportDb.prepare(`
INSERT INTO dismissed_alerts (id, user_id, alert_id, dismissed_at)
VALUES (?, ?, ?, ?)
`);
for (const alert of alerts) {
insertAlert.run(
alert.id,
alert.userId,
alert.alertId,
alert.dismissedAt,
);
}
const sshCredentialUsageRepository =
createCurrentSshCredentialUsageRepository();
const usage = await sshCredentialUsageRepository.listByUserId(userId);
@@ -1217,7 +1192,6 @@ app.post(
sshHostsImported: 0,
sshCredentialsImported: 0,
pluginItemsImported: 0,
dismissedAlertsImported: 0,
credentialUsageImported: 0,
settingsImported: 0,
skippedItems: 0,
@@ -1366,37 +1340,6 @@ app.post(
result.summary.skippedItems += pluginRows.skipped;
result.summary.errors.push(...pluginRows.errors);
const dismissedAlertRepository =
createCurrentDismissedAlertRepository();
try {
const importedAlerts = importDb
.prepare("SELECT * FROM dismissed_alerts")
.all();
for (const alert of importedAlerts) {
try {
const created = await dismissedAlertRepository.createForImport(
userId,
alert.alert_id,
alert.dismissed_at,
);
if (created) {
result.summary.dismissedAlertsImported++;
} else {
result.summary.skippedItems++;
}
} catch (alertError) {
result.summary.errors.push(
`Dismissed alert import error: ${alertError.message}`,
);
}
}
} catch {
apiLogger.info(
"dismissed_alerts table not found in import file, skipping",
);
}
const targetUser = await userRepository.findById(userId);
if (targetUser?.isAdmin) {
try {
@@ -1614,7 +1557,6 @@ app.post("/database/restore", requireAdmin, async (req, res) => {
app.use("/users", userRoutes);
app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/ssh-auth", sshAuthRoutes);
app.use("/rbac", rbacRoutes);
@@ -1624,7 +1566,6 @@ app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
app.use("/ui-preferences", uiPreferencesRoutes);
registerAuditLogRoutes(app, authenticateJWT);
app.use("/", notificationChannelsRoutes);
app.use("/sync", syncRoutes);
app.use("/dashboard", dashboardRoutes);
app.use("/plugins", pluginRoutes);
-31
View File
@@ -322,14 +322,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dismissed_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
alert_id TEXT NOT NULL,
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ssh_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -1549,29 +1541,6 @@ const migrateSchema = () => {
});
}
}
try {
sqlite.prepare("SELECT id FROM notification_channels LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS notification_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL,
config TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create notification_channels table", {
operation: "schema_migration",
error: createError,
});
}
}
// --- alerts end ---
@@ -109,11 +109,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
table: "user_open_tabs",
columns: "user_id",
},
{
name: "idx_dismissed_alerts_user_id",
table: "dismissed_alerts",
columns: "user_id",
},
];
interface IndexableDatabase {
-30
View File
@@ -271,20 +271,6 @@ export const hosts = mysqlTable(
],
);
export const dismissedAlerts = mysqlTable(
"dismissed_alerts",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
alertId: text("alert_id").notNull(),
dismissedAt: text("dismissed_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [index("idx_dismissed_alerts_user_id").on(table.userId)],
);
export const sshCredentials = mysqlTable(
"ssh_credentials",
@@ -720,22 +706,6 @@ export const uiPreferences = mysqlTable("ui_preferences", {
.default(sql`(CURRENT_TIMESTAMP)`),
});
// --- alerts begin ---
export const notificationChannels = mysqlTable("notification_channels", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
type: text("type").notNull(),
config: text("config").notNull(),
enabled: boolean("enabled").notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
// --- alerts end ---
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
-30
View File
@@ -272,20 +272,6 @@ export const hosts = pgTable(
],
);
export const dismissedAlerts = pgTable(
"dismissed_alerts",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
alertId: text("alert_id").notNull(),
dismissedAt: text("dismissed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_dismissed_alerts_user_id").on(table.userId)],
);
export const sshCredentials = pgTable(
"ssh_credentials",
@@ -721,22 +707,6 @@ export const uiPreferences = pgTable("ui_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
// --- alerts begin ---
export const notificationChannels = pgTable("notification_channels", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
type: text("type").notNull(),
config: text("config").notNull(),
enabled: boolean("enabled").notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- alerts end ---
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
-30
View File
@@ -264,20 +264,6 @@ export const hosts = sqliteTable(
],
);
export const dismissedAlerts = sqliteTable(
"dismissed_alerts",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
alertId: text("alert_id").notNull(),
dismissedAt: text("dismissed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_dismissed_alerts_user_id").on(table.userId)],
);
export const sshCredentials = sqliteTable(
"ssh_credentials",
@@ -717,22 +703,6 @@ export const uiPreferences = sqliteTable("ui_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
// --- alerts begin ---
export const notificationChannels = sqliteTable("notification_channels", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
type: text("type").notNull(),
config: text("config").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- alerts end ---
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
@@ -1,107 +0,0 @@
import { and, eq } from "drizzle-orm";
import { dismissedAlerts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type DismissedAlertRecord = typeof dismissedAlerts.$inferSelect;
export class DismissedAlertRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUserId(userId: string): Promise<DismissedAlertRecord[]> {
return this.context.drizzle
.select()
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
}
async listAlertIdsByUserId(userId: string): Promise<string[]> {
const rows = await this.context.drizzle
.select({ alertId: dismissedAlerts.alertId })
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
return rows.map((row) => row.alertId);
}
async findForUser(
userId: string,
alertId: string,
): Promise<DismissedAlertRecord | null> {
const rows = await this.context.drizzle
.select()
.from(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId),
),
)
.limit(1);
return rows[0] ?? null;
}
async create(userId: string, alertId: string): Promise<void> {
await this.context.drizzle.insert(dismissedAlerts).values({
userId,
alertId,
});
await this.afterWrite();
}
async createForImport(
userId: string,
alertId: string,
dismissedAt = new Date().toISOString(),
): Promise<boolean> {
const existing = await this.findForUser(userId, alertId);
if (existing) {
return false;
}
await this.context.drizzle.insert(dismissedAlerts).values({
userId,
alertId,
dismissedAt,
});
await this.afterWrite();
return true;
}
async deleteForUser(userId: string, alertId: string): Promise<boolean> {
const result = await this.context.drizzle
.delete(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -3,11 +3,9 @@ import { getDb, getSqlite } from "../db/index.js";
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
import { primeSettingsCache, readCachedSetting } from "./settings-cache.js";
import type { DatabaseContext } from "./database-context.js";
import { NotificationChannelRepository } from "./notification-channel-repository.js";
import { ApiKeyRepository } from "./api-key-repository.js";
import { AuditLogRepository } from "./audit-log-repository.js";
import { CredentialRepository } from "./credential-repository.js";
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
import { HostFolderRepository } from "./host-folder-repository.js";
import { HostRepository } from "./host-repository.js";
import { HostResolutionRepository } from "./host-resolution-repository.js";
@@ -119,13 +117,6 @@ export function getCurrentSettingValue(key: string): string | null {
return row?.value ?? null;
}
export function createCurrentNotificationChannelRepository(): NotificationChannelRepository {
return new NotificationChannelRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("notification_channel_repository_write"),
);
}
export function createCurrentApiKeyRepository(): ApiKeyRepository {
return new ApiKeyRepository(
createCurrentRepositoryContext(),
@@ -154,13 +145,6 @@ export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository
);
}
export function createCurrentDismissedAlertRepository(): DismissedAlertRepository {
return new DismissedAlertRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("dismissed_alert_repository_write"),
);
}
export function createCurrentHostFolderRepository(): HostFolderRepository {
return new HostFolderRepository(
createCurrentRepositoryContext(),
@@ -1,231 +0,0 @@
import { and, eq } from "drizzle-orm";
import { notificationChannels } from "../db/schema.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
export interface NotificationChannelRow {
id: number;
user_id: string;
name: string;
type: string;
config: string;
enabled: number;
created_at: string;
}
export class NotificationChannelRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
/**
* Channel configs carry ntfy tokens and webhook auth headers, so they are
* field-encrypted like any other secret. The key is derived from the row id,
* which does not exist until after the insert, so a new channel is written
* once and then re-encrypted in place with its real id.
*/
private userDataKey(userId: string): Buffer | null {
try {
return DataCrypto.getUserDataKey(userId);
} catch {
// Crypto is not initialized (tests, early boot); leave the value as is.
return null;
}
}
private encryptConfig(
config: string,
userId: string,
recordId: number | string,
): string {
const userDataKey = this.userDataKey(userId);
if (!userDataKey) return config;
return DataCrypto.encryptRecord(
"notification_channels",
{ id: recordId, config },
userId,
userDataKey,
).config;
}
private decryptConfig(
config: string,
userId: string,
recordId: number | string,
): string {
const userDataKey = this.userDataKey(userId);
if (!userDataKey) return config;
try {
return DataCrypto.decryptRecord(
"notification_channels",
{ id: recordId, config },
userId,
userDataKey,
).config;
} catch {
// Rows written before channel configs were encrypted are still plaintext.
return config;
}
}
async listNotificationChannels(
userId: string,
): Promise<NotificationChannelRow[]> {
const rows = await this.context.drizzle
.select()
.from(notificationChannels)
.where(eq(notificationChannels.userId, userId))
.orderBy(notificationChannels.id);
return rows.map((row) => {
const mapped = mapChannelRow(row);
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
return mapped;
});
}
async findNotificationChannelForUser(
id: number,
userId: string,
): Promise<NotificationChannelRow | null> {
const rows = await this.context.drizzle
.select()
.from(notificationChannels)
.where(
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
)
.limit(1);
if (!rows[0]) return null;
const mapped = mapChannelRow(rows[0]);
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
return mapped;
}
async createNotificationChannel(input: {
userId: string;
name: string;
type: string;
config: string;
enabled: boolean;
}): Promise<NotificationChannelRow> {
const [created] = await insertReturning(
this.context,
notificationChannels,
{
userId: input.userId,
name: input.name,
type: input.type,
config: input.config,
enabled: input.enabled,
},
);
const encrypted = this.encryptConfig(
input.config,
input.userId,
created.id,
);
if (encrypted !== input.config) {
await this.context.drizzle
.update(notificationChannels)
.set({ config: encrypted })
.where(eq(notificationChannels.id, created.id));
}
await this.afterWrite();
const mapped = mapChannelRow(created);
mapped.config = input.config;
return mapped;
}
async updateNotificationChannel(
id: number,
userId: string,
input: {
name?: string;
type?: string;
config?: string;
enabled?: boolean;
},
): Promise<NotificationChannelRow | null> {
if (Object.keys(input).length === 0) {
return this.findNotificationChannelForUser(id, userId);
}
const values =
input.config !== undefined
? { ...input, config: this.encryptConfig(input.config, userId, id) }
: input;
const [updated] = await updateReturning(
this.context,
notificationChannels,
values,
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
);
if (!updated) return null;
await this.afterWrite();
const mapped = mapChannelRow(updated);
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
return mapped;
}
async deleteNotificationChannel(
id: number,
userId: string,
): Promise<boolean> {
const result = await this.context.drizzle
.delete(notificationChannels)
.where(
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
);
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
return true;
}
async deleteByUserId(userId: string): Promise<{ channelsDeleted: number }> {
const result = await this.context.drizzle
.delete(notificationChannels)
.where(eq(notificationChannels.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return { channelsDeleted: rowsAffected(result) };
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
function mapChannelRow(row: NotificationChannelRecord): NotificationChannelRow {
return {
id: row.id,
user_id: row.userId,
name: row.name,
type: row.type,
config: row.config,
enabled: row.enabled ? 1 : 0,
created_at: row.createdAt,
};
}
-282
View File
@@ -1,282 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type {
AuthenticatedRequest,
CacheEntry,
TermixAlert,
} from "../../../types/index.js";
import express from "express";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { getProxyAgent } from "../../utils/proxy-agent.js";
import { createCurrentDismissedAlertRepository } from "../repositories/factory.js";
class AlertCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 5 * 60 * 1000;
set<T>(key: string, data: T): void {
const now = Date.now();
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + this.CACHE_DURATION,
});
}
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data as T;
}
}
const alertCache = new AlertCache();
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
const REPO_OWNER = "Termix-SSH";
const REPO_NAME = "Docs";
const ALERTS_FILE = "main/termix-alerts.json";
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
const cacheKey = "termix_alerts";
const cachedData = alertCache.get<TermixAlert[]>(cacheKey);
if (cachedData) {
return cachedData;
}
try {
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
const response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent": "TermixAlertChecker/1.0",
},
dispatcher: getProxyAgent(url),
});
if (!response.ok) {
authLogger.warn("GitHub API returned error status", {
operation: "alerts_fetch",
status: response.status,
statusText: response.statusText,
});
throw new Error(
`GitHub raw content error: ${response.status} ${response.statusText}`,
);
}
const alerts: TermixAlert[] = (await response.json()) as TermixAlert[];
const now = new Date();
const validAlerts = alerts.filter((alert) => {
const expiryDate = new Date(alert.expiresAt);
const isValid = expiryDate > now;
return isValid;
});
alertCache.set(cacheKey, validAlerts);
return validAlerts;
} catch (error) {
authLogger.error("Failed to fetch alerts from GitHub", {
operation: "alerts_fetch",
error: getErrorMessage(error),
});
return [];
}
}
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* @openapi
* /alerts:
* get:
* summary: Get active alerts
* description: Fetches active alerts for the authenticated user, excluding those that have been dismissed.
* tags:
* - Alerts
* responses:
* 200:
* description: A list of active alerts.
* 500:
* description: Failed to fetch alerts.
*/
router.get("/", authenticateJWT, async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
const allAlerts = await fetchAlertsFromGitHub();
const dismissedAlertIds = new Set(
await createCurrentDismissedAlertRepository().listAlertIdsByUserId(
userId,
),
);
const activeAlertsForUser = allAlerts.filter(
(alert) => !dismissedAlertIds.has(alert.id),
);
res.json({
alerts: activeAlertsForUser,
cached: alertCache.get("termix_alerts") !== null,
total_count: activeAlertsForUser.length,
});
} catch (error) {
authLogger.error("Failed to get user alerts", error);
res.status(500).json({ error: "Failed to fetch alerts" });
}
});
/**
* @openapi
* /alerts/dismiss:
* post:
* summary: Dismiss an alert
* description: Marks an alert as dismissed for the authenticated user.
* tags:
* - Alerts
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* alertId:
* type: string
* responses:
* 200:
* description: Alert dismissed successfully.
* 400:
* description: Alert ID is required.
* 409:
* description: Alert already dismissed.
* 500:
* description: Failed to dismiss alert.
*/
router.post("/dismiss", authenticateJWT, async (req, res) => {
try {
const { alertId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!alertId) {
authLogger.warn("Missing alertId in dismiss request", { userId });
return res.status(400).json({ error: "Alert ID is required" });
}
const existingDismissal =
await createCurrentDismissedAlertRepository().findForUser(
userId,
alertId,
);
if (existingDismissal) {
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
return res.status(409).json({ error: "Alert already dismissed" });
}
await createCurrentDismissedAlertRepository().create(userId, alertId);
res.json({ message: "Alert dismissed successfully" });
} catch (error) {
authLogger.error("Failed to dismiss alert", error);
res.status(500).json({ error: "Failed to dismiss alert" });
}
});
/**
* @openapi
* /alerts/dismissed:
* get:
* summary: Get dismissed alerts
* description: Fetches a list of alerts that have been dismissed by the authenticated user.
* tags:
* - Alerts
* responses:
* 200:
* description: A list of dismissed alerts.
* 500:
* description: Failed to fetch dismissed alerts.
*/
router.get("/dismissed", authenticateJWT, async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
const dismissedAlertRecords =
await createCurrentDismissedAlertRepository().listByUserId(userId);
res.json({
dismissed_alerts: dismissedAlertRecords,
total_count: dismissedAlertRecords.length,
});
} catch (error) {
authLogger.error("Failed to get dismissed alerts", error);
res.status(500).json({ error: "Failed to fetch dismissed alerts" });
}
});
/**
* @openapi
* /alerts/dismiss:
* delete:
* summary: Undismiss an alert
* description: Removes an alert from the dismissed list for the authenticated user.
* tags:
* - Alerts
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* alertId:
* type: string
* responses:
* 200:
* description: Alert undismissed successfully.
* 400:
* description: Alert ID is required.
* 404:
* description: Dismissed alert not found.
* 500:
* description: Failed to undismiss alert.
*/
router.delete("/dismiss", authenticateJWT, async (req, res) => {
try {
const { alertId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!alertId) {
return res.status(400).json({ error: "Alert ID is required" });
}
const deleted = await createCurrentDismissedAlertRepository().deleteForUser(
userId,
alertId,
);
if (!deleted) {
return res.status(404).json({ error: "Dismissed alert not found" });
}
res.json({ message: "Alert undismissed successfully" });
} catch (error) {
authLogger.error("Failed to undismiss alert", error);
res.status(500).json({ error: "Failed to undismiss alert" });
}
});
export default router;
@@ -1,10 +1,8 @@
import { authLogger } from "../../utils/logger.js";
import {
createCurrentNotificationChannelRepository,
createCurrentApiKeyRepository,
createCurrentAuditLogRepository,
createCurrentCredentialRepository,
createCurrentDismissedAlertRepository,
createCurrentHostFolderRepository,
createCurrentHostRepository,
createCurrentHostSidebarPreferenceRepository,
@@ -62,13 +60,11 @@ export async function deleteUserAndRelatedData(
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
await createCurrentNotificationChannelRepository().deleteByUserId(userId);
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentHostFolderRepository().deleteByUserId(userId);
@@ -1,245 +0,0 @@
import express, { type Request, type Response } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { createCurrentNotificationChannelRepository } from "../repositories/factory.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import { deliverNotification } from "../../utils/notification-sender.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
router.use(authenticateJWT);
/**
* @openapi
* /notification-channels:
* get:
* summary: List notification channels for the current user
* tags:
* - Notifications
* responses:
* 200:
* description: List of notification channels.
*/
router.get("/notification-channels", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows =
await createCurrentNotificationChannelRepository().listNotificationChannels(
userId,
);
res.json(rows);
} catch (err) {
databaseLogger.error("Failed to list notification channels", {
operation: "list_channels",
error: err,
});
res.status(500).json({ error: "Failed to list channels" });
}
});
/**
* @openapi
* /notification-channels:
* post:
* summary: Create a notification channel
* tags:
* - Notifications
*/
router.post("/notification-channels", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, type, config, enabled } = req.body as {
name: string;
type: string;
config: unknown;
enabled?: boolean;
};
if (!name || typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "name is required" });
}
if (type !== "webhook" && type !== "ntfy" && type !== "discord") {
return res
.status(400)
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
}
if (!config || typeof config !== "object") {
return res.status(400).json({ error: "config is required" });
}
if (type === "ntfy") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "ntfy config requires url" });
if (!c.topic || typeof c.topic !== "string")
return res.status(400).json({ error: "ntfy config requires topic" });
}
if (type === "webhook") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "webhook config requires url" });
}
if (type === "discord") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "discord config requires url" });
if (
!/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i.test(
c.url,
)
) {
return res.status(400).json({
error:
"discord config requires a valid Discord webhook URL (https://discord.com/api/webhooks/...)",
});
}
}
try {
const row =
await createCurrentNotificationChannelRepository().createNotificationChannel(
{
userId,
name: name.trim(),
type,
config: JSON.stringify(config),
enabled: enabled !== false,
},
);
res.status(201).json(row);
} catch (err) {
databaseLogger.error("Failed to create notification channel", {
operation: "create_channel",
error: err,
});
res.status(500).json({ error: "Failed to create channel" });
}
});
/**
* @openapi
* /notification-channels/{id}:
* put:
* summary: Update a notification channel
* tags:
* - Notifications
*/
router.put(
"/notification-channels/:id",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const { name, type, config, enabled } = req.body as {
name?: string;
type?: string;
config?: unknown;
enabled?: boolean;
};
const repository = createCurrentNotificationChannelRepository();
const existing = await repository.findNotificationChannelForUser(
channelId,
userId,
);
if (!existing) return res.status(404).json({ error: "Channel not found" });
if (type && type !== "webhook" && type !== "ntfy" && type !== "discord") {
return res
.status(400)
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
}
if (
name === undefined &&
type === undefined &&
config === undefined &&
enabled === undefined
) {
return res.json({ success: true });
}
try {
const row = await repository.updateNotificationChannel(
channelId,
userId,
{
...(name !== undefined ? { name: name.trim() } : {}),
...(type !== undefined ? { type } : {}),
...(config !== undefined ? { config: JSON.stringify(config) } : {}),
...(enabled !== undefined ? { enabled } : {}),
},
);
if (!row) return res.status(404).json({ error: "Channel not found" });
res.json(row);
} catch (err) {
databaseLogger.error("Failed to update notification channel", {
operation: "update_channel",
error: err,
});
res.status(500).json({ error: "Failed to update channel" });
}
},
);
/**
* @openapi
* /notification-channels/{id}:
* delete:
* summary: Delete a notification channel
* tags:
* - Notifications
*/
router.delete(
"/notification-channels/:id",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const deleted =
await createCurrentNotificationChannelRepository().deleteNotificationChannel(
channelId,
userId,
);
if (!deleted) return res.status(404).json({ error: "Channel not found" });
res.json({ success: true });
},
);
/**
* @openapi
* /notification-channels/{id}/test:
* post:
* summary: Send a test notification
* tags:
* - Notifications
*/
router.post(
"/notification-channels/:id/test",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const row =
await createCurrentNotificationChannelRepository().findNotificationChannelForUser(
channelId,
userId,
);
if (!row) return res.status(404).json({ error: "Channel not found" });
try {
// The same path an automation's notify step takes, so a test proves
// the channel works for real, private-network opt-in included.
await deliverNotification(row, {
title: "Termix",
body: "This is a test notification from Termix",
severity: "info",
context: { sourceName: "Test", triggerType: "test" },
});
res.json({ success: true });
} catch (err) {
res.json({
success: false,
error: err instanceof Error ? err.message : String(err),
});
}
},
);
export default router;
@@ -7,7 +7,6 @@ import { authLogger } from "../../utils/logger.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
import {
createCurrentCredentialRepository,
createCurrentDismissedAlertRepository,
createCurrentHostRepository,
createCurrentRecentActivityRepository,
createCurrentSettingsRepository,
@@ -70,7 +69,6 @@ export async function resetUserPassword(
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
// A plugin holding this user's data (snippets and anything else keyed by
+74 -43
View File
@@ -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;
},
};
}
+5 -1
View File
@@ -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,
+45
View File
@@ -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;
}
@@ -1,93 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { DismissedAlertRepository } from "../../../database/repositories/dismissed-alert-repository.js";
describe("DismissedAlertRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<DismissedAlertRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new DismissedAlertRepository(context, onWrite);
}
it("creates, lists, and finds dismissed alerts by user", async () => {
const repo = await createRepository();
await repo.create("user-1", "alert-1");
await repo.create("user-1", "alert-2");
await repo.create("user-2", "alert-3");
expect(await repo.listAlertIdsByUserId("user-1")).toEqual([
"alert-1",
"alert-2",
]);
expect((await repo.findForUser("user-1", "alert-1"))?.alertId).toBe(
"alert-1",
);
expect(await repo.findForUser("user-1", "alert-3")).toBeNull();
expect(
(await repo.listByUserId("user-1")).map((row) => row.alertId),
).toEqual(["alert-1", "alert-2"]);
});
it("creates import alerts without duplicating user alert pairs", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await expect(
repo.createForImport("user-1", "alert-1", "2026-01-01T00:00:00.000Z"),
).resolves.toBe(true);
await expect(
repo.createForImport("user-1", "alert-1", "2026-01-02T00:00:00.000Z"),
).resolves.toBe(false);
const alerts = await repo.listByUserId("user-1");
expect(alerts).toHaveLength(1);
expect(alerts[0]).toMatchObject({
alertId: "alert-1",
dismissedAt: "2026-01-01T00:00:00.000Z",
});
expect(writeCount).toBe(1);
});
it("deletes dismissed alerts and only triggers writes for changed rows", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create("user-1", "alert-1");
await repo.create("user-1", "alert-2");
await repo.create("user-2", "alert-3");
expect(writeCount).toBe(3);
expect(await repo.deleteForUser("user-1", "missing")).toBe(false);
expect(writeCount).toBe(3);
expect(await repo.deleteForUser("user-1", "alert-1")).toBe(true);
expect(writeCount).toBe(4);
expect(await repo.listAlertIdsByUserId("user-1")).toEqual(["alert-2"]);
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(writeCount).toBe(5);
expect(await repo.deleteByUserId("user-1")).toBe(0);
expect(writeCount).toBe(5);
});
});
@@ -1,69 +0,0 @@
/**
* POST /notification-channels/:id/test sends through deliverNotification,
* the same path an automation's notify step takes.
*/
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
deliver: vi.fn(),
channel: null as null | { id: number; type: string; config: string },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: { userId?: string }, _res: unknown, next: () => void) => {
req.userId = "user-1";
next();
},
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentNotificationChannelRepository: () => ({
findNotificationChannelForUser: async () => h.channel,
}),
}));
vi.mock("../../../utils/notification-sender.js", () => ({
deliverNotification: h.deliver,
}));
const { default: router } =
await import("../../../database/routes/notification-channels-routes.js");
const app = express().use(express.json()).use(router);
beforeEach(() => {
h.deliver.mockReset();
h.channel = { id: 3, type: "ntfy", config: '{"url":"https://ntfy.sh"}' };
});
describe("POST /notification-channels/:id/test", () => {
it("delivers a test notification to the channel", async () => {
h.deliver.mockResolvedValue(undefined);
const response = await request(app).post("/notification-channels/3/test");
expect(response.body).toEqual({ success: true });
expect(h.deliver).toHaveBeenCalledWith(
h.channel,
expect.objectContaining({ severity: "info" }),
);
});
it("reports the delivery error", async () => {
h.deliver.mockRejectedValue(new Error("ntfy channel is missing a topic"));
const response = await request(app).post("/notification-channels/3/test");
expect(response.body).toEqual({
success: false,
error: "ntfy channel is missing a topic",
});
});
it("answers 404 for a channel the user does not own", async () => {
h.channel = null;
const response = await request(app).post("/notification-channels/9/test");
expect(response.status).toBe(404);
});
});
@@ -29,7 +29,6 @@ vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({}),
createCurrentSshCredentialUsageRepository: deletingRepo("usage"),
createCurrentRecentActivityRepository: deletingRepo("activity"),
createCurrentDismissedAlertRepository: deletingRepo("alerts"),
createCurrentHostRepository: deletingRepo("hosts"),
createCurrentCredentialRepository: deletingRepo("credentials"),
}));
@@ -126,7 +125,6 @@ describe("resetUserPassword", () => {
expect(calls.deletedFor).toEqual([
"usage:user-1",
"activity:user-1",
"alerts:user-1",
"hosts:user-1",
"credentials:user-1",
]);
Binary file not shown.
+8 -1
View File
@@ -412,6 +412,14 @@ export const ROWS: Record<string, Row[]> = {
enabled: 1,
},
],
dismissed_alerts: [
{
id: 1,
user_id: USERS.admin,
alert_id: "d4-dismissed-announcement",
dismissed_at: T,
},
],
snippet_folders: [
{
@@ -839,6 +847,5 @@ export const CORE_TABLES = new Set([
"ssh_data",
"host_access",
"user_preferences",
"notification_channels",
"settings",
]);
+81
View File
@@ -337,6 +337,14 @@ ALTER TABLE `user_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
SELECT 1;
-- ==== core 0058_boring_avengers ====
-- dismissed_alerts and notification_channels moved to the alerts plugin. It
-- renames the first into its own namespace and copies the second at
-- activation, both after this migration, so a real DROP TABLE here would
-- delete them first. notification_channels stays in place, unused, until
-- 3.0.0.
SELECT 1;
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.
@@ -634,6 +642,23 @@ CREATE TABLE IF NOT EXISTS `automation_channels` (
);
RENAME TABLE `automation_channels` TO `p_automations_channels`;
-- ==== plugin automations 0002_unlink_channels.sql ====
-- automations 0002: unlink_channels
-- Hand-written: channels moved from core's notification_channels to the
-- alerts plugin, so the link table drops its foreign key to the old table.
-- The constraint's name differs between installs, so the table is rebuilt.
CREATE TABLE IF NOT EXISTS `p_automations_channels_v2` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`automation_id` int NOT NULL,
`channel_id` int NOT NULL,
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE
);
INSERT INTO `p_automations_channels_v2` (`id`, `automation_id`, `channel_id`) SELECT `id`, `automation_id`, `channel_id` FROM `p_automations_channels`;
DROP TABLE `p_automations_channels`;
RENAME TABLE `p_automations_channels_v2` TO `p_automations_channels`;
CREATE UNIQUE INDEX `idx_automation_channels_pair` ON `p_automations_channels` (`automation_id`, `channel_id`);
-- ==== plugin homepage 0001_adopt_homepage_tables.sql ====
-- homepage 0001: adopt_homepage_tables
-- Generated by termix-plugin migrations. Review before committing.
@@ -729,6 +754,62 @@ CREATE TABLE IF NOT EXISTS `ai_proposals` (
);
RENAME TABLE `ai_proposals` TO `p_ai_proposals`;
-- ==== plugin alerts 0001_create_alerts.sql ====
-- alerts 0001: create_alerts
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `p_alerts_channels` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`name` text NOT NULL,
`type` varchar(32) NOT NULL,
`config` text,
`enabled` boolean NOT NULL DEFAULT true,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
CREATE INDEX `idx_alerts_channels_user` ON `p_alerts_channels` (`user_id`);
CREATE TABLE IF NOT EXISTS `p_alerts_items` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`source` varchar(64) NOT NULL,
`category` varchar(128) NOT NULL,
`severity` varchar(16) NOT NULL,
`title` text NOT NULL,
`body` text,
`link` text,
`context` text,
`dedupe_key` varchar(255),
`deliveries` text,
`read_at` varchar(255),
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
CREATE INDEX `idx_alerts_items_user` ON `p_alerts_items` (`user_id`, `created_at`);
CREATE INDEX `idx_alerts_items_unread` ON `p_alerts_items` (`user_id`, `read_at`);
CREATE INDEX `idx_alerts_items_dedupe` ON `p_alerts_items` (`user_id`, `dedupe_key`);
CREATE TABLE IF NOT EXISTS `p_alerts_rules` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`name` text NOT NULL,
`pattern` varchar(128) NOT NULL DEFAULT '*',
`min_severity` varchar(16) NOT NULL DEFAULT 'warning',
`channel_ids` text NOT NULL,
`enabled` boolean NOT NULL DEFAULT true,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
CREATE INDEX `idx_alerts_rules_user` ON `p_alerts_rules` (`user_id`);
CREATE TABLE IF NOT EXISTS `dismissed_alerts` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`alert_id` text NOT NULL,
`dismissed_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `dismissed_alerts` TO `p_alerts_dismissed`;
-- ==== plugin file-manager 0001_adopt_file_manager_tables.sql ====
-- file-manager 0001: adopt_file_manager_tables
-- Generated by termix-plugin migrations. Review before committing.
+83
View File
@@ -363,6 +363,14 @@ ALTER TABLE "user_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIM
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
SELECT 1;
-- ==== core 0059_square_korg ====
-- dismissed_alerts and notification_channels moved to the alerts plugin. It
-- renames the first into its own namespace and copies the second at
-- activation, both after this migration, so a real DROP TABLE here would
-- delete them first. notification_channels stays in place, unused, until
-- 3.0.0.
SELECT 1;
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.
@@ -682,6 +690,24 @@ CREATE TABLE IF NOT EXISTS "automation_channels" (
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "automation_channels" ("automation_id", "channel_id");
ALTER TABLE "automation_channels" RENAME TO "p_automations_channels";
-- ==== plugin automations 0002_unlink_channels.sql ====
-- automations 0002: unlink_channels
-- Hand-written: channels moved from core's notification_channels to the
-- alerts plugin, so the link table drops its foreign key to the old table.
-- The constraint's name differs between installs, so the table is rebuilt.
CREATE TABLE IF NOT EXISTS "p_automations_channels_v2" (
"id" serial PRIMARY KEY,
"automation_id" integer NOT NULL,
"channel_id" integer NOT NULL,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
INSERT INTO "p_automations_channels_v2" ("id", "automation_id", "channel_id") SELECT "id", "automation_id", "channel_id" FROM "p_automations_channels";
DROP TABLE "p_automations_channels";
ALTER TABLE "p_automations_channels_v2" RENAME TO "p_automations_channels";
SELECT setval(pg_get_serial_sequence('p_automations_channels', 'id'), COALESCE((SELECT MAX("id") FROM "p_automations_channels"), 1));
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "p_automations_channels" ("automation_id", "channel_id");
-- ==== plugin homepage 0001_adopt_homepage_tables.sql ====
-- homepage 0001: adopt_homepage_tables
-- Generated by termix-plugin migrations. Review before committing.
@@ -784,6 +810,63 @@ CREATE INDEX IF NOT EXISTS "idx_ai_proposals_user" ON "ai_proposals" ("user_id",
CREATE INDEX IF NOT EXISTS "idx_ai_proposals_conversation" ON "ai_proposals" ("conversation_id");
ALTER TABLE "ai_proposals" RENAME TO "p_ai_proposals";
-- ==== plugin alerts 0001_create_alerts.sql ====
-- alerts 0001: create_alerts
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "p_alerts_channels" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"name" text NOT NULL,
"type" varchar(32) NOT NULL,
"config" text,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_alerts_channels_user" ON "p_alerts_channels" ("user_id");
CREATE TABLE IF NOT EXISTS "p_alerts_items" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"source" varchar(64) NOT NULL,
"category" varchar(128) NOT NULL,
"severity" varchar(16) NOT NULL,
"title" text NOT NULL,
"body" text,
"link" text,
"context" text,
"dedupe_key" varchar(255),
"deliveries" text,
"read_at" varchar(255),
"created_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_alerts_items_user" ON "p_alerts_items" ("user_id", "created_at");
CREATE INDEX IF NOT EXISTS "idx_alerts_items_unread" ON "p_alerts_items" ("user_id", "read_at");
CREATE INDEX IF NOT EXISTS "idx_alerts_items_dedupe" ON "p_alerts_items" ("user_id", "dedupe_key");
CREATE TABLE IF NOT EXISTS "p_alerts_rules" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"name" text NOT NULL,
"pattern" varchar(128) NOT NULL DEFAULT '*',
"min_severity" varchar(16) NOT NULL DEFAULT 'warning',
"channel_ids" text NOT NULL,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_alerts_rules_user" ON "p_alerts_rules" ("user_id");
CREATE TABLE IF NOT EXISTS "dismissed_alerts" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"alert_id" text NOT NULL,
"dismissed_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_dismissed_alerts_user_id" ON "dismissed_alerts" ("user_id");
ALTER TABLE "dismissed_alerts" RENAME TO "p_alerts_dismissed";
-- ==== plugin file-manager 0001_adopt_file_manager_tables.sql ====
-- file-manager 0001: adopt_file_manager_tables
-- Generated by termix-plugin migrations. Review before committing.
+160 -67
View File
@@ -1,27 +1,28 @@
/**
* ctx.notify and ctx.fetch: each is gated on its capability and audited,
* notify only ever reaches the acting user's own channels, and fetch goes
* through the SSRF guard with only the hosts the caller listed.
* ctx.notify and ctx.fetch: each is gated on its capability and audited.
* notify resolves the audience in core and hands the hub plain user ids, and
* fetch goes through the SSRF guard with only the hosts the caller listed.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
PluginNotification,
PluginNotifyHub,
} from "@termix/plugin-sdk/backend";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const state = vi.hoisted(() => ({
channels: new Map<
string,
Array<{
id: number;
name: string;
type: string;
config: string;
enabled: number;
}>
>(),
delivered: [] as Array<{ channelId: number; title: string }>,
failChannel: null as number | null,
users: [
{ id: "alice", isAdmin: true },
{ id: "bob", isAdmin: false },
{ id: "carol", isAdmin: false },
],
permissions: new Map<string, string[]>([
["bob", ["docker.view"]],
["carol", ["docker.view"]],
]),
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
lastTls: null as Record<string, unknown> | null,
lastSignal: null as AbortSignal | null,
@@ -35,28 +36,28 @@ vi.mock("../../database/repositories/factory.js", () => ({
capability,
})),
}),
createCurrentNotificationChannelRepository: () => ({
listNotificationChannels: async (userId: string) =>
state.channels.get(userId) ?? [],
createCurrentUserRepository: () => ({
listAll: async () => state.users,
findById: async (id: string) =>
state.users.find((user) => user.id === id) ?? null,
}),
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async (userId: string, permission: string) =>
state.permissions.get(userId)?.includes(permission) ?? false,
}),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
vi.mock("../../utils/notification-sender.js", () => ({
deliverNotification: async (
channel: { id: number },
notification: { title: string },
) => {
if (channel.id === state.failChannel) throw new Error("HTTP 500");
state.delivered.push({ channelId: channel.id, title: notification.title });
},
}));
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch: async (
url: string,
@@ -74,13 +75,18 @@ vi.mock("../../utils/safe-outbound-fetch.js", () => ({
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import { clearNotifyHub } from "../../plugins/notify-hub.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function contextFor(capabilities: string[]) {
grants.set("demo", capabilities);
function contextFor(
capabilities: string[],
id = "demo",
granted: string[] = capabilities,
) {
grants.set(id, granted);
const manifest = {
id: "demo",
name: "demo",
id,
name: id,
version: "1.0.0",
description: "",
author: { name: "test" },
@@ -89,26 +95,38 @@ function contextFor(capabilities: string[]) {
engine: { termix: ">=2.9.0", api: "1" },
capabilities,
} as PluginManifest;
const handle = createPluginHandle("demo", { activate: () => {} });
const handle = createPluginHandle(id, { activate: () => {} });
return createPluginContext(manifest, handle);
}
function fakeHub() {
const calls: Array<{
source: string;
recipients: string[];
notification: PluginNotification;
}> = [];
const hub: PluginNotifyHub = {
deliver: async (input) => {
calls.push(input);
return {
recipients: input.recipients.length,
delivered: 0,
failures: [],
};
},
channels: async (userId) => [
{ id: 1, name: `channel of ${userId}`, type: "webhook", enabled: true },
],
};
return { hub, calls };
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
state.channels.clear();
state.delivered.length = 0;
state.failChannel = null;
clearNotifyHub();
state.fetchCalls.length = 0;
state.channels.set("alice", [
{ id: 1, name: "ops", type: "webhook", config: "{}", enabled: 1 },
{ id: 2, name: "muted", type: "ntfy", config: "{}", enabled: 0 },
{ id: 3, name: "pager", type: "discord", config: "{}", enabled: 1 },
]);
state.channels.set("bob", [
{ id: 9, name: "bob's", type: "webhook", config: "{}", enabled: 1 },
]);
});
describe("ctx.notify", () => {
@@ -123,43 +141,118 @@ describe("ctx.notify", () => {
});
});
it("refuses a call with no acting user", async () => {
it("refuses a send with neither an acting user nor an audience", async () => {
const ctx = contextFor(["notify:send"]);
await expect(
ctx.notify.send([1], { title: "t", body: "b" }),
).rejects.toThrow(/acting user/);
});
it("lists the actor's channels without their config", async () => {
const ctx = contextFor(["notify:send"]);
const channels = await runAsActor("alice", "request", () =>
ctx.notify.channels(),
await expect(ctx.notify.send({ title: "t" })).rejects.toThrow(
/acting user/,
);
expect(channels).toEqual([
{ id: 1, name: "ops", type: "webhook", enabled: true },
{ id: 2, name: "muted", type: "ntfy", enabled: false },
{ id: 3, name: "pager", type: "discord", enabled: true },
]);
});
it("sends only to the actor's enabled channels and reports failures", async () => {
it("reaches nobody while no hub is running", async () => {
const ctx = contextFor(["notify:send"]);
const result = await runAsActor("alice", "request", () =>
ctx.notify.send({ title: "Disk" }),
);
expect(result).toEqual({ recipients: 0, delivered: 0, failures: [] });
expect(
await runAsActor("alice", "request", () => ctx.notify.channels()),
).toEqual([]);
});
it("hands the hub the acting user and the sending plugin by default", async () => {
const { hub, calls } = fakeHub();
contextFor(["notify:hub"], "hub").notify.serve(hub);
const ctx = contextFor(["notify:send"]);
state.failChannel = 3;
const result = await runAsActor("alice", "request", () =>
ctx.notify.send([1, 2, 3, 9], { title: "Disk", body: "full" }),
ctx.notify.send({ title: "Disk", severity: "critical" }),
);
expect(state.delivered).toEqual([{ channelId: 1, title: "Disk" }]);
expect(result).toEqual({
delivered: 1,
failures: [{ channelId: 3, name: "pager", error: "HTTP 500" }],
});
expect(result.recipients).toBe(1);
expect(calls).toEqual([
{
source: "demo",
recipients: ["alice"],
notification: { title: "Disk", severity: "critical" },
},
]);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_notify_send",
success: true,
});
});
it("resolves a named user, admins and a permission in core", async () => {
const { hub, calls } = fakeHub();
contextFor(["notify:hub"], "hub").notify.serve(hub);
const ctx = contextFor(["notify:send"]);
await ctx.notify.send({ title: "a", audience: { userId: "bob" } });
await ctx.notify.send({ title: "b", audience: "admins" });
await ctx.notify.send({
title: "c",
audience: { permission: "docker.view" },
});
expect(calls.map((call) => call.recipients)).toEqual([
["bob"],
["alice"],
["bob", "carol"],
]);
});
it("does not call the hub for an audience that names nobody", async () => {
const { hub, calls } = fakeHub();
contextFor(["notify:hub"], "hub").notify.serve(hub);
const ctx = contextFor(["notify:send"]);
const result = await ctx.notify.send({
title: "x",
audience: { userId: "ghost" },
});
expect(result.recipients).toBe(0);
expect(calls).toEqual([]);
});
it("asks the hub for the acting user's channels", async () => {
const { hub } = fakeHub();
contextFor(["notify:hub"], "hub").notify.serve(hub);
const ctx = contextFor(["notify:send"]);
expect(
await runAsActor("bob", "request", () => ctx.notify.channels()),
).toEqual([
{ id: 1, name: "channel of bob", type: "webhook", enabled: true },
]);
});
it("only serves a hub the manifest declares notify:hub for", () => {
const { hub } = fakeHub();
expect(() =>
contextFor(["notify:send"], "rogue").notify.serve(hub),
).toThrow(/notify:hub/);
});
it("refuses a second hub from another plugin", () => {
contextFor(["notify:hub"], "hub").notify.serve(fakeHub().hub);
expect(() =>
contextFor(["notify:hub"], "other").notify.serve(fakeHub().hub),
).toThrow(/already/);
});
it("stops using a hub once it is revoked or its grant is gone", async () => {
const first = fakeHub();
const revoke = contextFor(["notify:hub"], "hub").notify.serve(first.hub);
const ctx = contextFor(["notify:send"]);
revoke();
await ctx.notify.send({ title: "x", audience: "admins" });
expect(first.calls).toEqual([]);
const second = fakeHub();
contextFor(["notify:hub"], "ungranted", []).notify.serve(second.hub);
await ctx.notify.send({ title: "y", audience: "admins" });
expect(second.calls).toEqual([]);
});
});
describe("ctx.fetch", () => {
@@ -265,6 +265,7 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
["/plugin-api/secret-sources", "d4-source"],
["/plugin-api/session-sharing/rooms", "d4-room"],
["/plugin-api/webauthn/credentials", "d4-passkey", "passkey"],
["/plugin-api/alerts/channels", "d4-channel"],
];
const failures: string[] = [];
for (const [url, marker, as] of checks) {
@@ -500,6 +501,17 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
).toBe("d4-source-token");
});
it("moves every notification channel into alerts, config readable", async () => {
const response = await get(current(), "/plugin-api/alerts/channels/1");
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
name: "d4-channel",
type: "ntfy",
usable: true,
config: { url: "https://ntfy.d4.example/d4" },
});
});
it("keeps every role's permissions", async () => {
const { PermissionManager } =
await import("../../utils/permission-manager.js");
@@ -0,0 +1,223 @@
/**
* The 2.8 to 2.9 channel move. Every notification channel reaches the alerts
* plugin's table with its id, its config sealed with the installation key once
* the owner's data key opens, and nothing left readable in the old table.
* A closed or wrong key leaves the config as it was, and a later run for that
* user finishes the job. Running it twice changes nothing.
*/
import crypto from "node:crypto";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FieldCrypto } from "../../utils/field-crypto.js";
const h = vi.hoisted(() => ({
db: null as unknown,
deks: new Map<string, Buffer>(),
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: (userId: string) => h.deks.get(userId) },
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:v1:${value}`,
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
const { runNotificationChannelMigration } =
await import("../../upgrade/notification-channel-migration.js");
let sqlite: Database.Database;
const SOURCE_DDL = `
CREATE TABLE notification_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
config TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
const TARGET_DDL = `
CREATE TABLE "p_alerts_channels" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"name" text NOT NULL,
"type" text NOT NULL,
"config" text,
"enabled" integer NOT NULL DEFAULT 1,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
function setup({ target }: { target: boolean }) {
sqlite = new Database(":memory:");
sqlite.exec(SOURCE_DDL);
if (target) sqlite.exec(TARGET_DDL);
h.db = drizzle(sqlite);
h.deks.clear();
}
function addChannel(
id: number,
userId: string,
config: Record<string, unknown>,
options: { encrypt?: boolean; enabled?: boolean } = {},
) {
let dek = h.deks.get(userId);
if (!dek) {
dek = crypto.randomBytes(32);
h.deks.set(userId, dek);
}
const text = JSON.stringify(config);
const stored =
options.encrypt === false
? text
: FieldCrypto.encryptField(text, dek, String(id), "config");
sqlite
.prepare(
"INSERT INTO notification_channels (id, user_id, name, type, config, enabled) VALUES (?, ?, ?, ?, ?, ?)",
)
.run(
id,
userId,
`channel-${id}`,
"ntfy",
stored,
options.enabled === false ? 0 : 1,
);
}
const targetRows = () =>
sqlite
.prepare(
'SELECT id, user_id, name, type, config, enabled FROM "p_alerts_channels" ORDER BY id',
)
.all() as Array<{
id: number;
user_id: string;
name: string;
type: string;
config: string;
enabled: number;
}>;
const sealed = (config: Record<string, unknown>) =>
`sysenc:v1:${JSON.stringify(config)}`;
describe("runNotificationChannelMigration", () => {
beforeEach(() => setup({ target: true }));
it("does nothing until the plugin's table exists", async () => {
setup({ target: false });
addChannel(1, "alice", { url: "https://ntfy.sh", topic: "a" });
expect(await runNotificationChannelMigration()).toEqual({
moved: 0,
sealed: 0,
});
});
it("copies every channel with its id and seals its config", async () => {
addChannel(4, "alice", { url: "https://ntfy.sh", topic: "a", token: "tk" });
addChannel(7, "bob", { url: "https://hooks.example" }, { enabled: false });
expect(await runNotificationChannelMigration()).toEqual({
moved: 2,
sealed: 2,
});
expect(targetRows()).toEqual([
{
id: 4,
user_id: "alice",
name: "channel-4",
type: "ntfy",
config: sealed({ url: "https://ntfy.sh", topic: "a", token: "tk" }),
enabled: 1,
},
{
id: 7,
user_id: "bob",
name: "channel-7",
type: "ntfy",
config: sealed({ url: "https://hooks.example" }),
enabled: 0,
},
]);
// No token is left readable in the old table.
expect(
sqlite.prepare("SELECT config FROM notification_channels").all(),
).toEqual([{ config: "" }, { config: "" }]);
});
it("seals a config 2.8 never encrypted", async () => {
addChannel(
2,
"alice",
{ url: "https://ntfy.sh", topic: "p" },
{
encrypt: false,
},
);
await runNotificationChannelMigration();
expect(targetRows()[0].config).toBe(
sealed({ url: "https://ntfy.sh", topic: "p" }),
);
});
it("waits for a closed key, then seals at that user's login", async () => {
addChannel(3, "carol", { url: "https://ntfy.sh", topic: "c" });
const dek = h.deks.get("carol")!;
h.deks.delete("carol");
expect(await runNotificationChannelMigration()).toEqual({
moved: 1,
sealed: 0,
});
const raw = targetRows()[0].config;
expect(raw.startsWith("sysenc:")).toBe(false);
expect(FieldCrypto.isEncrypted(raw)).toBe(true);
h.deks.set("carol", dek);
expect(await runNotificationChannelMigration("carol")).toEqual({
moved: 0,
sealed: 1,
});
expect(targetRows()[0].config).toBe(
sealed({ url: "https://ntfy.sh", topic: "c" }),
);
});
it("leaves a config the key cannot open as it was", async () => {
addChannel(5, "dave", { url: "https://ntfy.sh", topic: "d" });
h.deks.set("dave", crypto.randomBytes(32));
await runNotificationChannelMigration();
const raw = targetRows()[0].config;
expect(raw.startsWith("sysenc:")).toBe(false);
expect(FieldCrypto.isEncrypted(raw)).toBe(true);
});
it("changes nothing the second time", async () => {
addChannel(1, "alice", { url: "https://ntfy.sh", topic: "a" });
await runNotificationChannelMigration();
const first = targetRows();
expect(await runNotificationChannelMigration()).toEqual({
moved: 0,
sealed: 0,
});
expect(targetRows()).toEqual(first);
});
});
+2
View File
@@ -29,6 +29,7 @@ import { runAcmeSslSettingsMigration } from "./acme-ssl-settings-migration.js";
import { runVaultSettingsMigration } from "./vault-settings-migration.js";
import { runSecretSourcesTokenMigration } from "./secret-sources-token-migration.js";
import { runTotpMigration } from "./totp-migration.js";
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
@@ -55,6 +56,7 @@ const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
["runVaultSettingsMigration", runVaultSettingsMigration],
["runSecretSourcesTokenMigration", runSecretSourcesTokenMigration],
["runTotpMigration", runTotpMigration],
["runNotificationChannelMigration", runNotificationChannelMigration],
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
];
@@ -0,0 +1,186 @@
/**
* Moves 2.8 notification channels into the alerts plugin.
*
* 2.8 kept them in core's notification_channels, each config encrypted under
* its owner's data key. This copies every row into p_alerts_channels with the
* same id, because automations' notify steps and links name it, then empties
* the old config column so no webhook token or ntfy key stays behind.
*
* The plugin reads configs sealed with the installation key, so it can send
* an alert with no user signed in. A config is resealed as soon as its
* owner's data key opens: at boot for most users, at their next password
* login for a 2.8 key only the password unwraps. Until then the channel shows
* as needing to be saved again.
*
* The old table stays until 3.0.0, like the old columns. Idempotent.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { resolveDatabaseDialect } from "../database/db/dialect.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { LazyFieldEncryption } from "../utils/lazy-field-encryption.js";
import {
encryptSystemSecret,
isSystemEncrypted,
} from "../utils/system-secret-crypto.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const TARGET = "p_alerts_channels";
const SOURCE = "notification_channels";
interface LegacyChannel {
id: number;
user_id: string;
name: string;
type: string;
config: string | null;
enabled: unknown;
created_at: string | null;
}
export interface NotificationChannelMigrationResult {
/** Channels copied into the plugin's table. */
moved: number;
/** Configs sealed with the installation key. */
sealed: number;
}
async function tableExists(name: string): Promise<boolean> {
try {
await selectRows(sql`SELECT 1 FROM ${sql.identifier(name)} WHERE 1 = 0`);
return true;
} catch {
return false;
}
}
function isOn(value: unknown): boolean {
return value === true || value === 1 || value === "1" || value === "t";
}
/** Explicit ids leave a Postgres sequence behind them. */
async function syncSequence(): Promise<void> {
if (resolveDatabaseDialect() !== "postgres") return;
await runStatement(
sql`SELECT setval(pg_get_serial_sequence(${TARGET}, 'id'), (SELECT COALESCE(MAX(id), 1) FROM ${sql.identifier(TARGET)}))`,
);
}
/** The config in plain JSON, or null while the owner's key is closed. */
function openLegacyConfig(row: {
id: number;
user_id: string;
config: string;
}): string | null {
const dek = DataCrypto.getUserDataKey(row.user_id);
if (!dek) return null;
const plain = LazyFieldEncryption.safeGetFieldValue(
row.config,
dek,
String(row.id),
"config",
);
if (!plain) return null;
try {
JSON.parse(plain);
return plain;
} catch {
return null;
}
}
async function copyChannels(
result: NotificationChannelMigrationResult,
): Promise<void> {
if (!(await tableExists(SOURCE))) return;
const rows = await selectRows<LegacyChannel>(
sql`SELECT id, user_id, name, type, config, enabled, created_at FROM ${sql.identifier(SOURCE)} ORDER BY id`,
);
const sqlite = resolveDatabaseDialect() === "sqlite";
let copied = false;
for (const row of rows) {
// Emptied once copied.
if (!row.config) continue;
const taken = await selectRows(
sql`SELECT id FROM ${sql.identifier(TARGET)} WHERE id = ${row.id}`,
);
if (taken.length > 0) continue;
const enabled = isOn(row.enabled);
const createdAt = row.created_at ?? new Date().toISOString();
await runStatement(
sql`INSERT INTO ${sql.identifier(TARGET)} (id, user_id, name, type, config, enabled, created_at, updated_at) VALUES (${row.id}, ${row.user_id}, ${row.name}, ${row.type}, ${row.config}, ${sqlite ? (enabled ? 1 : 0) : enabled}, ${createdAt}, ${createdAt})`,
);
await runStatement(
sql`UPDATE ${sql.identifier(SOURCE)} SET config = '' WHERE id = ${row.id}`,
);
copied = true;
result.moved++;
}
if (copied) await syncSequence();
}
async function sealConfigs(
result: NotificationChannelMigrationResult,
onlyUserId?: string,
): Promise<void> {
const rows = await selectRows<{
id: number;
user_id: string;
config: string | null;
}>(
onlyUserId
? sql`SELECT id, user_id, config FROM ${sql.identifier(TARGET)} WHERE user_id = ${onlyUserId}`
: sql`SELECT id, user_id, config FROM ${sql.identifier(TARGET)}`,
);
for (const row of rows) {
if (!row.config || isSystemEncrypted(row.config)) continue;
try {
const plain = openLegacyConfig({
id: Number(row.id),
user_id: row.user_id,
config: row.config,
});
if (!plain) continue;
await runStatement(
sql`UPDATE ${sql.identifier(TARGET)} SET config = ${await encryptSystemSecret(plain)} WHERE id = ${row.id}`,
);
result.sealed++;
} catch (error) {
databaseLogger.warn("Could not seal a notification channel's config", {
operation: "notification_channel_migration",
channelId: row.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
/**
* Pass a user id to reseal only that user's channels, as a password login
* does once it has opened a data key the boot run could not.
*/
export async function runNotificationChannelMigration(
onlyUserId?: string,
): Promise<NotificationChannelMigrationResult> {
const result: NotificationChannelMigrationResult = { moved: 0, sealed: 0 };
try {
if (!(await tableExists(TARGET))) return result;
if (!onlyUserId) await copyChannels(result);
await sealConfigs(result, onlyUserId);
} catch (error) {
databaseLogger.error("Notification channel migration failed", error, {
operation: "notification_channel_migration",
});
}
if (result.moved > 0 || result.sealed > 0) {
databaseLogger.info("Moved notification channels into the alerts plugin", {
operation: "notification_channel_migration",
...result,
});
}
return result;
}
+3
View File
@@ -10,6 +10,7 @@ import { runOpksshConfigMigration } from "./opkssh-config-migration.js";
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
import { runSessionRecordingDataMigration } from "./session-recording-data-migration.js";
import { runHostMetricsSettingsMigration } from "./host-metrics-settings-migration.js";
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
export async function runPluginDataMoves(): Promise<void> {
await runLdapProviderMigration();
@@ -18,4 +19,6 @@ export async function runPluginDataMoves(): Promise<void> {
// Both set how long history is kept, which the plugin prunes by on activate.
await runSessionRecordingDataMigration();
await runHostMetricsSettingsMigration();
// Automations name channels by id, so they are in place before it starts.
await runNotificationChannelMigration();
}
@@ -1,106 +0,0 @@
import { eq } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import {
createCurrentRepositoryContext,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { notificationChannels } from "../../database/db/schema.js";
import { DataCrypto } from "../data-crypto.js";
import { FieldCrypto } from "../field-crypto.js";
const MIGRATION_FLAG = "notification_channel_config_encrypted_v1";
export interface ChannelConfigEncryptionResult {
encrypted: number;
skipped: number;
}
/**
* Notification channel configs hold ntfy tokens, webhook auth headers and
* Discord webhook URLs, but shipped as plaintext JSON while every other secret
* in the app was field-encrypted. This encrypts the rows already on disk.
*
* Rows whose owner has no usable data key are left alone and retried on a later
* boot; the repository reads tolerate both shapes, so a partial run is safe.
*/
export async function runChannelConfigEncryptionMigration(): Promise<ChannelConfigEncryptionResult | null> {
const settingsRepository = createCurrentSettingsRepository();
try {
if ((await settingsRepository.get(MIGRATION_FLAG)) === "done") {
return null;
}
const { drizzle } = createCurrentRepositoryContext();
const rows = await drizzle
.select({
id: notificationChannels.id,
userId: notificationChannels.userId,
config: notificationChannels.config,
})
.from(notificationChannels);
let encrypted = 0;
let skipped = 0;
let deferred = 0;
for (const row of rows) {
if (!row.config || FieldCrypto.isEncrypted(row.config)) {
skipped++;
continue;
}
let userDataKey: Buffer | null = null;
try {
userDataKey = DataCrypto.getUserDataKey(row.userId);
} catch {
userDataKey = null;
}
// No usable key yet (legacy DEK pending migration); retry on a later boot.
if (!userDataKey) {
deferred++;
continue;
}
const value = DataCrypto.encryptRecord(
"notification_channels",
{ id: row.id, config: row.config },
row.userId,
userDataKey,
).config;
await drizzle
.update(notificationChannels)
.set({ config: value })
.where(eq(notificationChannels.id, row.id));
encrypted++;
}
// Only close the migration out once no row is still waiting on a key, so a
// boot that could not reach some users' keys retries those rows next time.
if (deferred === 0) {
await settingsRepository.set(MIGRATION_FLAG, "done");
}
if (encrypted > 0 || deferred > 0) {
databaseLogger.info(
`Encrypted ${encrypted} notification channel config(s)`,
{
operation: "channel_config_encryption_migration",
encrypted,
skipped,
deferred,
},
);
}
return { encrypted, skipped: skipped + deferred };
} catch (error) {
databaseLogger.warn("Notification channel config encryption failed", {
operation: "channel_config_encryption_error",
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
-1
View File
@@ -38,7 +38,6 @@ class FieldCrypto {
]),
// Channel configs hold ntfy tokens, webhook auth headers and Discord
// webhook URLs, which are credentials like any other.
notification_channels: new Set(["config"]),
};
static encryptField(
-9
View File
@@ -1,5 +1,3 @@
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
export const NOTIFICATION_PRIVATE_ALLOWLIST_KEY =
"notification_private_endpoint_allowlist";
@@ -16,10 +14,3 @@ export function parseNotificationAllowlist(raw: string | null): string[] {
return [];
}
}
export async function readNotificationPrivateAllowlist(): Promise<string[]> {
const raw = await createCurrentSettingsRepository().get(
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
);
return parseNotificationAllowlist(raw);
}
-152
View File
@@ -1,152 +0,0 @@
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
import { readNotificationPrivateAllowlist } from "./notification-egress.js";
const NTFY_PRIORITY: Record<string, number> = {
info: 2,
warning: 3,
critical: 5,
};
export interface ChannelNotification {
title: string;
body: string;
severity: "info" | "warning" | "critical";
context?: {
hostId?: number;
hostName?: string;
sourceId?: number | string;
sourceName?: string;
triggerType?: string;
value?: unknown;
threshold?: unknown;
};
}
const NTFY_TAGS: Record<string, string> = {
info: "information_source",
warning: "warning",
critical: "rotating_light",
};
const DISCORD_COLORS: Record<string, number> = {
info: 3066993,
warning: 16753920,
critical: 15158332,
};
/**
* Sends one notification to one channel and throws on failure, so the caller
* can report which channel failed. Private and loopback targets need both the
* channel's allowPrivateNetwork opt-in and an exact host in the admin
* allowlist.
*/
export async function deliverNotification(
channel: { id: number; type: string; config: string },
notification: ChannelNotification,
): Promise<void> {
let config: Record<string, unknown>;
try {
config = JSON.parse(channel.config) as Record<string, unknown>;
} catch {
throw new Error("Channel configuration is not valid JSON");
}
const allowlist =
config.allowPrivateNetwork === true
? await readNotificationPrivateAllowlist()
: [];
const post = async (
url: string,
init: { method: string; headers: Record<string, string>; body: string },
) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
try {
const response = await safeOutboundFetch(
url,
{ ...init, signal: controller.signal },
allowlist,
);
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`HTTP ${response.status} ${response.statusText}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
);
}
} finally {
clearTimeout(timer);
}
};
const url = typeof config.url === "string" ? config.url.trim() : "";
if (!url) throw new Error("Channel is missing a URL");
const context = notification.context ?? {};
switch (channel.type) {
case "webhook": {
const headers =
config.headers && typeof config.headers === "object"
? (config.headers as Record<string, string>)
: {};
return post(url, {
method: config.method === "PUT" ? "PUT" : "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify({
title: notification.title,
hostName: context.hostName,
hostId: context.hostId,
ruleName: context.sourceName ?? notification.title,
ruleId: context.sourceId,
triggerType: context.triggerType,
value: context.value,
threshold: context.threshold,
message: notification.body,
severity: notification.severity,
timestamp: new Date().toISOString(),
}),
});
}
case "ntfy": {
const topic = typeof config.topic === "string" ? config.topic.trim() : "";
if (!topic) throw new Error("ntfy channel is missing a topic");
const headers: Record<string, string> = {
Title: notification.title || "Termix",
Priority: String(NTFY_PRIORITY[notification.severity] ?? 3),
Tags: NTFY_TAGS[notification.severity] ?? "information_source",
};
if (typeof config.token === "string" && config.token) {
headers.Authorization = `Bearer ${config.token}`;
}
return post(`${url.replace(/\/$/, "")}/${topic}`, {
method: "POST",
headers,
body: notification.body || notification.title,
});
}
case "discord": {
const payload: Record<string, unknown> = {
embeds: [
{
title: notification.title || "Termix",
description: notification.body || undefined,
color: DISCORD_COLORS[notification.severity] ?? 3447003,
timestamp: new Date().toISOString(),
},
],
};
if (typeof config.username === "string" && config.username) {
payload.username = config.username;
}
if (typeof config.avatar_url === "string" && config.avatar_url) {
payload.avatar_url = config.avatar_url;
}
return post(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
default:
throw new Error(`Unsupported channel type: ${channel.type}`);
}
}
+2 -11
View File
@@ -1,5 +1,4 @@
import {
createCurrentDismissedAlertRepository,
createCurrentUserDataExportRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
@@ -17,7 +16,6 @@ interface UserExportData {
sshCredentials: unknown[];
/** The user's rows in plugin tables, keyed by table name. */
pluginData: Record<string, unknown[]>;
dismissedAlerts: unknown[];
};
metadata: {
totalRecords: number;
@@ -91,9 +89,6 @@ class UserDataExport {
0,
);
const alerts =
await createCurrentDismissedAlertRepository().listByUserId(userId);
const exportData: UserExportData = {
version: this.EXPORT_VERSION,
exportedAt: new Date().toISOString(),
@@ -103,14 +98,12 @@ class UserDataExport {
sshHosts: processedSshHosts,
sshCredentials: sshCredentialsData,
pluginData,
dismissedAlerts: alerts,
},
metadata: {
totalRecords:
processedSshHosts.length +
sshCredentialsData.length +
pluginRowCount +
alerts.length,
pluginRowCount,
encrypted: format === "encrypted",
exportType: scope,
},
@@ -182,7 +175,7 @@ class UserDataExport {
if (dataObj.userData) {
const userData = dataObj.userData as Record<string, unknown>;
const requiredFields = ["sshHosts", "sshCredentials", "dismissedAlerts"];
const requiredFields = ["sshHosts", "sshCredentials"];
for (const field of requiredFields) {
if (!Array.isArray(userData[field])) {
errors.push(`Missing or invalid userData.${field} field`);
@@ -213,7 +206,6 @@ class UserDataExport {
sshHosts: number;
sshCredentials: number;
pluginRows: number;
dismissedAlerts: number;
};
encrypted: boolean;
} {
@@ -229,7 +221,6 @@ class UserDataExport {
(total, rows) => total + rows.length,
0,
),
dismissedAlerts: data.userData.dismissedAlerts.length,
},
encrypted: data.metadata.encrypted,
};