feat(FN-2339): add configurable ntfy server support

- Add ntfy base URL to global settings schema/types with persistence coverage and regression tests
- Extend dashboard settings API/routes and Settings modal UI to edit and save a custom ntfy server
- Update notifier runtime to use configured ntfy base URL when sending notifications
- Document the new setting and include a changeset for @runfusion/fusion
This commit is contained in:
Fusion
2026-04-24 15:57:35 -07:00
committed by gsxdsm
parent 3c0fcda6ad
commit bb75e73e24
13 changed files with 385 additions and 10 deletions

View File

@@ -650,7 +650,7 @@ export function updatePiExtensions(disabledIds: string[], projectId?: string): P
});
}
export function testNtfyNotification(config?: { ntfyEnabled?: boolean; ntfyTopic?: string }, projectId?: string): Promise<{ success: boolean }> {
export function testNtfyNotification(config?: { ntfyEnabled?: boolean; ntfyTopic?: string; ntfyBaseUrl?: string }, projectId?: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>(withProjectId("/settings/test-ntfy", projectId), {
method: "POST",
body: config ? JSON.stringify(config) : undefined,

View File

@@ -486,9 +486,11 @@ export function SettingsModal({
setTestNotificationLoading(true);
try {
const ntfyBaseUrl = form.ntfyBaseUrl?.trim();
const result = await testNtfyNotification({
ntfyEnabled: form.ntfyEnabled,
ntfyTopic: form.ntfyTopic,
...(ntfyBaseUrl ? { ntfyBaseUrl } : {}),
}, projectId);
if (result.success) {
addToast("Test notification sent — check your ntfy app!", "success");
@@ -500,7 +502,7 @@ export function SettingsModal({
} finally {
setTestNotificationLoading(false);
}
}, [addToast, form.ntfyEnabled, form.ntfyTopic, projectId]);
}, [addToast, form.ntfyBaseUrl, form.ntfyEnabled, form.ntfyTopic, projectId]);
const handleBackupNow = useCallback(async () => {
setBackupLoading(true);
@@ -2811,6 +2813,25 @@ export function SettingsModal({
Topic must be 1–64 alphanumeric, hyphen, or underscore characters
</small>
)}
<details className="ntfy-advanced-disclosure">
<summary>Advanced</summary>
<div className="ntfy-advanced-content">
<label htmlFor="ntfyBaseUrl">Custom ntfy server URL (optional)</label>
<input
id="ntfyBaseUrl"
type="url"
placeholder="https://ntfy.sh"
value={form.ntfyBaseUrl || ""}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({ ...f, ntfyBaseUrl: value || undefined }));
}}
/>
<small>
Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://.
</small>
</div>
</details>
<button
type="button"
className="btn btn-sm"

View File

@@ -46,6 +46,7 @@ const defaultSettings: SettingsWithAutoArchive = {
defaultPresetBySize: {},
ntfyEnabled: false,
ntfyTopic: undefined,
ntfyBaseUrl: undefined,
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"],
taskStuckTimeoutMs: undefined,
maxStuckKills: 6,
@@ -2298,6 +2299,34 @@ describe("SettingsModal", () => {
expect(screen.getByLabelText("ntfy Topic")).toBeTruthy();
});
it("hides advanced ntfy server field by default and reveals it on disclosure", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitForSettingsModalReady();
fireEvent.click(screen.getByText("Notifications"));
fireEvent.click(screen.getByLabelText("Enable ntfy.sh notifications"));
const disclosure = screen.getByText("Advanced").closest("details") as HTMLDetailsElement;
expect(disclosure.open).toBe(false);
const advancedInput = screen.getByLabelText("Custom ntfy server URL (optional)");
expect(advancedInput).not.toBeVisible();
fireEvent.click(screen.getByText("Advanced"));
expect(disclosure.open).toBe(true);
expect(screen.getByLabelText("Custom ntfy server URL (optional)")).toBeVisible();
});
it("hides advanced ntfy server controls entirely when ntfy is disabled", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitForSettingsModalReady();
fireEvent.click(screen.getByText("Notifications"));
expect(screen.queryByText("Advanced")).toBeNull();
expect(screen.queryByLabelText("Custom ntfy server URL (optional)")).toBeNull();
});
it("toggling ntfyEnabled checkbox sends true in save payload", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitForSettingsModalReady();
@@ -2377,6 +2406,29 @@ describe("SettingsModal", () => {
expect(payload.ntfyTopic).toBeNull(); // null means "explicitly clear this field"
});
it("ntfy custom server submits null when cleared (null-as-delete semantics)", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "existing-topic",
ntfyBaseUrl: "https://ntfy.internal.example",
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitForSettingsModalReady();
fireEvent.click(screen.getByRole("button", { name: /Notifications/ }));
fireEvent.click(screen.getByText("Advanced"));
const baseUrlInput = screen.getByLabelText("Custom ntfy server URL (optional)") as HTMLInputElement;
fireEvent.change(baseUrlInput, { target: { value: "" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.ntfyBaseUrl).toBeNull();
});
it("ntfy topic shows validation error for invalid input", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitForSettingsModalReady();
@@ -2914,6 +2966,49 @@ describe("SettingsModal", () => {
expect(testNtfyNotification).toHaveBeenCalledWith({ ntfyEnabled: true, ntfyTopic: "my-valid-topic" }, undefined);
});
it("Test notification call includes custom ntfy server when populated", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitForSettingsModalReady();
fireEvent.click(screen.getByText("Notifications"));
fireEvent.click(screen.getByLabelText("Enable ntfy.sh notifications"));
const topicInput = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
fireEvent.change(topicInput, { target: { value: "my-valid-topic" } });
fireEvent.click(screen.getByText("Advanced"));
const baseUrlInput = screen.getByLabelText("Custom ntfy server URL (optional)") as HTMLInputElement;
fireEvent.change(baseUrlInput, { target: { value: "https://ntfy.internal.example/" } });
fireEvent.click(screen.getByRole("button", { name: "Test notification" }));
await waitFor(() => expect(testNtfyNotification).toHaveBeenCalledTimes(1));
expect(testNtfyNotification).toHaveBeenCalledWith(
{
ntfyEnabled: true,
ntfyTopic: "my-valid-topic",
ntfyBaseUrl: "https://ntfy.internal.example/",
},
undefined,
);
});
it("Test notification call omits custom ntfy server when blank", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitForSettingsModalReady();
fireEvent.click(screen.getByText("Notifications"));
fireEvent.click(screen.getByLabelText("Enable ntfy.sh notifications"));
const topicInput = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
fireEvent.change(topicInput, { target: { value: "my-valid-topic" } });
fireEvent.click(screen.getByRole("button", { name: "Test notification" }));
await waitFor(() => expect(testNtfyNotification).toHaveBeenCalledTimes(1));
expect(testNtfyNotification).toHaveBeenCalledWith({ ntfyEnabled: true, ntfyTopic: "my-valid-topic" }, undefined);
});
it("Success toast is shown when test notification succeeds", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitForSettingsModalReady();

View File

@@ -3695,6 +3695,34 @@ input[type="range"]:focus-visible {
}
}
/* === Notifications Settings === */
.ntfy-advanced-disclosure {
margin-top: var(--space-md);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.ntfy-advanced-disclosure > summary {
cursor: pointer;
list-style: none;
padding: var(--space-sm) var(--space-md);
font-size: 12px;
font-weight: 600;
color: var(--text);
}
.ntfy-advanced-disclosure > summary::-webkit-details-marker {
display: none;
}
.ntfy-advanced-content {
padding: 0 var(--space-md) var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
/* === Memory Settings === */
.memory-status-message {
display: flex;

View File

@@ -13327,6 +13327,61 @@ describe("POST /settings/test-ntfy", () => {
expect(options?.body).toBe("Fusion test notification — your notifications are working!");
});
it("uses configured ntfyBaseUrl from settings when present", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyBaseUrl: "https://ntfy.internal.example///",
});
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-ntfy");
expect(res.status).toBe(200);
expect(fetchSpy).toHaveBeenCalledTimes(1);
const url = fetchSpy.mock.calls[0]?.[0] as string;
expect(url).toBe("https://ntfy.internal.example/my-topic");
});
it("uses request ntfyBaseUrl override when provided", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyBaseUrl: "https://ntfy.saved.example",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-ntfy",
JSON.stringify({ ntfyBaseUrl: "https://ntfy.override.example//" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
const url = fetchSpy.mock.calls[0]?.[0] as string;
expect(url).toBe("https://ntfy.override.example/my-topic");
});
it("falls back to saved ntfyBaseUrl when request override is blank", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyBaseUrl: "https://ntfy.saved.example",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-ntfy",
JSON.stringify({ ntfyBaseUrl: " " }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
const url = fetchSpy.mock.calls[0]?.[0] as string;
expect(url).toBe("https://ntfy.saved.example/my-topic");
});
it("returns 400 when ntfy is not enabled", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: false,
@@ -13351,6 +13406,44 @@ describe("POST /settings/test-ntfy", () => {
expect(res.body.error).toContain("not configured or invalid");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("returns 400 when request ntfyBaseUrl uses non-http protocol", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "test-topic",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-ntfy",
JSON.stringify({ ntfyBaseUrl: "ftp://ntfy.example.com" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("http:// or https://");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("returns 400 when request ntfyBaseUrl is malformed", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
ntfyEnabled: true,
ntfyTopic: "test-topic",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/settings/test-ntfy",
JSON.stringify({ ntfyBaseUrl: "not-a-url" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("must be a valid URL");
expect(fetchSpy).not.toHaveBeenCalled();
});
});
// ── Memory Routes ─────────────────────────────────────────────

View File

@@ -3735,6 +3735,26 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
* Returns: { success: true } on success, { error: string } on failure.
*/
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");
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw badRequest(`ntfy server URL from ${source} must be a valid URL`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw badRequest("ntfy server URL must use http:// or https://");
}
return trimmed.replace(/\/+$/, "");
};
try {
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
@@ -3750,8 +3770,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest("ntfy topic is not configured or invalid");
}
// Send test notification to ntfy.sh
const ntfyBaseUrl = "https://ntfy.sh";
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 ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh";
const url = `${ntfyBaseUrl}/${topic}`;
const response = await fetch(url, {
@@ -3765,7 +3795,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
if (!response.ok) {
throw new ApiError(502, `ntfy.sh returned ${response.status}: ${response.statusText}`);
throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`);
}
res.json({ success: true });