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 /packages/core/src | |
| 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.
Diffstat (limited to 'packages/core/src')
| -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 |
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; |
