summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 11:28:03 +0900
committerAdam Malczewski <[email protected]>2026-06-01 11:28:03 +0900
commitd3d94b69a9f98a0a4b68dc6ee830466471adf26d (patch)
tree3320ab5b89d0ebb7578a43df28b6b95d2c3e0b3c
parent07970bd4c89068272b76407beb6df5bc9aef2ff7 (diff)
downloaddispatch-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.md26
-rw-r--r--packages/api/src/routes/notifications.ts21
-rw-r--r--packages/api/tests/routes.test.ts7
-rw-r--r--packages/core/src/notifications/config.ts4
-rw-r--r--packages/core/src/notifications/index.ts8
-rw-r--r--packages/core/src/notifications/ntfy.ts55
-rw-r--r--packages/core/src/notifications/types.ts10
-rw-r--r--packages/core/tests/notifications/config.test.ts18
-rw-r--r--packages/core/tests/notifications/dispatcher.test.ts2
-rw-r--r--packages/core/tests/notifications/ntfy.test.ts87
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte14
11 files changed, 127 insertions, 125 deletions
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/<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>