From 21cdb1199599c4dc6e2a941e52713ba6511cd675 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:28:30 +0900 Subject: feat(api): wire notification dispatcher into app + /notifications routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PermissionManager: add onPromptAdded(listener) callback. Fires exactly once per unique pending prompt id, even when broadcastPending is called repeatedly for unrelated mutations (e.g. another prompt resolving while this one is still pending). app.ts: instantiate NotificationDispatcher, attach to both AgentManager and PermissionManager. Tab-title lookup via core's getTab so the notifications carry human-readable context instead of raw UUIDs. routes/notifications.ts: - GET /notifications — current config (auth token redacted) plus the event-type catalog and defaults - PUT /notifications — partial update; auth token semantics are undefined=keep, ''=clear, otherwise replace - POST /notifications/test — sends a test notification with the current config (rejects if disabled or topic invalid) Tests: - new permission-manager.test.ts covers the onPromptAdded contract (one-fire-per-prompt, dedup across rebroadcasts, unsubscribe, listener throws don't break siblings) - existing routes.test.ts gets stubs for the new core notification exports so the @dispatch/core mock stays complete --- packages/api/src/routes/notifications.ts | 81 ++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 packages/api/src/routes/notifications.ts (limited to 'packages/api/src/routes') diff --git a/packages/api/src/routes/notifications.ts b/packages/api/src/routes/notifications.ts new file mode 100644 index 0000000..57519bc --- /dev/null +++ b/packages/api/src/routes/notifications.ts @@ -0,0 +1,81 @@ +// `/notifications` — ntfy.sh config + test-send route. + +import { + defaultNtfyConfig, + loadNtfyConfig, + type NotificationEventType, + NTFY_EVENT_TYPES, + type NtfyConfig, + normalizeNtfyConfig, + redactNtfyConfig, + saveNtfyConfig, + sendNtfy, + validateTopicUrl, +} from "@dispatch/core"; +import { Hono } from "hono"; + +export const notificationsRoutes = new Hono(); + +notificationsRoutes.get("/", (c) => { + const config = loadNtfyConfig(); + return c.json({ + config: redactNtfyConfig(config), + eventTypes: NTFY_EVENT_TYPES, + defaults: defaultNtfyConfig(), + }); +}); + +notificationsRoutes.put("/", async (c) => { + const body = await c.req.json & { authToken?: string }>(); + const existing = loadNtfyConfig(); + + // `authToken === ""` ⇒ explicit clear; `authToken === undefined` ⇒ keep + // the existing token (the GET response redacts it, so the frontend doesn't + // have it to send back). Any other string ⇒ replace. + let nextAuthToken = existing.authToken; + if (typeof body.authToken === "string") nextAuthToken = body.authToken; + + const merged = normalizeNtfyConfig({ + enabled: typeof body.enabled === "boolean" ? body.enabled : existing.enabled, + topicUrl: typeof body.topicUrl === "string" ? body.topicUrl : existing.topicUrl, + authToken: nextAuthToken, + events: { ...existing.events, ...(body.events ?? {}) }, + }); + + if (merged.enabled) { + const err = validateTopicUrl(merged.topicUrl); + if (err) return c.json({ error: err }, 400); + } + + saveNtfyConfig(merged); + return c.json({ config: redactNtfyConfig(merged) }); +}); + +notificationsRoutes.post("/test", async (c) => { + const config = loadNtfyConfig(); + 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); + + // 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 + // common enabled-by-default event. + const eventType: NotificationEventType = "turn-completed"; + if (!config.events[eventType]) { + return c.json( + { ok: false, error: `Event type "${eventType}" is disabled — enable it to test.` }, + 400, + ); + } + + const result = await sendNtfy(config, { + type: eventType, + title: "Dispatch test notification", + message: "If you can see this, ntfy.sh notifications are wired up correctly.", + tags: ["bell"], + }); + if (!result.ok) return c.json(result, 502); + return c.json(result); +}); -- cgit v1.2.3 From d3d94b69a9f98a0a4b68dc6ee830466471adf26d Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 11:28:03 +0900 Subject: 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/` (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. --- HANDOFF.md | 26 +++++-- packages/api/src/routes/notifications.ts | 21 ++++-- packages/api/tests/routes.test.ts | 7 +- packages/core/src/notifications/config.ts | 4 +- packages/core/src/notifications/index.ts | 8 +- packages/core/src/notifications/ntfy.ts | 55 +++++--------- packages/core/src/notifications/types.ts | 10 ++- packages/core/tests/notifications/config.test.ts | 18 ++--- .../core/tests/notifications/dispatcher.test.ts | 2 +- packages/core/tests/notifications/ntfy.test.ts | 87 ++++++++++------------ .../src/lib/components/SettingsPanel.svelte | 14 ++-- 11 files changed, 127 insertions(+), 125 deletions(-) (limited to 'packages/api/src/routes') diff --git a/HANDOFF.md b/HANDOFF.md index 46cce2d..fde84c8 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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/` (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/`. `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; 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:///` with the body +// ntfy's API is a simple POST to `https://ntfy.sh/` 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; body: string; signal?: AbortSignal }, ) => Promise<{ ok: boolean; status: number; statusText?: string; text(): Promise }>; -/** - * 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 { 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; 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).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 { 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 { 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 { "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/ 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; @@ -48,7 +48,7 @@ const NTFY_EVENT_LABELS: Record = { const DEFAULT_NTFY: NtfyConfigView = { enabled: false, - topicUrl: "", + topic: "", authToken: "", hasAuthToken: false, events: { @@ -162,7 +162,7 @@ async function saveNtfy(): Promise { // `authToken: ""` ⇒ explicit clear (the user typed and cleared). const payload: Partial & { authToken?: string } = { enabled: ntfy.enabled, - topicUrl: ntfy.topicUrl, + topic: ntfy.topic, events: ntfy.events, notifySubagents: ntfy.notifySubagents, }; @@ -421,15 +421,15 @@ $effect(() => { -- cgit v1.2.3