FN-6953: honor unsaved ntfy config in tests

Let ntfy test notifications use the current Settings form values before they are saved.

- Send unsaved ntfy enabled/topic/server/token values from all Settings ntfy test buttons.
- Resolve request-scoped ntfy config on the backend for general, message, and room test notifications.
- Publish message and room ntfy tests directly with the resolved config instead of the active persisted notification service.
- Cover unsaved ntfy test behavior across Settings UI and API routes.

Files changed:
 .changeset/fn-6953-ntfy-test-unsaved-config.md     |   5 +
 .../dashboard/app/components/SettingsModal.css     |   5 +-
 .../dashboard/app/components/SettingsModal.tsx     |  21 +-
 .../components/__tests__/SettingsModal.test.tsx    |  60 +++-
 .../src/__tests__/routes-settings.test.ts          | 181 ++++++++---
 .../src/routes/register-settings-memory-routes.ts  | 355 +++++++++------------
 6 files changed, 380 insertions(+), 247 deletions(-)

Fusion-Task-Id: FN-6953

Fusion-Task-Lineage: 90557168-3b11-4ab4-b5c1-4261225600ba
This commit is contained in:
gsxdsm
2026-06-23 13:01:06 -07:00
parent 2954b07965
commit 7b60539006
6 changed files with 381 additions and 248 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix ntfy test notifications to honor unsaved Settings form config so users can enable ntfy, enter a valid topic/server/token, and send a test notification before saving.

View File

