Public: anyone can read this; the crew can change it.
The terminal's notify screen sends a test push with `t` and says whether it arrived #8085
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8081-the-terminal-s-notify-screen-sends-a-tes → main 4 commits fork
3 files
changed +159
−6
src/crew/notify.ts +30
| @@ -169,6 +169,36 @@ export const post: Sender = async (url, body, headers) => { | ||
| 169 | 169 | } |
| 170 | 170 | }; |
| 171 | 171 | |
| 172 | export const TEST_LINE = "Test from the forge — notifications reach you."; | |
| 173 | const TEST_GAP_MS = 30_000; | |
| 174 | const lastTest = new Map<string, number>(); | |
| 175 | ||
| 176 | /** Whether this person may send a test now: one per `TEST_GAP_MS`. Claims the slot when it says yes. */ | |
| 177 | export function claimTest(userId: string, now = Date.now()): boolean { | |
| 178 | for (const [id, at] of lastTest) if (now - at >= TEST_GAP_MS) lastTest.delete(id); | |
| 179 | if (lastTest.has(userId)) return false; | |
| 180 | lastTest.set(userId, now); | |
| 181 | return true; | |
| 182 | } | |
| 183 | ||
| 184 | /** | |
| 185 | * One short line to the saved URL, to prove it works. Never throws; the | |
| 186 | * URL's path (it can hold a secret) is never logged. | |
| 187 | */ | |
| 188 | export async function sendTest( | |
| 189 | db: Database, | |
| 190 | userId: string, | |
| 191 | sender: Sender = post, | |
| 192 | ): Promise<"sent" | "refused" | "off"> { | |
| 193 | const url = notifyUrlOf(db, userId); | |
| 194 | if (url === undefined) return "off"; | |
| 195 | try { | |
| 196 | return (await sender(url, TEST_LINE, { title: "forge" })) ? "sent" : "refused"; | |
| 197 | } catch { | |
| 198 | return "refused"; | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 172 | 202 | /** |
| 173 | 203 | * The forge's admin hears of a red restore drill, or none in eight days, |
| 174 | 204 | * once per cause: the id is the drill's own key, so a repeat tick is not news. |
src/tui/screens/notify.ts +31 −6
| @@ -1,12 +1,13 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Your phone notify URL in the terminal: the host notifications go to, or |
| 3 | * that they are off. `s` asks for a URL and saves it, `x` turns it off after | |
| 4 | * a confirm. The URL may carry a secret in its path (an ntfy topic is a | |
| 5 | * password), so only its host is ever drawn; the rules and the words are | |
| 6 | * `crew/notify.ts`'s, as Settings and MCP `notify` use them. | |
| 3 | * that they are off. `s` asks for a URL and saves it, `t` sends a test | |
| 4 | * line, `x` turns it off after a confirm. The URL may carry a secret in | |
| 5 | * its path (an ntfy topic is a password), so only its host is ever drawn; | |
| 6 | * the rules and the words are `crew/notify.ts`'s, as Settings and MCP | |
| 7 | * `notify` use them. | |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | import { NOTIFY_ERRORS, notifyHost, notifyUrlOf, setNotifyUrl } from "../../crew/notify.ts"; | |
| 10 | import { NOTIFY_ERRORS, claimTest, notifyHost, notifyUrlOf, post, sendTest, setNotifyUrl, type Sender } from "../../crew/notify.ts"; | |
| 10 | 11 | import type { Ctx, Screen } from "../app/app.ts"; |
| 11 | 12 | import { binding } from "../app/keys.ts"; |
| 12 | 13 | import { style } from "../term/style.ts"; |
| @@ -14,6 +15,9 @@ import { truncate } from "../term/width.ts"; | ||
| 14 | 15 | import type { Session } from "../session.ts"; |
| 15 | 16 | import { confirmScreen, promptScreen } from "./common.ts"; |
| 16 | 17 | |
| 18 | /** The sender `t` uses; a test swaps it for a fake. */ | |
| 19 | export const testSender: { current: Sender } = { current: post }; | |
| 20 | ||
| 17 | 21 | export function notifyScreen(f: Session, ctx: Ctx): Screen { |
| 18 | 22 | let note = ""; |
| 19 | 23 | const host = () => |
| @@ -29,7 +33,13 @@ export function notifyScreen(f: Session, ctx: Ctx): Screen { | ||
| 29 | 33 | |
| 30 | 34 | const screen: Screen = { |
| 31 | 35 | title: "Phone notifications", |
| 32 | keys: [binding(["s"], "set the URL"), binding(["x"], "turn off")], | |
| 36 | get keys() { | |
| 37 | return [ | |
| 38 | binding(["s"], "set the URL"), | |
| 39 | ...(host() === undefined ? [] : [binding(["t"], "send a test")]), | |
| 40 | binding(["x"], "turn off"), | |
| 41 | ]; | |
| 42 | }, | |
| 33 | 43 | render(area, s) { |
| 34 | 44 | const c = ctx.theme.c; |
| 35 | 45 | const h = host(); |
| @@ -58,6 +68,21 @@ export function notifyScreen(f: Session, ctx: Ctx): Screen { | ||
| 58 | 68 | ); |
| 59 | 69 | return true; |
| 60 | 70 | } |
| 71 | if (e.key === "t") { | |
| 72 | const h = host(); | |
| 73 | if (h === undefined || f.user === undefined) return true; | |
| 74 | if (!claimTest(f.user.id)) { | |
| 75 | ctx.toast("just sent — wait a moment"); | |
| 76 | return true; | |
| 77 | } | |
| 78 | const id = f.user.id; | |
| 79 | void sendTest(f.app.db, id, testSender.current).then((r) => { | |
| 80 | if (r === "sent") ctx.toast(`sent to ${h}`); | |
| 81 | else ctx.toast(`${h} did not take it`, "danger"); | |
| 82 | ctx.invalidate(); | |
| 83 | }); | |
| 84 | return true; | |
| 85 | } | |
| 61 | 86 | if (e.key === "x") { |
| 62 | 87 | if (host() === undefined) { |
| 63 | 88 | note = "Already off."; |
test/notify-test-send.test.ts added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | /** The notify screen's `t`: a test line to the saved URL, and whether it arrived (#8081). */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { TEST_LINE, claimTest, sendTest, setNotifyUrl, type Sender } from "../src/crew/notify.ts"; | |
| 8 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 9 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 10 | import { testSender } from "../src/tui/screens/notify.ts"; | |
| 11 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | import { settleApp } from "./helpers/tui.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | const URL_ = "https://ntfy.example.com/secret-topic-123"; | |
| 18 | ||
| 19 | describe("send a test push", () => { | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let id: string; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | dataDir = await mkdtemp(join(tmpdir(), "forge-notify-test-")); | |
| 26 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 27 | const made = await createUser(app.db, { | |
| 28 | origin: "seed", | |
| 29 | trusted: true, | |
| 30 | username: "alice", | |
| 31 | email: "[email protected]", | |
| 32 | password: "a-good-passphrase", | |
| 33 | adminIfFirst: true, | |
| 34 | }); | |
| 35 | if (!made.ok) throw new Error(made.error); | |
| 36 | id = getUserByName(app.db, "alice")!.id; | |
| 37 | }); | |
| 38 | afterAll(async () => { | |
| 39 | await rm(dataDir, { recursive: true, force: true }); | |
| 40 | }); | |
| 41 | ||
| 42 | test("off, sent, refused, and a throwing sender", async () => { | |
| 43 | const calls: string[] = []; | |
| 44 | const ok: Sender = async (u, body) => (calls.push(u, body), true); | |
| 45 | expect(await sendTest(app.db, id, ok)).toBe("off"); | |
| 46 | expect(calls).toEqual([]); | |
| 47 | setNotifyUrl(app.db, id, URL_); | |
| 48 | expect(await sendTest(app.db, id, ok)).toBe("sent"); | |
| 49 | expect(calls).toEqual([URL_, TEST_LINE]); | |
| 50 | expect(await sendTest(app.db, id, async () => false)).toBe("refused"); | |
| 51 | expect( | |
| 52 | await sendTest(app.db, id, async () => { | |
| 53 | throw new Error("boom"); | |
| 54 | }), | |
| 55 | ).toBe("refused"); | |
| 56 | }); | |
| 57 | ||
| 58 | test("one per user per 30 seconds", () => { | |
| 59 | expect(claimTest("u1", 1_000)).toBe(true); | |
| 60 | expect(claimTest("u1", 20_000)).toBe(false); | |
| 61 | expect(claimTest("u2", 20_000)).toBe(true); | |
| 62 | expect(claimTest("u1", 31_001)).toBe(true); | |
| 63 | }); | |
| 64 | ||
| 65 | test("t in the terminal toasts the result, then the wait", async () => { | |
| 66 | setNotifyUrl(app.db, id, URL_); | |
| 67 | const terminal = headlessTerminal(140, 32); | |
| 68 | const { app: tui } = createForgeApp( | |
| 69 | app, | |
| 70 | { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(app.db, "alice")! }, | |
| 71 | { terminal, exitHooks: false }, | |
| 72 | ); | |
| 73 | tui.start(); | |
| 74 | const key = async (k: string, ctrl = false) => { | |
| 75 | tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 76 | await settleApp(tui); | |
| 77 | }; | |
| 78 | const sent: string[] = []; | |
| 79 | const saved = testSender.current; | |
| 80 | testSender.current = async (u) => (sent.push(u), false); | |
| 81 | try { | |
| 82 | await key("k", true); | |
| 83 | for (const ch of "Phone notifications") await key(ch); | |
| 84 | await key("enter"); | |
| 85 | expect(tui.screen.toText()).toContain("send a test"); | |
| 86 | await key("t"); | |
| 87 | expect(sent).toEqual([URL_]); | |
| 88 | expect(tui.screen.toText()).toContain("ntfy.example.com did not take it"); | |
| 89 | expect(tui.screen.toText()).not.toContain("secret-topic"); | |
| 90 | await key("t"); | |
| 91 | expect(sent.length).toBe(1); | |
| 92 | expect(tui.screen.toText()).toContain("just sent — wait a moment"); | |
| 93 | } finally { | |
| 94 | testSender.current = saved; | |
| 95 | tui.stop(); | |
| 96 | } | |
| 97 | }); | |
| 98 | }); |
new above
offline