summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
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 /packages/core/src
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.
Diffstat (limited to 'packages/core/src')
-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
4 files changed, 33 insertions, 44 deletions
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;