diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 11:28:03 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 11:28:03 +0900 |
| commit | d3d94b69a9f98a0a4b68dc6ee830466471adf26d (patch) | |
| tree | 3320ab5b89d0ebb7578a43df28b6b95d2c3e0b3c | |
| parent | 07970bd4c89068272b76407beb6df5bc9aef2ff7 (diff) | |
| download | dispatch-d3d94b69a9f98a0a4b68dc6ee830466471adf26d.tar.gz dispatch-d3d94b69a9f98a0a4b68dc6ee830466471adf26d.zip | |
feat(notifications): topic-only input (drop URL validation)
The Settings field is now a plain topic name (e.g. `my-secret-topic`)
instead of a full URL. The transport always posts to
`https://ntfy.sh/<topic>` (URL-encoded), and the only server-side check
is "non-empty when enabled". Removes the user-visible
"string does not match the expected pattern" error people hit when
typing a bare topic.
- packages/core/src/notifications/ntfy.ts: drop validateTopicUrl;
add buildNtfyUrl(topic) + exported NTFY_BASE_URL.
- packages/core/src/notifications/types.ts, config.ts: rename
topicUrl -> topic; update docs.
- packages/api/src/routes/notifications.ts: only validates non-empty
topic when enabled. Also fixes a latent bug where notifySubagents
was dropped on every PUT (was not passed to normalizeNtfyConfig).
- packages/frontend/src/lib/components/SettingsPanel.svelte: relabel
field "Topic URL" -> "Topic"; placeholder "your-secret-topic";
updated helper copy.
- Tests updated: rewrote validateTopicUrl coverage as buildNtfyUrl
coverage + proof that previously-rejected topics (dots, spaces,
unicode, "Any Topic Whatsoever") now POST cleanly.
- HANDOFF.md: added a short "topic-only input" section.
| -rw-r--r-- | HANDOFF.md | 26 | ||||
| -rw-r--r-- | packages/api/src/routes/notifications.ts | 21 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 7 | ||||
| -rw-r--r-- | packages/core/src/notifications/config.ts | 4 | ||||
| -rw-r--r-- | packages/core/src/notifications/index.ts | 8 | ||||
| -rw-r--r-- | packages/core/src/notifications/ntfy.ts | 55 | ||||
| -rw-r--r-- | packages/core/src/notifications/types.ts | 10 | ||||
| -rw-r--r-- | packages/core/tests/notifications/config.test.ts | 18 | ||||
| -rw-r--r-- | packages/core/tests/notifications/dispatcher.test.ts | 2 | ||||
| -rw-r--r-- | packages/core/tests/notifications/ntfy.test.ts | 87 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SettingsPanel.svelte | 14 |
11 files changed, 127 insertions, 125 deletions
@@ -277,11 +277,14 @@ single-user, single-process design): later by extending `NotificationEventType`, `NTFY_DEFAULT_EVENTS`, and adding a builder + dispatch hook. -6. **Ntfy server-side validation is minimal.** We only check that the - topic URL is a syntactically-valid `http(s)://host/topic-segment` - matching ntfy's documented topic-name rules. We don't ping the server - on save (would slow the UI and confuse users behind captive portals). - The "Send test" button is the integration check. +6. **No topic validation client-side.** The Settings field accepts any + non-empty string as the topic name and the transport posts to + `https://ntfy.sh/<topic>` (URL-encoded). Earlier revisions enforced + ntfy.sh's documented `^[A-Za-z0-9_-]{1,64}$` rule, but the project + relaxes those rules over time (issue #1451) and a regex here just + locks users out of valid configurations. The server is the final + authority; any rejection surfaces through the "Send test" button or + the first real notification. 7. **Header-based publishing (vs. JSON-body publishing).** Per the Gemini-review triage above, the transport sends `Title`/`Tags`/`Click` @@ -293,3 +296,16 @@ single-user, single-process design): Working tree is clean; seven commits on `n2/ntfy-notifications`; nothing merged. + +## Update — topic-only input (post-merge of this branch's seventh commit) + +The `topicUrl` field was replaced with `topic`. The user now enters just +the ntfy topic name (e.g. `my-secret-topic`); the transport always posts +to `https://ntfy.sh/<topic>`. `validateTopicUrl` is gone — only an empty +check remains (server-side, and only when `enabled === true`). This +eliminates the "string does not match the expected pattern" error users +hit when entering a bare topic. Tests, the `/notifications` PUT route, +the persisted JSON shape, and the SettingsPanel UI were updated together. +Also fixed a small pre-existing bug: the `/notifications` PUT handler now +honours `notifySubagents` on save (previously it was silently dropped +because the field wasn't passed to `normalizeNtfyConfig`). diff --git a/packages/api/src/routes/notifications.ts b/packages/api/src/routes/notifications.ts index 57519bc..473e837 100644 --- a/packages/api/src/routes/notifications.ts +++ b/packages/api/src/routes/notifications.ts @@ -10,7 +10,6 @@ import { redactNtfyConfig, saveNtfyConfig, sendNtfy, - validateTopicUrl, } from "@dispatch/core"; import { Hono } from "hono"; @@ -37,14 +36,21 @@ notificationsRoutes.put("/", async (c) => { const merged = normalizeNtfyConfig({ enabled: typeof body.enabled === "boolean" ? body.enabled : existing.enabled, - topicUrl: typeof body.topicUrl === "string" ? body.topicUrl : existing.topicUrl, + topic: typeof body.topic === "string" ? body.topic : existing.topic, authToken: nextAuthToken, events: { ...existing.events, ...(body.events ?? {}) }, + notifySubagents: + typeof body.notifySubagents === "boolean" ? body.notifySubagents : existing.notifySubagents, }); - if (merged.enabled) { - const err = validateTopicUrl(merged.topicUrl); - if (err) return c.json({ error: err }, 400); + // Only validation: if notifications are turned on, the topic must be + // non-empty. Any other "is this a valid ntfy topic name?" check is + // punted to the ntfy server itself — its rules vary and have changed + // over time, and a syntactically-valid name still might be rejected + // (e.g. reserved words), so a clear server error is more useful than + // a client-side guess. + if (merged.enabled && !merged.topic.trim()) { + return c.json({ error: "Topic is required" }, 400); } saveNtfyConfig(merged); @@ -56,8 +62,9 @@ notificationsRoutes.post("/test", async (c) => { if (!config.enabled) { return c.json({ ok: false, error: "Notifications are disabled" }, 400); } - const err = validateTopicUrl(config.topicUrl); - if (err) return c.json({ ok: false, error: err }, 400); + if (!config.topic.trim()) { + return c.json({ ok: false, error: "Topic is required" }, 400); + } // Use a real event type so the per-event toggle is honored when wiring // is tested end-to-end; pick `turn-completed` since it's the most diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index 1fad690..5606754 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -282,7 +282,7 @@ vi.mock("@dispatch/core", () => ({ loadNtfyConfig() { return { enabled: false, - topicUrl: "", + topic: "", authToken: "", events: { "turn-completed": true, @@ -300,7 +300,7 @@ vi.mock("@dispatch/core", () => ({ defaultNtfyConfig() { return { enabled: false, - topicUrl: "", + topic: "", authToken: "", events: { "turn-completed": true, @@ -318,9 +318,6 @@ vi.mock("@dispatch/core", () => ({ async sendNtfy() { return { ok: true }; }, - validateTopicUrl() { - return null; - }, })); const { app } = await import("../src/app.js"); diff --git a/packages/core/src/notifications/config.ts b/packages/core/src/notifications/config.ts index faa316e..49e6ff4 100644 --- a/packages/core/src/notifications/config.ts +++ b/packages/core/src/notifications/config.ts @@ -14,7 +14,7 @@ export const NTFY_CONFIG_KEY = "ntfy_config"; export function defaultNtfyConfig(): NtfyConfig { return { enabled: false, - topicUrl: "", + topic: "", authToken: "", events: { ...NTFY_DEFAULT_EVENTS }, notifySubagents: false, @@ -32,7 +32,7 @@ export function normalizeNtfyConfig(raw: unknown): NtfyConfig { const obj = raw as Record<string, unknown>; const out: NtfyConfig = { enabled: typeof obj.enabled === "boolean" ? obj.enabled : base.enabled, - topicUrl: typeof obj.topicUrl === "string" ? obj.topicUrl : base.topicUrl, + topic: typeof obj.topic === "string" ? obj.topic : base.topic, authToken: typeof obj.authToken === "string" ? obj.authToken : base.authToken, events: { ...base.events }, notifySubagents: diff --git a/packages/core/src/notifications/index.ts b/packages/core/src/notifications/index.ts index d9d934d..d1e7891 100644 --- a/packages/core/src/notifications/index.ts +++ b/packages/core/src/notifications/index.ts @@ -17,7 +17,13 @@ export { type TabParentLookup, type TabTitleLookup, } from "./dispatcher.js"; -export { type FetchLike, type NtfySendResult, sendNtfy, validateTopicUrl } from "./ntfy.js"; +export { + buildNtfyUrl, + type FetchLike, + NTFY_BASE_URL, + type NtfySendResult, + sendNtfy, +} from "./ntfy.js"; export { type NotificationEvent, type NotificationEventType, diff --git a/packages/core/src/notifications/ntfy.ts b/packages/core/src/notifications/ntfy.ts index 1f5101c..eb5de9e 100644 --- a/packages/core/src/notifications/ntfy.ts +++ b/packages/core/src/notifications/ntfy.ts @@ -1,13 +1,15 @@ // ntfy.sh HTTP transport. // -// ntfy's API is a simple POST to `https://<server>/<topic>` with the body +// ntfy's API is a simple POST to `https://ntfy.sh/<topic>` with the body // as the message and metadata passed via HTTP headers: // Title: notification title // Priority: 1..5 (3 = default) // Tags: comma-separated emoji shortcodes // Click: URL opened when the notification is tapped // -// We intentionally use `fetch` directly — no SDK, no extra deps. +// The server is hardcoded to the public ntfy.sh instance; the user only +// configures a topic name. We intentionally use `fetch` directly — no +// SDK, no extra deps. import type { NotificationEvent, NtfyConfig } from "./types.js"; import { NTFY_DEFAULT_PRIORITIES, NTFY_DEFAULT_TAGS } from "./types.js"; @@ -27,41 +29,20 @@ export type FetchLike = ( init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal }, ) => Promise<{ ok: boolean; status: number; statusText?: string; text(): Promise<string> }>; -/** - * ntfy topic-name rules: 1–64 chars, ASCII alphanumerics + `-` and `_`. Sourced - * from the ntfy server (cf. binwiederhier/ntfy issue #1451 — longer names - * silently 404). Matching this client-side keeps users from saving topic URLs - * that look fine but only fail at publish time. - */ -const NTFY_TOPIC_RE = /^[A-Za-z0-9_-]{1,64}$/; +/** Base URL of the public ntfy.sh server. */ +export const NTFY_BASE_URL = "https://ntfy.sh"; /** - * Validate a ntfy topic URL. Accepts only `http(s)://host/topic` where - * `topic` is a single path segment of 1–64 chars matching `[A-Za-z0-9_-]`. - * Returns `null` on success, a human-readable error string on failure. + * Build the publish URL for a topic name. + * + * No client-side validation of the topic content: ntfy.sh's accepted + * character set has changed over time and a regex here only locks users + * out of legitimate topics. The topic is URL-encoded so the resulting + * URL is always syntactically valid; if ntfy rejects the name the HTTP + * error surfaces on the first send / `Send test`. */ -export function validateTopicUrl(topicUrl: string): string | null { - const trimmed = topicUrl.trim(); - if (!trimmed) return "Topic URL is required"; - let url: URL; - try { - url = new URL(trimmed); - } catch { - return "Topic URL is not a valid URL"; - } - if (url.protocol !== "http:" && url.protocol !== "https:") { - return "Topic URL must use http:// or https://"; - } - // Path must be exactly one topic segment. - const topic = url.pathname.replace(/^\/+|\/+$/g, ""); - if (!topic) return "Topic URL must include a topic name (e.g. https://ntfy.sh/my-topic)"; - if (topic.includes("/")) { - return "Topic URL must point at a single topic (no extra path segments)"; - } - if (!NTFY_TOPIC_RE.test(topic)) { - return "Topic name must be 1–64 characters, letters/numbers/underscore/hyphen only"; - } - return null; +export function buildNtfyUrl(topic: string): string { + return `${NTFY_BASE_URL}/${encodeURIComponent(topic.trim())}`; } /** @@ -81,8 +62,8 @@ export async function sendNtfy( timeoutMs = 10_000, ): Promise<NtfySendResult> { if (!config.enabled) return { ok: false, error: "Notifications are disabled" }; - const topicErr = validateTopicUrl(config.topicUrl); - if (topicErr) return { ok: false, error: topicErr }; + if (!config.topic.trim()) return { ok: false, error: "Topic is required" }; + const targetUrl = buildNtfyUrl(config.topic); const priority = event.priority ?? NTFY_DEFAULT_PRIORITIES[event.type] ?? 3; const baseTags = event.tags ?? NTFY_DEFAULT_TAGS[event.type] ?? []; @@ -110,7 +91,7 @@ export async function sendNtfy( const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const res = await fetchImpl(config.topicUrl.trim(), { + const res = await fetchImpl(targetUrl, { method: "POST", headers, body: event.message, diff --git a/packages/core/src/notifications/types.ts b/packages/core/src/notifications/types.ts index 238cb68..ef6c059 100644 --- a/packages/core/src/notifications/types.ts +++ b/packages/core/src/notifications/types.ts @@ -52,9 +52,11 @@ export interface NotificationEvent { * existing single-user assumption (cf. `title_model_*`, `perm_*`). * * - `enabled` — master switch. Off ⇒ dispatcher never sends. - * - `topicUrl` — full URL, e.g. `https://ntfy.sh/my-secret-topic`. Missing - * ⇒ dispatcher never sends. - * - `authToken` — optional bearer token for private ntfy servers. + * - `topic` — bare ntfy.sh topic name, e.g. `my-secret-topic`. The + * server is hardcoded to https://ntfy.sh; the user only picks a topic. + * Missing ⇒ dispatcher never sends. + * - `authToken` — optional bearer token (rarely needed against ntfy.sh + * directly; preserved for users behind an auth-protected proxy). * - `events` — per-event-type enable map. Missing entries default to OFF * so a newly-added event type doesn't silently start firing. * - `notifySubagents` — when false (default), `turn-completed` and @@ -67,7 +69,7 @@ export interface NotificationEvent { */ export interface NtfyConfig { enabled: boolean; - topicUrl: string; + topic: string; authToken: string; events: Record<NotificationEventType, boolean>; notifySubagents: boolean; diff --git a/packages/core/tests/notifications/config.test.ts b/packages/core/tests/notifications/config.test.ts index c110788..71dc00c 100644 --- a/packages/core/tests/notifications/config.test.ts +++ b/packages/core/tests/notifications/config.test.ts @@ -28,7 +28,7 @@ describe("defaultNtfyConfig", () => { it("disables notifications and ships sane per-event defaults", () => { const cfg = defaultNtfyConfig(); expect(cfg.enabled).toBe(false); - expect(cfg.topicUrl).toBe(""); + expect(cfg.topic).toBe(""); expect(cfg.authToken).toBe(""); expect(cfg.events["turn-completed"]).toBe(true); expect(cfg.events["turn-error"]).toBe(true); @@ -48,7 +48,7 @@ describe("normalizeNtfyConfig", () => { it("fills in missing event toggles with defaults (newly-added types default OFF)", () => { const normalized = normalizeNtfyConfig({ enabled: true, - topicUrl: "https://ntfy.sh/x", + topic: "https://ntfy.sh/x", events: { "turn-completed": false }, }); expect(normalized.events["turn-completed"]).toBe(false); @@ -60,13 +60,13 @@ describe("normalizeNtfyConfig", () => { it("ignores extraneous fields and wrong-typed values", () => { const normalized = normalizeNtfyConfig({ enabled: "yes", // wrong type ⇒ default - topicUrl: 42, // wrong type ⇒ default + topic: 42, // wrong type ⇒ default authToken: null, // wrong type ⇒ default events: { "turn-completed": "no", bogus: true }, extra: "ignored", }); expect(normalized.enabled).toBe(false); - expect(normalized.topicUrl).toBe(""); + expect(normalized.topic).toBe(""); expect(normalized.authToken).toBe(""); expect(normalized.events["turn-completed"]).toBe(true); // default kept expect((normalized.events as Record<string, boolean>).bogus).toBeUndefined(); @@ -77,7 +77,7 @@ describe("normalizeNtfyConfig — notifySubagents", () => { it("defaults notifySubagents to false when absent", () => { const normalized = normalizeNtfyConfig({ enabled: true, - topicUrl: "https://ntfy.sh/x", + topic: "https://ntfy.sh/x", }); expect(normalized.notifySubagents).toBe(false); }); @@ -85,7 +85,7 @@ describe("normalizeNtfyConfig — notifySubagents", () => { it("respects an explicit notifySubagents=true", () => { const normalized = normalizeNtfyConfig({ enabled: true, - topicUrl: "https://ntfy.sh/x", + topic: "https://ntfy.sh/x", notifySubagents: true, }); expect(normalized.notifySubagents).toBe(true); @@ -94,7 +94,7 @@ describe("normalizeNtfyConfig — notifySubagents", () => { it("falls back to default when notifySubagents is wrong-typed", () => { const normalized = normalizeNtfyConfig({ enabled: true, - topicUrl: "https://ntfy.sh/x", + topic: "https://ntfy.sh/x", notifySubagents: "yes" as unknown, }); expect(normalized.notifySubagents).toBe(false); @@ -113,7 +113,7 @@ describe("load/save round-trip", () => { it("round-trips a complete config", () => { const cfg = { enabled: true, - topicUrl: "https://ntfy.sh/team", + topic: "https://ntfy.sh/team", authToken: "tk_abc", events: { "turn-completed": false, @@ -136,7 +136,7 @@ describe("load/save round-trip", () => { }); it("clearNtfyConfig removes the persisted entry", () => { - saveNtfyConfig({ ...defaultNtfyConfig(), enabled: true, topicUrl: "https://ntfy.sh/x" }); + saveNtfyConfig({ ...defaultNtfyConfig(), enabled: true, topic: "https://ntfy.sh/x" }); expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(true); clearNtfyConfig(); expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(false); diff --git a/packages/core/tests/notifications/dispatcher.test.ts b/packages/core/tests/notifications/dispatcher.test.ts index 750552c..c2faba6 100644 --- a/packages/core/tests/notifications/dispatcher.test.ts +++ b/packages/core/tests/notifications/dispatcher.test.ts @@ -17,7 +17,7 @@ const { NotificationDispatcher } = await import("../../src/notifications/dispatc function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig { return { enabled: true, - topicUrl: "https://ntfy.sh/topic", + topic: "test-topic", authToken: "", events: { "turn-completed": true, diff --git a/packages/core/tests/notifications/ntfy.test.ts b/packages/core/tests/notifications/ntfy.test.ts index c183847..5f14a60 100644 --- a/packages/core/tests/notifications/ntfy.test.ts +++ b/packages/core/tests/notifications/ntfy.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import { sendNtfy, validateTopicUrl } from "../../src/notifications/ntfy.js"; +import { buildNtfyUrl, NTFY_BASE_URL, sendNtfy } from "../../src/notifications/ntfy.js"; import type { NotificationEvent, NtfyConfig } from "../../src/notifications/types.js"; function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig { return { enabled: true, - topicUrl: "https://ntfy.sh/my-topic", + topic: "my-topic", authToken: "", events: { "turn-completed": true, @@ -13,6 +13,7 @@ function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig { "permission-required": true, "agent-spawned": true, }, + notifySubagents: false, ...overrides, }; } @@ -38,54 +39,26 @@ function makeFetch( return fetchImpl; } -describe("validateTopicUrl", () => { - it("accepts ntfy.sh-style URLs", () => { - expect(validateTopicUrl("https://ntfy.sh/my-topic")).toBeNull(); - expect(validateTopicUrl("http://ntfy.example.com/team-alerts")).toBeNull(); +describe("buildNtfyUrl", () => { + it("prefixes the public ntfy.sh host", () => { + expect(buildNtfyUrl("my-topic")).toBe(`${NTFY_BASE_URL}/my-topic`); }); - it("rejects empty / whitespace", () => { - expect(validateTopicUrl("")).toMatch(/required/); - expect(validateTopicUrl(" ")).toMatch(/required/); + it("trims surrounding whitespace", () => { + expect(buildNtfyUrl(" hello ")).toBe(`${NTFY_BASE_URL}/hello`); }); - it("rejects malformed URLs", () => { - expect(validateTopicUrl("not a url")).toMatch(/valid URL/); - }); - - it("rejects non-http(s) schemes", () => { - expect(validateTopicUrl("ftp://ntfy.sh/topic")).toMatch(/http/); - }); - - it("rejects URLs missing a topic path", () => { - expect(validateTopicUrl("https://ntfy.sh")).toMatch(/topic/); - expect(validateTopicUrl("https://ntfy.sh/")).toMatch(/topic/); - }); - - it("rejects multi-segment paths (topic must be a single segment)", () => { - expect(validateTopicUrl("https://ntfy.sh/team/sub")).toMatch(/single topic/); - }); - - it("rejects topic names with disallowed characters", () => { - expect(validateTopicUrl("https://ntfy.sh/has.dot")).toMatch(/letters\/numbers/); - expect(validateTopicUrl("https://ntfy.sh/has space")).toMatch(/letters\/numbers/); - expect(validateTopicUrl("https://ntfy.sh/has%20enc")).toMatch(/letters\/numbers/); - }); - - it("rejects topic names longer than 64 chars", () => { - const long = "a".repeat(65); - expect(validateTopicUrl(`https://ntfy.sh/${long}`)).toMatch(/64/); - }); - - it("accepts 64-char topic names and underscore/hyphen mixes", () => { - const maxlen = "a".repeat(64); - expect(validateTopicUrl(`https://ntfy.sh/${maxlen}`)).toBeNull(); - expect(validateTopicUrl("https://ntfy.sh/My_Topic-123")).toBeNull(); + it("URL-encodes the topic so any string yields a valid URL", () => { + // Spaces, slashes, unicode — all preserved as encoded bytes; the ntfy + // server is the final authority on what it accepts. + expect(buildNtfyUrl("has space")).toBe(`${NTFY_BASE_URL}/has%20space`); + expect(buildNtfyUrl("a/b")).toBe(`${NTFY_BASE_URL}/a%2Fb`); + expect(buildNtfyUrl("日本語")).toBe(`${NTFY_BASE_URL}/${encodeURIComponent("日本語")}`); }); }); describe("sendNtfy", () => { - it("POSTs to the topic URL with Title/Priority/Tags/Content-Type headers and body", async () => { + it("POSTs to https://ntfy.sh/<topic> with Title/Priority/Tags/Content-Type headers and body", async () => { const fetchImpl = makeFetch(); const result = await sendNtfy( makeConfig(), @@ -95,7 +68,7 @@ describe("sendNtfy", () => { expect(result.ok).toBe(true); expect(fetchImpl).toHaveBeenCalledTimes(1); const [url, init] = fetchImpl.mock.calls[0]; - expect(url).toBe("https://ntfy.sh/my-topic"); + expect(url).toBe(`${NTFY_BASE_URL}/my-topic`); expect(init.method).toBe("POST"); expect(init.headers.Title).toBe("Hello"); expect(init.headers.Priority).toBe("4"); @@ -104,6 +77,21 @@ describe("sendNtfy", () => { expect(init.body).toBe("World"); }); + it("accepts arbitrary topic strings without a client-side pattern check", async () => { + const fetchImpl = makeFetch(); + // Things the old validator would have rejected — dots, spaces, unicode, + // a single-word "any topic". All should POST and let ntfy decide. + await sendNtfy(makeConfig({ topic: "release.notes" }), makeEvent(), fetchImpl); + await sendNtfy(makeConfig({ topic: "with space" }), makeEvent(), fetchImpl); + await sendNtfy(makeConfig({ topic: "Any Topic Whatsoever" }), makeEvent(), fetchImpl); + await sendNtfy(makeConfig({ topic: "日本語" }), makeEvent(), fetchImpl); + expect(fetchImpl).toHaveBeenCalledTimes(4); + expect(fetchImpl.mock.calls[0][0]).toBe(`${NTFY_BASE_URL}/release.notes`); + expect(fetchImpl.mock.calls[1][0]).toBe(`${NTFY_BASE_URL}/with%20space`); + expect(fetchImpl.mock.calls[2][0]).toBe(`${NTFY_BASE_URL}/Any%20Topic%20Whatsoever`); + expect(fetchImpl.mock.calls[3][0]).toBe(`${NTFY_BASE_URL}/${encodeURIComponent("日本語")}`); + }); + it("uses per-event-type defaults for priority and tags", async () => { const fetchImpl = makeFetch(); await sendNtfy(makeConfig(), makeEvent({ type: "turn-error" }), fetchImpl); @@ -183,11 +171,16 @@ describe("sendNtfy", () => { expect(fetchImpl).not.toHaveBeenCalled(); }); - it("returns ok:false on invalid topic URL without calling fetch", async () => { + it("returns ok:false when topic is empty / whitespace, without calling fetch", async () => { const fetchImpl = makeFetch(); - const result = await sendNtfy(makeConfig({ topicUrl: "not a url" }), makeEvent(), fetchImpl); - expect(result.ok).toBe(false); - expect(result.error).toBeDefined(); + const empty = await sendNtfy(makeConfig({ topic: "" }), makeEvent(), fetchImpl); + expect(empty.ok).toBe(false); + expect(empty.error).toMatch(/required/i); + + const ws = await sendNtfy(makeConfig({ topic: " " }), makeEvent(), fetchImpl); + expect(ws.ok).toBe(false); + expect(ws.error).toMatch(/required/i); + expect(fetchImpl).not.toHaveBeenCalled(); }); diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index 08e86ac..dc9d07d 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -32,7 +32,7 @@ type NotificationEventType = interface NtfyConfigView { enabled: boolean; - topicUrl: string; + topic: string; authToken: string; hasAuthToken?: boolean; events: Record<NotificationEventType, boolean>; @@ -48,7 +48,7 @@ const NTFY_EVENT_LABELS: Record<NotificationEventType, string> = { const DEFAULT_NTFY: NtfyConfigView = { enabled: false, - topicUrl: "", + topic: "", authToken: "", hasAuthToken: false, events: { @@ -162,7 +162,7 @@ async function saveNtfy(): Promise<void> { // `authToken: ""` ⇒ explicit clear (the user typed and cleared). const payload: Partial<NtfyConfigView> & { authToken?: string } = { enabled: ntfy.enabled, - topicUrl: ntfy.topicUrl, + topic: ntfy.topic, events: ntfy.events, notifySubagents: ntfy.notifySubagents, }; @@ -421,15 +421,15 @@ $effect(() => { </label> <label class="text-xs text-base-content/60 flex flex-col gap-1"> - Topic URL + Topic <input type="text" class="input input-bordered input-sm w-full" - placeholder="https://ntfy.sh/your-secret-topic" - bind:value={ntfy.topicUrl} + placeholder="your-secret-topic" + bind:value={ntfy.topic} /> <span class="text-[10px] text-base-content/40"> - Pick something unguessable — anyone with the URL can read your notifications. + Any string — pick something unguessable, since anyone with the topic name can read your notifications. Subscribe to the same topic in the ntfy app. </span> </label> |