@@ -696,13 +696,16 @@ The embedded title reads like other embedded-view titles (Planning modal-header-
}
}
/*
FNXC:SettingsMobile 2026-06-23-09:02:
Settings section headings should preserve hierarchy through spacing and type only. Avoid per-heading divider borders so mobile and desktop shared Settings sections keep the lighter scrollbar-focused chrome contract.
*/
.settings-section-heading {
font-size: 14px;
font-weight: 600;
padding: var(--space-lg) 0 var(--space-md);
margin: 0 0 var(--space-md);
color: var(--text);
border-bottom: 1px solid var(--border);
}
/* First heading inside the section drops top padding to remove a redundant

View File

@@ -1851,17 +1851,22 @@ export function SettingsModal({
return next;
});
try {
/*
FNXC:Notifications 2026-06-23-08:49:
Settings notification tests must send the current unsaved ntfy form values for every ntfy test affordance. Users validate the exact topic/server/token they just typed before saving, so message/room test requests carry the same request-scoped config as the general ntfy test.
*/
const currentNtfyConfig = {
ntfyEnabled: form.ntfyEnabled,
ntfyTopic: form.ntfyTopic,
...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}),
...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}),
};
const config = providerId === "ntfy"
? {
ntfyEnabled: form.ntfyEnabled,
ntfyTopic: form.ntfyTopic,
...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}),
...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}),
}
? currentNtfyConfig
: providerId === "ntfy-message"
? { messageEventType: "message:agent-to-user" }
? { ...currentNtfyConfig, messageEventType: "message:agent-to-user" }
: providerId === "ntfy-room"
? { messageEventType: "message:room" }
? { ...currentNtfyConfig, messageEventType: "message:room" }
: {
webhookUrl: form.webhookUrl,
webhookFormat: form.webhookFormat || "generic",

View File

@@ -4895,6 +4895,54 @@ describe("SettingsModal", () => {
});
});
it("sends unsaved ntfy form config before saving", async () => {
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: false, ntfyTopic: undefined });
renderModal();
await waitForSettingsModalReady();
await openNotificationsSection();
await user.click(screen.getByLabelText("Enable"));
await user.type(screen.getByLabelText("ntfy Topic"), "fresh-topic");
await user.click(screen.getByText("Advanced"));
await user.type(screen.getByLabelText("Custom ntfy server URL (optional)"), "https://ntfy.override.example//");
await user.type(screen.getByLabelText("Access token (optional)"), "override-token");
await user.click(screen.getByRole("button", { name: /Test notification/ }));
await waitFor(() => {
expect(mockTestNotification).toHaveBeenCalledWith(
"ntfy",
expect.objectContaining({
ntfyEnabled: true,
ntfyTopic: "fresh-topic",
ntfyBaseUrl: "https://ntfy.override.example//",
ntfyAccessToken: "override-token",
}),
undefined,
);
});
expect(mockUpdateSettings).not.toHaveBeenCalled();
expect(mockUpdateGlobalSettings).not.toHaveBeenCalled();
});
it("keeps ntfy test disabled until the current form has a valid topic", async () => {
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: false, ntfyTopic: undefined });
renderModal();
await waitForSettingsModalReady();
await openNotificationsSection();
await user.click(screen.getByLabelText("Enable"));
const testButton = screen.getByRole("button", { name: /Test notification/ });
expect(testButton).toBeDisabled();
await user.type(screen.getByLabelText("ntfy Topic"), "bad topic!");
expect(testButton).toBeDisabled();
expect(mockTestNotification).not.toHaveBeenCalled();
await user.clear(screen.getByLabelText("ntfy Topic"));
await user.type(screen.getByLabelText("ntfy Topic"), "fresh-topic");
expect(testButton).toBeEnabled();
});
it("clears a saved ntfy access token via global null-as-delete semantics", async () => {
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
@@ -4929,7 +4977,11 @@ describe("SettingsModal", () => {
await waitFor(() => {
expect(mockTestNotification).toHaveBeenCalledWith(
"ntfy",
{ messageEventType: "message:agent-to-user" },
expect.objectContaining({
messageEventType: "message:agent-to-user",
ntfyEnabled: true,
ntfyTopic: "test-topic",
}),
undefined,
);
});
@@ -4953,7 +5005,11 @@ describe("SettingsModal", () => {
await waitFor(() => {
expect(mockTestNotification).toHaveBeenCalledWith(
"ntfy",
{ messageEventType: "message:room" },
expect.objectContaining({
messageEventType: "message:room",
ntfyEnabled: true,
ntfyTopic: "test-topic",
}),
undefined,
);
});

View File

@@ -1772,6 +1772,37 @@ describe("POST /settings/test-ntfy", () => {
expect(url).toBe("https://ntfy.override.example/my-topic");
});
it("uses unsaved request ntfy config when saved settings are disabled", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: false,
ntfyTopic: undefined,
ntfyBaseUrl: "https://ntfy.saved.example",
ntfyAccessToken: "saved-token",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-ntfy",
JSON.stringify({
ntfyEnabled: true,
ntfyTopic: "fresh-topic",
ntfyBaseUrl: "https://ntfy.override.example//",
ntfyAccessToken: "override-token",
}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).not.toHaveBeenCalled();
expect(store.updateGlobalSettings).not.toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledTimes(1);
const url = fetchSpy.mock.calls[0]?.[0] as string;
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
expect(url).toBe("https://ntfy.override.example/fresh-topic");
expect(options.headers).toHaveProperty("Authorization", "Bearer override-token");
});
it("falls back to saved ntfyBaseUrl when request override is blank", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
@@ -1973,69 +2004,82 @@ describe("POST /settings/test-notification", () => {
);
});
it("ntfy provider dispatches a message-event pipeline test when messageEventType is provided", async () => {
const dispatchSpy = vi.fn().mockResolvedValue(undefined);
mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy });
it("ntfy provider sends a message-event test with unsaved config when messageEventType is provided", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyEnabled: false,
ntfyTopic: "saved-topic",
ntfyBaseUrl: "https://ntfy.saved.example",
ntfyAccessToken: "saved-token",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-notification",
JSON.stringify({ providerId: "ntfy", messageEventType: "message:agent-to-user" }),
JSON.stringify({
providerId: "ntfy",
config: {
messageEventType: "message:agent-to-user",
ntfyEnabled: true,
ntfyTopic: "fresh-message-topic",
ntfyBaseUrl: "https://ntfy.message.example//",
ntfyAccessToken: "message-token",
},
}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(dispatchSpy).toHaveBeenCalledWith(
"message:agent-to-user",
expect.objectContaining({
event: "message:agent-to-user",
metadata: expect.objectContaining({
fromId: "system",
toId: "user",
preview: "Fusion test message notification",
}),
}),
);
expect(fetchSpy).not.toHaveBeenCalled();
expect(mockGetActiveNotificationService).not.toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledTimes(1);
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.message.example/fresh-message-topic");
expect(options.headers).toMatchObject({
Title: "New message from Fusion",
Priority: "high",
Authorization: "Bearer message-token",
});
expect(options.body).toBe("Fusion → you: Fusion test message notification");
});
it("ntfy provider dispatches a room message-event pipeline test when messageEventType is message:room", async () => {
const dispatchSpy = vi.fn().mockResolvedValue(undefined);
mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy });
it("ntfy provider sends a room message-event test with unsaved config when messageEventType is message:room", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyEnabled: false,
ntfyTopic: "saved-topic",
ntfyBaseUrl: "https://ntfy.saved.example",
ntfyAccessToken: "saved-token",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-notification",
JSON.stringify({ providerId: "ntfy", messageEventType: "message:room" }),
JSON.stringify({
providerId: "ntfy",
config: {
messageEventType: "message:room",
ntfyEnabled: true,
ntfyTopic: "fresh-room-topic",
ntfyBaseUrl: "https://ntfy.room.example//",
ntfyAccessToken: "room-token",
},
}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(dispatchSpy).toHaveBeenCalledWith(
"message:room",
expect.objectContaining({
event: "message:room",
metadata: expect.objectContaining({
roomId: "test-room",
roomName: "Test Room",
senderName: "Fusion",
preview: "Fusion test room notification",
}),
}),
);
expect(fetchSpy).not.toHaveBeenCalled();
expect(mockGetActiveNotificationService).not.toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledTimes(1);
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.room.example/fresh-room-topic");
expect(options.headers).toMatchObject({
Title: "#Test Room — Fusion",
Priority: "default",
Authorization: "Bearer room-token",
});
expect(options.body).toBe("Fusion in #Test Room: Fusion test room notification");
});
it("ntfy provider uses config override for baseUrl", async () => {
@@ -2057,6 +2101,69 @@ describe("POST /settings/test-notification", () => {
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/my-topic");
});
it("ntfy provider sends with unsaved config when saved settings are disabled", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: false,
ntfyTopic: undefined,
ntfyBaseUrl: "https://ntfy.saved.example",
ntfyAccessToken: "saved-token",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-notification",
JSON.stringify({
providerId: "ntfy",
config: {
ntfyEnabled: true,
ntfyTopic: "fresh-topic",
ntfyBaseUrl: "https://ntfy.override.example//",
ntfyAccessToken: "override-token",
},
}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).not.toHaveBeenCalled();
expect(store.updateGlobalSettings).not.toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledTimes(1);
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/fresh-topic");
expect(options.headers).toHaveProperty("Authorization", "Bearer override-token");
});
it("ntfy provider ignores blank request baseUrl and token overrides", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "saved-topic",
ntfyBaseUrl: "https://ntfy.saved.example",
ntfyAccessToken: "saved-token",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-notification",
JSON.stringify({
providerId: "ntfy",
config: {
ntfyEnabled: true,
ntfyTopic: "fresh-topic",
ntfyBaseUrl: " ",
ntfyAccessToken: " ",
},
}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.saved.example/fresh-topic");
expect(options.headers).toHaveProperty("Authorization", "Bearer saved-token");
});
it("ntfy provider sends Authorization header from saved or override token", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,

View File

@@ -47,7 +47,6 @@ import {
import {
buildSessionSkillContextSync,
createFnAgent as engineCreateFnAgent,
getActiveNotificationService,
probeWorktrunk,
resolveWorktrunkBinary,
} from "@fusion/engine";
@@ -2044,84 +2043,160 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
* Returns the user's global pi extension settings from ~/.pi/agent/settings.json.
* Includes packages, extension paths, skill paths, prompt template paths, and theme paths.
*/
router.post("/settings/test-ntfy", async (req, res) => {
const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => {
const trimmed = value.trim();
if (!trimmed) {
throw badRequest("ntfy server URL cannot be empty");
}
const normalizeHttpUrl = (value: string, fieldName: string): string => {
const trimmed = value.trim();
if (!trimmed) {
throw badRequest(`${fieldName} cannot be empty`);
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw badRequest(`ntfy server URL from ${source} must be a valid URL`);
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw badRequest(`${fieldName} must be a valid URL`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw badRequest("ntfy server URL must use http:// or https://");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw badRequest(`${fieldName} must use http:// or https://`);
}
return trimmed.replace(/\/+$/, "");
return trimmed;
};
const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => {
const normalized = normalizeHttpUrl(value, `ntfy server URL from ${source}`);
return normalized.replace(/\/+$/, "");
};
const getOwnValue = (source: Record<string, unknown>, key: string): unknown => (
Object.prototype.hasOwnProperty.call(source, key) ? source[key] : undefined
);
const getRequestNtfyValue = (body: Record<string, unknown>, config: Record<string, unknown>, key: string): unknown => {
const configValue = getOwnValue(config, key);
return configValue !== undefined ? configValue : getOwnValue(body, key);
};
type NtfyTestMessageEventType = "message:agent-to-user" | "message:agent-to-agent" | "message:room";
function resolveEffectiveNtfyTestConfig(
settings: Record<string, unknown>,
body: Record<string, unknown>,
config: Record<string, unknown> = {},
): { topic: string; ntfyBaseUrl: string; ntfyAccessToken?: string } {
/*
FNXC:Notifications 2026-06-23-08:34:
Test sends must honor unsaved Settings form state because users enable ntfy, enter a topic/server/token, and test before saving. Resolve request-scoped values ahead of persisted settings without persisting or logging tokens.
FNXC:Notifications 2026-06-23-10:21:
Every ntfy test affordance, including message and room tests, must publish with the request-scoped topic/server/token instead of the active notification service's persisted provider state.
*/
const enabledOverride = getRequestNtfyValue(body, config, "ntfyEnabled");
if (enabledOverride !== undefined && enabledOverride !== null && typeof enabledOverride !== "boolean") {
throw badRequest("ntfy enabled must be a boolean");
}
const ntfyEnabled = typeof enabledOverride === "boolean" ? enabledOverride : settings.ntfyEnabled === true;
if (!ntfyEnabled) {
throw badRequest("ntfy notifications are not enabled");
}
const topicOverride = getRequestNtfyValue(body, config, "ntfyTopic");
if (topicOverride !== undefined && topicOverride !== null && typeof topicOverride !== "string") {
throw badRequest("ntfy topic must be a string");
}
const topic = typeof topicOverride === "string" ? topicOverride : settings.ntfyTopic;
if (typeof topic !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) {
throw badRequest("ntfy topic is not configured or invalid");
}
const baseUrlOverride = getRequestNtfyValue(body, config, "ntfyBaseUrl");
if (baseUrlOverride !== undefined && baseUrlOverride !== null && typeof baseUrlOverride !== "string") {
throw badRequest("ntfy server URL must be a string");
}
const requestBaseUrl = typeof baseUrlOverride === "string" && baseUrlOverride.trim()
? normalizeNtfyBaseUrl(baseUrlOverride, "request")
: undefined;
const storedBaseUrl = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim()
? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings")
: undefined;
const tokenOverride = getRequestNtfyValue(body, config, "ntfyAccessToken");
if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") {
throw badRequest("ntfy access token must be a string");
}
const requestToken = typeof tokenOverride === "string" && tokenOverride.trim()
? tokenOverride.trim()
: undefined;
const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim()
? settings.ntfyAccessToken.trim()
: undefined;
return {
topic,
ntfyBaseUrl: requestBaseUrl ?? storedBaseUrl ?? "https://ntfy.sh",
ntfyAccessToken: requestToken ?? storedToken,
};
}
async function sendNtfyTestNotification(
options: { topic: string; ntfyBaseUrl: string; ntfyAccessToken?: string; messageEventType?: NtfyTestMessageEventType },
): Promise<void> {
const contentByEvent: Record<NtfyTestMessageEventType | "default", { title: string; message: string; priority: "default" | "high" }> = {
default: {
title: "Fusion test notification",
message: "Fusion test notification — your notifications are working!",
priority: "default",
},
"message:agent-to-user": {
title: "New message from Fusion",
message: "Fusion → you: Fusion test message notification",
priority: "high",
},
"message:agent-to-agent": {
title: "Fusion → recipient",
message: "Fusion messaged recipient: Fusion test message notification",
priority: "default",
},
"message:room": {
title: "#Test Room — Fusion",
message: "Fusion in #Test Room: Fusion test room notification",
priority: "default",
},
};
const content = contentByEvent[options.messageEventType ?? "default"];
const headers: Record<string, string> = {
Title: content.title,
Priority: content.priority,
"Content-Type": "text/plain",
};
if (options.ntfyAccessToken) {
headers.Authorization = `Bearer ${options.ntfyAccessToken}`;
}
const response = await fetch(`${options.ntfyBaseUrl}/${options.topic}`, {
method: "POST",
headers,
body: content.message,
});
if (!response.ok) {
throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`);
}
}
router.post("/settings/test-ntfy", async (req, res) => {
try {
const body = (req.body ?? {}) as Record<string, unknown>;
const configValue = body.config;
if (configValue !== undefined && (typeof configValue !== "object" || configValue === null || Array.isArray(configValue))) {
throw badRequest("config must be an object when provided");
}
const config = (configValue ?? {}) as Record<string, unknown>;
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
// Validate ntfy is enabled
if (!settings.ntfyEnabled) {
throw badRequest("ntfy notifications are not enabled");
}
// Validate topic exists and matches required format
const topic = settings.ntfyTopic;
if (!topic || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) {
throw badRequest("ntfy topic is not configured or invalid");
}
const overrideValue = req.body?.ntfyBaseUrl;
if (overrideValue !== undefined && overrideValue !== null && typeof overrideValue !== "string") {
throw badRequest("ntfy server URL must be a string");
}
const requestOverride = typeof overrideValue === "string" && overrideValue.trim()
? normalizeNtfyBaseUrl(overrideValue, "request")
: undefined;
const storedServer = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim()
? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings")
: undefined;
const tokenOverride = req.body?.ntfyAccessToken;
if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") {
throw badRequest("ntfy access token must be a string");
}
const requestToken = typeof tokenOverride === "string" && tokenOverride.trim()
? tokenOverride.trim()
: undefined;
const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim()
? settings.ntfyAccessToken.trim()
: undefined;
const ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh";
const url = `${ntfyBaseUrl}/${topic}`;
const headers: Record<string, string> = {
"Title": "Fusion test notification",
"Priority": "default",
"Content-Type": "text/plain",
};
const ntfyAccessToken = requestToken ?? storedToken;
if (ntfyAccessToken) {
headers.Authorization = `Bearer ${ntfyAccessToken}`;
}
const response = await fetch(url, {
method: "POST",
headers,
body: "Fusion test notification — your notifications are working!",
});
if (!response.ok) {
throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`);
}
const configForTest = resolveEffectiveNtfyTestConfig(settings as Record<string, unknown>, body, config);
await sendNtfyTestNotification(configForTest);
res.json({ success: true });
} catch (err: unknown) {
@@ -2133,31 +2208,6 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
});
router.post("/settings/test-notification", async (req, res) => {
const normalizeHttpUrl = (value: string, fieldName: string): string => {
const trimmed = value.trim();
if (!trimmed) {
throw badRequest(`${fieldName} cannot be empty`);
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw badRequest(`${fieldName} must be a valid URL`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw badRequest(`${fieldName} must use http:// or https://`);
}
return trimmed;
};
const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => {
const normalized = normalizeHttpUrl(value, `ntfy server URL from ${source}`);
return normalized.replace(/\/+$/, "");
};
try {
const body = (req.body ?? {}) as Record<string, unknown>;
const providerId = body.providerId;
@@ -2176,113 +2226,20 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
if (providerId === "ntfy") {
const requestedMessageEventType = config.messageEventType ?? body.messageEventType;
if (requestedMessageEventType !== undefined) {
if (
requestedMessageEventType !== "message:agent-to-user"
&& requestedMessageEventType !== "message:agent-to-agent"
&& requestedMessageEventType !== "message:room"
) {
throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or message:room");
}
const notificationService = getActiveNotificationService();
if (!notificationService) {
throw new ApiError(502, "Notification service is not active");
}
try {
const messageId = `test-${crypto.randomUUID()}`;
if (requestedMessageEventType === "message:room") {
await notificationService.dispatch(requestedMessageEventType, {
taskId: undefined,
taskTitle: undefined,
event: requestedMessageEventType,
metadata: {
messageId,
roomId: "test-room",
roomName: "Test Room",
senderAgentId: "system",
senderName: "Fusion",
preview: "Fusion test room notification",
type: "room-assistant",
},
});
} else {
const messageType = requestedMessageEventType.split(":")[1] ?? "agent-to-user";
await notificationService.dispatch(requestedMessageEventType, {
taskId: undefined,
taskTitle: undefined,
event: requestedMessageEventType,
metadata: {
messageId,
fromId: "system",
fromType: "agent",
toId: "user",
toType: "user",
type: messageType,
preview: "Fusion test message notification",
},
});
}
res.json({ success: true });
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new ApiError(502, `Failed to dispatch message notification: ${message}`);
}
if (
requestedMessageEventType !== undefined
&& requestedMessageEventType !== "message:agent-to-user"
&& requestedMessageEventType !== "message:agent-to-agent"
&& requestedMessageEventType !== "message:room"
) {
throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or message:room");
}
if (!settings.ntfyEnabled) {
throw badRequest("ntfy notifications are not enabled");
}
const topic = settings.ntfyTopic;
if (!topic || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) {
throw badRequest("ntfy topic is not configured or invalid");
}
const overrideValue = config.ntfyBaseUrl ?? body.ntfyBaseUrl;
if (overrideValue !== undefined && overrideValue !== null && typeof overrideValue !== "string") {
throw badRequest("ntfy server URL must be a string");
}
const requestOverride = typeof overrideValue === "string" && overrideValue.trim()
? normalizeNtfyBaseUrl(overrideValue, "request")
: undefined;
const storedServer = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim()
? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings")
: undefined;
const tokenOverride = config.ntfyAccessToken ?? body.ntfyAccessToken;
if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") {
throw badRequest("ntfy access token must be a string");
}
const requestToken = typeof tokenOverride === "string" && tokenOverride.trim()
? tokenOverride.trim()
: undefined;
const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim()
? settings.ntfyAccessToken.trim()
: undefined;
const ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh";
const url = `${ntfyBaseUrl}/${topic}`;
const headers: Record<string, string> = {
"Title": "Fusion test notification",
"Priority": "default",
"Content-Type": "text/plain",
};
const ntfyAccessToken = requestToken ?? storedToken;
if (ntfyAccessToken) {
headers.Authorization = `Bearer ${ntfyAccessToken}`;
}
const response = await fetch(url, {
method: "POST",
headers,
body: "Fusion test notification — your notifications are working!",
const configForTest = resolveEffectiveNtfyTestConfig(settings as Record<string, unknown>, body, config);
await sendNtfyTestNotification({
...configForTest,
messageEventType: requestedMessageEventType as NtfyTestMessageEventType | undefined,
});
if (!response.ok) {
throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`);
}
res.json({ success: true });
return;
}