feat(FN-4078): add ntfy access token support to notifications
Adds ntfy access token support to Fusion's notification system, wiring the token through the core settings schema, dashboard UI (SettingsModal), engine notifier, and notification pipeline, with corresponding tests across core, dashboard, and engine packages; also updates settings and storage documen Fusion-Task-Id: FN-4078
This commit is contained in:
5
.changeset/fn-4078-ntfy-access-token.md
Normal file
5
.changeset/fn-4078-ntfy-access-token.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add optional ntfy access-token settings support so authenticated ntfy topics receive `Authorization: Bearer <token>` on runtime and test notification publishes.
|
||||
@@ -42,6 +42,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
||||
| `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. |
|
||||
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
|
||||
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |
|
||||
| `ntfyAccessToken` | `string` | `undefined` | Optional ntfy access token. When set, Fusion sends `Authorization: Bearer <token>` with ntfy publish requests, including Settings → Notifications test sends. Leave blank/unset to publish without authentication. |
|
||||
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). If you use a custom `ntfyEvents` list, this event must be present (or `ntfyEvents` must be unset so defaults apply) for agent-to-agent inbox notifications to send. |
|
||||
| `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. |
|
||||
| `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. |
|
||||
@@ -144,6 +145,7 @@ When `id` is `"ntfy"` in `notificationProviders`, the provider `config` supports
|
||||
|---|---|---:|---|
|
||||
| `topic` | `string` | _required_ | ntfy topic name (1–64 chars, alphanumeric + `-_`). |
|
||||
| `ntfyBaseUrl` | `string` | `"https://ntfy.sh"` | Optional custom ntfy server URL. |
|
||||
| `ntfyAccessToken` | `string` | `undefined` | Optional access token. When set, provider sends `Authorization: Bearer <token>` on ntfy publishes. |
|
||||
| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. |
|
||||
| `dashboardHost` | `string` | `undefined` | Dashboard host for deep links in notifications. |
|
||||
|
||||
|
||||
@@ -94,6 +94,8 @@ API endpoints reviewed:
|
||||
| `defaultThinkingLevel` | Global | `GET/PUT /api/settings/global` | Default reasoning effort |
|
||||
| `ntfyEnabled` | Global | `GET/PUT /api/settings/global` | Notifications enabled |
|
||||
| `ntfyTopic` | Global | `GET/PUT /api/settings/global` | Ntfy topic |
|
||||
| `ntfyBaseUrl` | Global | `GET/PUT /api/settings/global` | Custom ntfy server base URL override |
|
||||
| `ntfyAccessToken` | Global | `GET/PUT /api/settings/global` | Access token for authenticated ntfy publishes |
|
||||
| `ntfyEvents` | Global | `GET/PUT /api/settings/global` | Notification event filters |
|
||||
| `ntfyDashboardHost` | Global | `GET/PUT /api/settings/global` | Host for deep links |
|
||||
| `defaultProjectId` | Global | `GET/PUT /api/settings/global` | CLI default project |
|
||||
|
||||
@@ -169,6 +169,7 @@ describe("GlobalSettingsStore", () => {
|
||||
// Defaults are filled in for missing fields
|
||||
expect(settings.ntfyEnabled).toBe(false);
|
||||
expect(settings.ntfyBaseUrl).toBeUndefined();
|
||||
expect(settings.ntfyAccessToken).toBeUndefined();
|
||||
expect(settings.defaultProvider).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -260,6 +261,23 @@ describe("GlobalSettingsStore", () => {
|
||||
expect(settings.ntfyTopic).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearing ntfyAccessToken with null removes it from disk and returns undefined", async () => {
|
||||
await store.init();
|
||||
await store.updateSettings({ ntfyAccessToken: "secret-token" });
|
||||
|
||||
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
|
||||
expect(raw.ntfyAccessToken).toBe("secret-token");
|
||||
|
||||
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
|
||||
await store.updateSettings({ ntfyAccessToken: null });
|
||||
|
||||
const rawAfter = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
|
||||
expect(rawAfter.ntfyAccessToken).toBeUndefined();
|
||||
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.ntfyAccessToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearing ntfyDashboardHost with null removes it from disk", async () => {
|
||||
await store.init();
|
||||
await store.updateSettings({ ntfyDashboardHost: "https://dashboard.example.com" });
|
||||
|
||||
@@ -23,6 +23,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyBaseUrl: undefined,
|
||||
ntfyAccessToken: undefined,
|
||||
ntfyEvents: [
|
||||
"in-review",
|
||||
"merged",
|
||||
|
||||
@@ -1608,6 +1608,10 @@ export interface GlobalSettings {
|
||||
* Must be an http:// or https:// URL. When omitted, notifications default to
|
||||
* https://ntfy.sh. Example: "https://ntfy.internal.example" */
|
||||
ntfyBaseUrl?: string;
|
||||
/** Optional ntfy access token used for authenticated publishes.
|
||||
* When set, Fusion sends `Authorization: Bearer <token>` with ntfy requests.
|
||||
* Leave undefined to publish without authentication. */
|
||||
ntfyAccessToken?: string;
|
||||
/** List of notification events to send via ntfy.sh.
|
||||
* When ntfyEnabled is true, only events in this list will trigger notifications.
|
||||
* If undefined or empty when ntfyEnabled is true, all events are sent (backward compatible).
|
||||
|
||||
@@ -993,7 +993,15 @@ export function testNotification(providerId: string, config?: Record<string, unk
|
||||
* Backward-compatible ntfy test helper.
|
||||
* Wraps testNotification() while preserving the legacy function signature.
|
||||
*/
|
||||
export function testNtfyNotification(config?: { ntfyEnabled?: boolean; ntfyTopic?: string; ntfyBaseUrl?: string }, projectId?: string): Promise<{ success: boolean }> {
|
||||
export function testNtfyNotification(
|
||||
config?: {
|
||||
ntfyEnabled?: boolean;
|
||||
ntfyTopic?: string;
|
||||
ntfyBaseUrl?: string;
|
||||
ntfyAccessToken?: string;
|
||||
},
|
||||
projectId?: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
return testNotification("ntfy", config as Record<string, unknown> | undefined, projectId);
|
||||
}
|
||||
|
||||
|
||||
@@ -397,6 +397,7 @@ export function SettingsModal({
|
||||
worktreeInitCommand: "",
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyAccessToken: undefined,
|
||||
webhookEnabled: false,
|
||||
webhookUrl: undefined,
|
||||
webhookFormat: "generic",
|
||||
@@ -1186,6 +1187,7 @@ export function SettingsModal({
|
||||
ntfyEnabled: form.ntfyEnabled,
|
||||
ntfyTopic: form.ntfyTopic,
|
||||
...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}),
|
||||
...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}),
|
||||
}
|
||||
: providerId === "ntfy-message"
|
||||
? { messageEventType: "message:agent-to-user" }
|
||||
@@ -1215,7 +1217,17 @@ export function SettingsModal({
|
||||
} finally {
|
||||
setTestNotificationLoading((prev) => ({ ...prev, [providerId]: false }));
|
||||
}
|
||||
}, [addToast, form.ntfyBaseUrl, form.ntfyEnabled, form.ntfyTopic, form.webhookEnabled, form.webhookFormat, form.webhookUrl, projectId]);
|
||||
}, [
|
||||
addToast,
|
||||
form.ntfyAccessToken,
|
||||
form.ntfyBaseUrl,
|
||||
form.ntfyEnabled,
|
||||
form.ntfyTopic,
|
||||
form.webhookEnabled,
|
||||
form.webhookFormat,
|
||||
form.webhookUrl,
|
||||
projectId,
|
||||
]);
|
||||
|
||||
const handleBackupNow = useCallback(async () => {
|
||||
setBackupLoading(true);
|
||||
@@ -4860,6 +4872,21 @@ export function SettingsModal({
|
||||
<small>
|
||||
Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://.
|
||||
</small>
|
||||
<label htmlFor="ntfyAccessToken">Access token (optional)</label>
|
||||
<input
|
||||
id="ntfyAccessToken"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="tk_..."
|
||||
value={form.ntfyAccessToken || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setForm((f) => ({ ...f, ntfyAccessToken: value || undefined }));
|
||||
}}
|
||||
/>
|
||||
<small>
|
||||
Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests.
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -182,6 +182,7 @@ const defaultSettings = {
|
||||
worktreeInitCommand: "",
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyAccessToken: undefined,
|
||||
webhookEnabled: false,
|
||||
webhookUrl: undefined,
|
||||
webhookFormat: undefined,
|
||||
@@ -2994,6 +2995,9 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByLabelText("Dashboard Hostname")).toBeInTheDocument();
|
||||
expect(screen.getByText("Notify on events")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Test notification/ })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("Advanced"));
|
||||
expect(screen.getByLabelText("Access token (optional)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows fallback, dreams, and mailbox message events for both providers", async () => {
|
||||
@@ -3048,18 +3052,46 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openNotificationsSection();
|
||||
await userEvent.click(screen.getByText("Advanced"));
|
||||
await userEvent.type(screen.getByLabelText("Access token (optional)"), "secret-token");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Test notification/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTestNotification).toHaveBeenCalledWith(
|
||||
"ntfy",
|
||||
expect.objectContaining({ ntfyEnabled: true, ntfyTopic: "test-topic" }),
|
||||
expect.objectContaining({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyAccessToken: "secret-token",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a saved ntfy access token via global null-as-delete semantics", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openNotificationsSection();
|
||||
await userEvent.click(screen.getByText("Advanced"));
|
||||
const tokenInput = screen.getByLabelText("Access token (optional)");
|
||||
await userEvent.clear(tokenInput);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ntfyAccessToken: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls testNotification with ntfy message-event config when message test button clicked", async () => {
|
||||
const addToast = vi.fn();
|
||||
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
|
||||
@@ -1659,6 +1659,53 @@ describe("POST /settings/test-ntfy", () => {
|
||||
expect(url).toBe("https://ntfy.saved.example/my-topic");
|
||||
});
|
||||
|
||||
it("sends Authorization header from saved ntfy access token", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-topic",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-ntfy");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer saved-token");
|
||||
});
|
||||
|
||||
it("prefers request ntfy access token override and ignores blank overrides", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-topic",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const overrideRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-ntfy",
|
||||
JSON.stringify({ ntfyAccessToken: "override-token" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(overrideRes.status).toBe(200);
|
||||
let options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer override-token");
|
||||
|
||||
fetchSpy.mockClear();
|
||||
const blankRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-ntfy",
|
||||
JSON.stringify({ ntfyAccessToken: " " }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(blankRes.status).toBe(200);
|
||||
options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer saved-token");
|
||||
});
|
||||
|
||||
it("returns 400 when ntfy is not enabled", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: false,
|
||||
@@ -1844,6 +1891,59 @@ describe("POST /settings/test-notification", () => {
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/my-topic");
|
||||
});
|
||||
|
||||
it("ntfy provider sends Authorization header from saved or override token", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-topic",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const savedRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "ntfy" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(savedRes.status).toBe(200);
|
||||
let options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer saved-token");
|
||||
|
||||
fetchSpy.mockClear();
|
||||
const overrideRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "ntfy", ntfyAccessToken: "override-token" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(overrideRes.status).toBe(200);
|
||||
options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer override-token");
|
||||
});
|
||||
|
||||
it("ntfy provider omits Authorization header when no token is configured", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-topic",
|
||||
ntfyAccessToken: " ",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "ntfy", ntfyAccessToken: " " }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect((options.headers as Record<string, string>).Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ntfy provider returns 400 when ntfy not enabled", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ntfyEnabled: false });
|
||||
|
||||
|
||||
@@ -1850,16 +1850,31 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
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: {
|
||||
"Title": "Fusion test notification",
|
||||
"Priority": "default",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
headers,
|
||||
body: "Fusion test notification — your notifications are working!",
|
||||
});
|
||||
|
||||
@@ -1976,16 +1991,31 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
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: {
|
||||
"Title": "Fusion test notification",
|
||||
"Priority": "default",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
headers,
|
||||
body: "Fusion test notification — your notifications are working!",
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,10 @@ function createStore(settings: Partial<Settings> = {}) {
|
||||
describe("message notification pipeline", () => {
|
||||
it("dispatches agent-originated messages and ignores user-to-agent", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const store = createStore({ ntfyEvents: ["message:agent-to-user", "message:agent-to-agent"] });
|
||||
const store = createStore({
|
||||
ntfyEvents: ["message:agent-to-user", "message:agent-to-agent"],
|
||||
ntfyAccessToken: "token-123",
|
||||
});
|
||||
const messageStore = new TestMessageStore();
|
||||
|
||||
const service = new NotificationService(store as any, {
|
||||
@@ -53,6 +56,7 @@ describe("message notification pipeline", () => {
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.sh/test-topic");
|
||||
const firstHeaders = (fetchSpy.mock.calls[0]?.[1] as RequestInit).headers as Record<string, string>;
|
||||
expect(firstHeaders.Title).toContain("Triage Bot");
|
||||
expect(firstHeaders.Authorization).toBe("Bearer token-123");
|
||||
const firstBody = String((fetchSpy.mock.calls[0]?.[1] as RequestInit).body);
|
||||
expect(firstBody).toContain("hi");
|
||||
|
||||
|
||||
@@ -136,7 +136,12 @@ describe("NotificationService", () => {
|
||||
await service.start();
|
||||
|
||||
expect(initSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ topic: "demo", projectId: "p1", ntfyBaseUrl: "https://n" }),
|
||||
expect.objectContaining({
|
||||
topic: "demo",
|
||||
projectId: "p1",
|
||||
ntfyBaseUrl: "https://n",
|
||||
ntfyAccessToken: undefined,
|
||||
}),
|
||||
);
|
||||
initSpy.mockRestore();
|
||||
});
|
||||
@@ -150,6 +155,48 @@ describe("NotificationService", () => {
|
||||
initSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("reconfigures the ntfy provider when the access token changes without logging the token", async () => {
|
||||
const store = createStore({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "demo",
|
||||
ntfyAccessToken: "old-token",
|
||||
});
|
||||
const initSpy = vi.spyOn(NtfyNotificationProvider.prototype, "initialize");
|
||||
|
||||
const service = new NotificationService(store as any, { projectId: "p1" });
|
||||
await service.start();
|
||||
|
||||
store.emit("settings:updated", {
|
||||
settings: {
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "demo",
|
||||
ntfyAccessToken: "new-token",
|
||||
} as Settings,
|
||||
previous: {
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "demo",
|
||||
ntfyAccessToken: "old-token",
|
||||
} as Settings,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(initSpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
topic: "demo",
|
||||
projectId: "p1",
|
||||
ntfyAccessToken: "new-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(schedulerLog.log).toHaveBeenCalledWith("NotificationService ntfy access token updated");
|
||||
});
|
||||
expect(schedulerLog.log).not.toHaveBeenCalledWith(expect.stringContaining("new-token"));
|
||||
expect(schedulerLog.log).not.toHaveBeenCalledWith(expect.stringContaining("old-token"));
|
||||
initSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("dispatches message:agent-to-user from message:sent", async () => {
|
||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||
const messageStore = new EventEmitter();
|
||||
|
||||
@@ -26,6 +26,7 @@ describe("NtfyNotificationProvider", () => {
|
||||
await provider.initialize({
|
||||
topic: "topic-a",
|
||||
ntfyBaseUrl: "https://ntfy.local",
|
||||
ntfyAccessToken: "secret-token",
|
||||
dashboardHost: "http://dash",
|
||||
projectId: "p1",
|
||||
});
|
||||
@@ -63,6 +64,7 @@ describe("NtfyNotificationProvider", () => {
|
||||
expect(mocks.sendNtfyNotificationWithResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
topic: "topic-a",
|
||||
ntfyAccessToken: "secret-token",
|
||||
title: expectedTitle,
|
||||
priority,
|
||||
message: expect.stringContaining(messagePart),
|
||||
|
||||
@@ -152,6 +152,7 @@ export class NotificationService {
|
||||
settings.ntfyEnabled !== previous.ntfyEnabled ||
|
||||
settings.ntfyTopic !== previous.ntfyTopic ||
|
||||
settings.ntfyBaseUrl !== previous.ntfyBaseUrl ||
|
||||
settings.ntfyAccessToken !== previous.ntfyAccessToken ||
|
||||
settings.ntfyDashboardHost !== previous.ntfyDashboardHost ||
|
||||
JSON.stringify(settings.ntfyEvents) !== JSON.stringify(previous.ntfyEvents)
|
||||
) {
|
||||
@@ -168,6 +169,8 @@ export class NotificationService {
|
||||
schedulerLog.log("NotificationService ntfy topic updated");
|
||||
} else if (settings.ntfyBaseUrl !== previous.ntfyBaseUrl) {
|
||||
schedulerLog.log("NotificationService ntfy base URL updated");
|
||||
} else if (settings.ntfyAccessToken !== previous.ntfyAccessToken) {
|
||||
schedulerLog.log("NotificationService ntfy access token updated");
|
||||
} else if (settings.ntfyDashboardHost !== previous.ntfyDashboardHost) {
|
||||
schedulerLog.log("NotificationService ntfy dashboard host updated");
|
||||
} else if (JSON.stringify(settings.ntfyEvents) !== JSON.stringify(previous.ntfyEvents)) {
|
||||
@@ -206,6 +209,7 @@ export class NotificationService {
|
||||
await this.ntfyProvider.initialize?.({
|
||||
topic: settings.ntfyTopic,
|
||||
ntfyBaseUrl: settings.ntfyBaseUrl ?? this.options.ntfyBaseUrl,
|
||||
ntfyAccessToken: settings.ntfyAccessToken,
|
||||
dashboardHost: settings.ntfyDashboardHost,
|
||||
events: settings.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS],
|
||||
projectId: this.options.projectId,
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface NtfyProviderConfig {
|
||||
dashboardHost?: string;
|
||||
/** Project identifier for deep links */
|
||||
projectId?: string;
|
||||
/** Optional access token used for authenticated publishes */
|
||||
ntfyAccessToken?: string;
|
||||
/** Events to enable (default: DEFAULT_NTFY_EVENTS) */
|
||||
events?: NtfyNotificationEvent[];
|
||||
}
|
||||
@@ -208,6 +210,7 @@ export class NtfyNotificationProvider implements NotificationProvider {
|
||||
|
||||
const response = await sendNtfyNotificationWithResult({
|
||||
ntfyBaseUrl: this.config.ntfyBaseUrl,
|
||||
ntfyAccessToken: this.config.ntfyAccessToken,
|
||||
topic: this.config.topic,
|
||||
title: content.title,
|
||||
message: content.message,
|
||||
|
||||
@@ -37,10 +37,12 @@ export interface NtfyNotificationConfigInput {
|
||||
events?: NtfyNotificationEvent[];
|
||||
projectId?: string;
|
||||
ntfyBaseUrl?: string;
|
||||
ntfyAccessToken?: string;
|
||||
}
|
||||
|
||||
export interface SendNtfyNotificationInput {
|
||||
ntfyBaseUrl?: string;
|
||||
ntfyAccessToken?: string;
|
||||
topic: string;
|
||||
title: string;
|
||||
message: string;
|
||||
@@ -140,6 +142,7 @@ export function buildNtfyClickUrl(options: {
|
||||
*/
|
||||
export async function sendNtfyNotificationWithResult({
|
||||
ntfyBaseUrl,
|
||||
ntfyAccessToken,
|
||||
topic,
|
||||
title,
|
||||
message,
|
||||
@@ -158,6 +161,11 @@ export async function sendNtfyNotificationWithResult({
|
||||
headers.Click = clickUrl;
|
||||
}
|
||||
|
||||
const trimmedToken = ntfyAccessToken?.trim();
|
||||
if (trimmedToken) {
|
||||
headers.Authorization = `Bearer ${trimmedToken}`;
|
||||
}
|
||||
|
||||
const resolvedBaseUrl = resolveNtfyBaseUrl(ntfyBaseUrl);
|
||||
const response = await fetch(`${resolvedBaseUrl}/${topic}`, {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user