Public: anyone can read this; the crew can change it.

Something that newly needs you reaches your phone: a notify URL fed from the event stream #354

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/347-something-that-newly-needs-you-reaches-y → main 1 commit fork

  • Merged. scout/347-something-that-newly-needs-you-reaches-y was merged into main as 4b9f1ce.

Review

Sign in to review this change.
7 files changed +637 −10
docs/APPS.md +22
@@ -790,6 +790,28 @@ of your own:
790790 issue; it is for the repository's admins, like everything else about its
791791 settings.
792792
793 ## Something that needs you reaches your phone
794
795 Under Settings → Profile a person may set one **notify URL**: an `https:`
796 address that takes a POST of plain text (an [ntfy](https://ntfy.sh) topic
797 does). When the forge's events change what asks them — a pull the crew
798 stopped on, a green pull waiting for a look, a repository at its ceiling —
799 one line and a link to the asks screen is posted there (`src/crew/notify.ts`).
800
801 - *New only.* What asks a person is read as that person, the way the asks
802 panel reads it, so a private repository's titles never go to someone who
803 cannot read it. An ask is told once; it is new again only after it went
804 away and came back. A restart, or saving the URL, starts from what asks now.
805 - *Bounded.* Ten an hour per person. A failing endpoint pauses that person's
806 notifications for a minute, doubling to an hour; what could not be sent is
807 dropped, never retried or queued. Nothing blocks the event stream.
808 - *The URL is a secret* (a topic name is a password). It is `https:` only,
809 without credentials, at most 200 characters, and refuses loopback, private,
810 link-local, `.local`, `.internal` and bare hosts. The name is resolved and
811 judged again at send time and redirects are not followed. It is kept in the
812 user's settings, never logged, and the account page shows only its host.
813 - Not here yet: a health problem for an admin has no event to ride.
814
793815 ## Configuration
794816
795817 | variable | default | meaning |
src/crew/notify.ts added +255
@@ -0,0 +1,255 @@
1 /**
2 * Something that newly needs you reaches your phone. A person may set one
3 * HTTPS URL (an ntfy topic, or anything that takes a POST of plain text);
4 * when the forge's events change what asks them — a pull the crew stopped
5 * on, a green pull waiting for a look, a repository at its ceiling — one
6 * short line and a link to the asks screen is posted there.
7 *
8 * What asks a person is read the way the asks panel reads it
9 * (`askLinesFor`), as that person, so nothing private leaves for someone who
10 * could not read it. "New" means not in the set last seen for them. At most
11 * one line per ask, at most `HOURLY` an hour per person, and a dead endpoint
12 * costs a growing pause rather than a retry: a line that cannot be sent is
13 * dropped, never queued. Nothing here blocks the event stream — the work
14 * runs after the event, off its stack.
15 *
16 * The URL is a secret (an ntfy topic is a password): it lives in the user's
17 * settings, is never logged, and the account page shows its host only.
18 */
19
20 import type { Database } from "bun:sqlite";
21 import { lookup } from "node:dns/promises";
22 import type { App } from "../app.ts";
23 import { isPrivateHost } from "../actions/migrate.ts";
24 import { getUserSetting, setUserSetting } from "../core/settings.ts";
25 import { getUserById } from "../core/users.ts";
26 import { err, ok, type Result } from "../kernel/result.ts";
27 import { askLinesFor } from "../web/studio/asks.ts";
28
29 const KEY = "notify_url";
30 export const MAX_URL = 200;
31 const HOURLY = 10;
32 const HOUR_MS = 3_600_000;
33 const SEND_TIMEOUT_MS = 5_000;
34 const BACKOFF_FIRST_MS = 60_000;
35 const BACKOFF_MAX_MS = HOUR_MS;
36
37 /** Events after which the set of asks may have changed. */
38 const WAKES = new Set([
39 "run.finished",
40 "pull.opened",
41 "pull.pushed",
42 "pull.reviewed",
43 "pull.merged",
44 "issue.closed",
45 "issue.commented",
46 "repo.settings_updated",
47 "account.notify_set",
48 ]);
49
50 export type NotifyUrlError =
51 | "invalid_url"
52 | "not_https"
53 | "credentials"
54 | "too_long"
55 | "private_host";
56
57 /** The URL as it will be stored, or why it is refused. */
58 export function parseNotifyUrl(raw: string): Result<string, NotifyUrlError> {
59 const text = raw.trim();
60 if (text.length > MAX_URL) return err("too_long");
61 let url: URL;
62 try {
63 url = new URL(text);
64 } catch {
65 return err("invalid_url");
66 }
67 if (url.protocol !== "https:") return err("not_https");
68 if (url.username !== "" || url.password !== "") return err("credentials");
69 const host = url.hostname.toLowerCase();
70 if (
71 isPrivateHost(host) ||
72 host.endsWith(".local") ||
73 host.endsWith(".internal") ||
74 !host.includes(".") || // a bare name is inside someone's network
75 host.startsWith("[") // an IPv6 literal is never a topic host
76 )
77 return err("private_host");
78 return ok(url.toString());
79 }
80
81 export const NOTIFY_ERRORS: Record<NotifyUrlError, string> = {
82 invalid_url: "That isn't a URL.",
83 not_https: "Only https: addresses are accepted.",
84 credentials: "Leave the username and password out of the address.",
85 too_long: `Keep it under ${MAX_URL} characters.`,
86 private_host: "That address is inside a network, not on the internet.",
87 };
88
89 export const notifyUrlOf = (db: Database, userId: string): string | undefined =>
90 getUserSetting(db, userId, KEY) || undefined;
91
92 /** Save a validated URL; an empty string clears it. */
93 export function setNotifyUrl(
94 db: Database,
95 userId: string,
96 raw: string,
97 ): Result<"set" | "cleared", NotifyUrlError> {
98 if (raw.trim() === "") {
99 db.query("DELETE FROM user_setting WHERE user_id = ?1 AND key = ?2").run(
100 userId,
101 KEY,
102 );
103 return ok("cleared");
104 }
105 const parsed = parseNotifyUrl(raw);
106 if (!parsed.ok) return parsed;
107 setUserSetting(db, userId, KEY, parsed.value);
108 return ok("set");
109 }
110
111 /** What the account page may show of the URL: its host, nothing after. */
112 export function notifyHost(url: string | undefined): string | undefined {
113 if (url === undefined) return undefined;
114 try {
115 return new URL(url).host;
116 } catch {
117 return undefined;
118 }
119 }
120
121 export type Sender = (
122 url: string,
123 body: string,
124 headers: Record<string, string>,
125 ) => Promise<boolean>;
126
127 /** The real sender: the name is resolved and judged first, no redirect is followed. */
128 const post: Sender = async (url, body, headers) => {
129 const host = new URL(url).hostname;
130 const found = await lookup(host, { all: true });
131 if (found.length === 0 || found.some((a) => isPrivateHost(a.address)))
132 return false;
133 const res = await fetch(url, {
134 method: "POST",
135 body,
136 headers: { "content-type": "text/plain; charset=utf-8", ...headers },
137 redirect: "manual",
138 signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
139 });
140 return res.status >= 200 && res.status < 300;
141 };
142
143 export interface Notifier {
144 /** Resolves when the work queued so far is done. For tests and shutdown. */
145 settled(): Promise<void>;
146 stop(): void;
147 }
148
149 export interface NotifyOptions {
150 /** The forge's public address; the link goes to its asks screen. */
151 forgeUrl: string;
152 send?: Sender;
153 now?: () => number;
154 }
155
156 const oneLine = (s: string): string =>
157 // eslint-disable-next-line no-control-regex
158 s.replace(/[\u0000-\u001f\u007f]+/g, " ").slice(0, 160);
159
160 export function startNotifier(app: App, options: NotifyOptions): Notifier {
161 const send = options.send ?? post;
162 const now = options.now ?? Date.now;
163 const link = new URL("/", options.forgeUrl).toString();
164 /** Per person: the asks last seen, and so already told or deliberately skipped. */
165 const seen = new Map<string, Set<string>>();
166 const sent = new Map<string, number[]>();
167 const pause = new Map<string, { until: number; next: number }>();
168 let queue: Promise<void> = Promise.resolve();
169 let stopped = false;
170
171 const people = (): string[] =>
172 app.db
173 .query<
174 { user_id: string },
175 [string]
176 >("SELECT user_id FROM user_setting WHERE key = ?1 AND value <> ''")
177 .all(KEY)
178 .map((r) => r.user_id);
179
180 async function tell(userId: string, baseline: boolean): Promise<void> {
181 const user = getUserById(app.db, userId);
182 const url = notifyUrlOf(app.db, userId);
183 if (user === undefined || url === undefined) {
184 seen.delete(userId);
185 return;
186 }
187 const asks = askLinesFor(app.db, user);
188 const before = seen.get(userId);
189 // What is gone may ask again later, and then it is new again.
190 seen.set(userId, new Set(asks.map((a) => a.id)));
191 if (before === undefined && !baseline) baseline = true;
192 if (baseline) return;
193 for (const ask of asks) {
194 if (before!.has(ask.id)) continue;
195 const t = now();
196 const recent = (sent.get(userId) ?? []).filter((s) => t - s < HOUR_MS);
197 sent.set(userId, recent);
198 if (recent.length >= HOURLY) return;
199 if (t < (pause.get(userId)?.until ?? 0)) return;
200 recent.push(t);
201 let delivered = false;
202 try {
203 delivered = await send(url, `${oneLine(ask.line)}\n${link}`, {
204 click: link,
205 title: "forge",
206 });
207 } catch {
208 delivered = false;
209 }
210 if (!delivered) {
211 const next = Math.min(
212 pause.get(userId)?.next ?? BACKOFF_FIRST_MS,
213 BACKOFF_MAX_MS,
214 );
215 pause.set(userId, { until: now() + next, next: next * 2 });
216 // The host only: the path is the secret.
217 app.log.warn("notify endpoint failed; pausing", {
218 host: notifyHost(url) ?? "",
219 pauseMs: next,
220 });
221 return;
222 }
223 pause.delete(userId);
224 }
225 }
226
227 const unsubscribe = app.bus.subscribe((event) => {
228 if (stopped || !WAKES.has(event.kind)) return;
229 const own =
230 event.kind === "account.notify_set" ? event.actorId : undefined;
231 // Off the appender's stack, and one at a time: a slow endpoint holds up
232 // only the notifier's own queue.
233 queue = queue.then(async () => {
234 try {
235 if (own !== null && own !== undefined) await tell(own, true);
236 else for (const id of people()) await tell(id, false);
237 } catch (error) {
238 app.log.error("notify failed", { error: String(error) });
239 }
240 });
241 });
242
243 // A forge that was down has told what it told: start from what asks now.
244 queue = queue.then(async () => {
245 for (const id of people()) await tell(id, true).catch(() => undefined);
246 });
247
248 return {
249 settled: () => queue,
250 stop() {
251 stopped = true;
252 unsubscribe();
253 },
254 };
255 }
src/http/server.ts +2
@@ -203,6 +203,7 @@ import {
203203 } from "../web/pages/org.ts";
204204 import {
205205 accountPage,
206 notifyUpdate,
206207 accountUpdate,
207208 keyCreate,
208209 keyDelete,
@@ -457,6 +458,7 @@ export function buildRouter(): Router<Handler> {
457458 router.add("GET", "/runs/:id", runRedirect);
458459 router.add("GET", "/settings", accountPage);
459460 router.add("POST", "/settings", accountUpdate);
461 router.add("POST", "/settings/notify", notifyUpdate);
460462 router.add("GET", "/settings/password", passwordPage);
461463 router.add("POST", "/settings/password", passwordUpdate);
462464 router.add("GET", "/settings/tokens", tokensPage);
src/main.ts +4
@@ -25,6 +25,7 @@ import { STALE_AFTER_MS } from "./runs/lease.ts";
2525 import { startCrew } from "./crew/dispatch.ts";
2626 import { typeSafeDecider } from "./crew/decide.ts";
2727 import { graphInstagram } from "./crew/instagram.ts";
28 import { startNotifier } from "./crew/notify.ts";
2829 import { startPromptRoutine } from "./crew/prompts.ts";
2930 import { pruneTokens } from "./core/tokens.ts";
3031
@@ -190,6 +191,8 @@ const crew = appsOn
190191 // that connected an account, the top comment on its latest Reel is told to
191192 // the crew. A minute's clock; nothing leaves the box until a source exists.
192193 const prompts = startPromptRoutine(app, { client: graphInstagram() });
194 // What newly asks a person reaches the phone they chose (docs/APPS.md).
195 const notifier = startNotifier(app, { forgeUrl });
193196
194197 // The webhook delivery worker: poll the outbox for due deliveries. The event
195198 // row is the source of truth, so a missed tick only delays delivery, never
@@ -276,6 +279,7 @@ function shutdown(): void {
276279 clearInterval(leaseTimer);
277280 Promise.all([
278281 prompts.stop(),
282 notifier.stop(),
279283 crew?.stop({ drain: true }),
280284 apps?.stop(),
281285 checks?.stop(),
src/web/pages/account.ts +57 −2
@@ -17,6 +17,12 @@ import {
1717 deleteToken,
1818 updateProfile,
1919 } from "../../actions/users.ts";
20 import {
21 NOTIFY_ERRORS,
22 notifyHost,
23 notifyUrlOf,
24 setNotifyUrl,
25 } from "../../crew/notify.ts";
2026 import { listKeys } from "../../core/keys.ts";
2127 import { listTokens, type TokenScope } from "../../core/tokens.ts";
2228 import type { Ctx } from "../../http/context.ts";
@@ -75,6 +81,30 @@ interface ProfileForm {
7581 errors?: { displayName?: string; email?: string };
7682 }
7783
84 function notifyView(ctx: Ctx, error?: string): Html {
85 const host = notifyHost(notifyUrlOf(ctx.app.db, ctx.auth.user!.id));
86 return card(
87 html`<form method="post" action="/settings/notify">
88 ${when(error, (m) => html`<p class="error-note">${m}</p>`)}
89 ${uiField({
90 label: "Notify URL",
91 name: "url",
92 type: "url",
93 autocomplete: "off",
94 placeholder: host === undefined ? "https://ntfy.sh/your-topic" : "••••••••",
95 hint:
96 host === undefined
97 ? "When something newly needs you, one line and a link is posted here. https: only; leave it empty to turn it off."
98 : `Sending to ${host}. The rest of the address stays hidden; paste a new one to replace it, or save it empty to turn it off.`,
99 })}
100 <div class="form-actions">
101 ${btn("Save notify URL", { variant: "primary" })}
102 </div>
103 </form>`,
104 { head: html`<h3>Phone notifications</h3>` },
105 );
106 }
107
78108 function profileView(ctx: Ctx, values: ProfileForm): Html {
79109 const user = ctx.auth.user!;
80110 return card(
@@ -126,13 +156,38 @@ export function accountPage(ctx: Ctx): Response {
126156 ctx,
127157 "profile",
128158 "Settings",
129 profileView(ctx, {
159 html`${profileView(ctx, {
130160 displayName: ctx.auth.user.displayName,
131161 email: ctx.auth.user.email,
132 }),
162 })}${notifyView(ctx)}`,
133163 );
134164 }
135165
166 // POST /settings/notify
167 export async function notifyUpdate(ctx: Ctx): Promise<Response> {
168 if (!ctx.auth.user) return signInFirst(ctx);
169 const form = await readForm(ctx, "admin");
170 if (form instanceof Response) return form;
171 const saved = setNotifyUrl(ctx.app.db, ctx.auth.user.id, field(form, "url"));
172 if (!saved.ok)
173 return accountShell(
174 ctx,
175 "profile",
176 "Settings",
177 html`${profileView(ctx, {
178 displayName: ctx.auth.user.displayName,
179 email: ctx.auth.user.email,
180 })}${notifyView(ctx, NOTIFY_ERRORS[saved.error])}`,
181 422,
182 );
183 // The notifier starts from what asks now, so only what comes after is new.
184 ctx.app.bus.append({ kind: "account.notify_set", actorId: ctx.auth.user.id });
185 return flashTo(ctx, "/settings", {
186 message:
187 saved.value === "set" ? "Notify URL saved." : "Notifications turned off.",
188 });
189 }
190
136191 // POST /settings
137192 export async function accountUpdate(ctx: Ctx): Promise<Response> {
138193 if (!ctx.auth.user) return signInFirst(ctx);
src/web/studio/asks.ts +32 −8
@@ -7,6 +7,7 @@
77 * to the route a person could have posted to by hand (`actions.ts`).
88 */
99
10 import type { Database } from "bun:sqlite";
1011 import { previewEnv } from "../../apps/data.ts";
1112 import { crewForRepos } from "../../core/agents.ts";
1213 import { appsForRepos, getAppByRepo } from "../../core/apps.ts";
@@ -100,12 +101,12 @@ export interface Decision {
100101 }
101102
102103 /** When a person last spoke on a conversation, or its start. */
103 function lastHumanWord(ctx: Ctx, issue: Issue, agentName: string): string {
104 function lastHumanWord(db: Database, issue: Issue, agentName: string): string {
104105 let latest = issue.createdAt;
105 for (const c of listComments(ctx.app.db, issue.id))
106 for (const c of listComments(db, issue.id))
106107 if (c.authorName !== agentName && c.createdAt > latest)
107108 latest = c.createdAt;
108 for (const r of listReviews(ctx.app.db, issue.id))
109 for (const r of listReviews(db, issue.id))
109110 if (r.authorName !== agentName && r.createdAt > latest)
110111 latest = r.createdAt;
111112 return latest;
@@ -141,10 +142,12 @@ interface AskCandidate {
141142 * each (`spentTodayForRepos`, `crewForRepos`), not `autonomyStatus` per
142143 * repository.
143144 */
144 function askCandidatesFor(ctx: Ctx, allRepos: readonly Repo[]): AskCandidate[] {
145 const db = ctx.app.db;
145 function askCandidatesFor(
146 db: Database,
147 user: User | undefined,
148 allRepos: readonly Repo[],
149 ): AskCandidate[] {
146150 const out: AskCandidate[] = [];
147 const user = ctx.auth.user;
148151 if (!user) return out;
149152 // Shipping, closing and the day's ceiling are the owner's own decisions —
150153 // a reader who cannot write here is never asked to make one, and never
@@ -172,7 +175,7 @@ function askCandidatesFor(ctx: Ctx, allRepos: readonly Repo[]): AskCandidate[] {
172175 // own machinery — a person's own failing pull never accrues tries,
173176 // so it never earns a "stopped" card; it just stays waiting.
174177 if (!issue.authorIsAgent) continue;
175 const since = lastHumanWord(ctx, issue, who);
178 const since = lastHumanWord(db, issue, who);
176179 const tries = automaticRunsSince(db, issue.id, since);
177180 const stopped = latestDecision(db, issue.id, "retry")?.answer === "stop";
178181 // A repository on its own parks what it cannot finish and moves on;
@@ -220,9 +223,30 @@ function askCandidatesFor(ctx: Ctx, allRepos: readonly Repo[]): AskCandidate[] {
220223 return out;
221224 }
222225
226 /**
227 * What asks `user` right now, as a stable id and one plain line each — the
228 * panel's own candidates (same visibility, same bounds), without the
229 * pictures. What the phone's notifier compares from one moment to the next.
230 */
231 export function askLinesFor(
232 db: Database,
233 user: User,
234 ): { id: string; line: string }[] {
235 const repos = listReposVisibleTo(db, user, { limit: 200 });
236 return askCandidatesFor(db, user, repos).map((c) => {
237 const repo = `${c.repo.name}`;
238 if (c.kind === "paused")
239 return { id: `paused:${c.repo.id}`, line: `${repo} is paused at its ceiling` };
240 const title = c.pull?.title ?? "";
241 return c.kind === "stopped"
242 ? { id: `stopped:${c.pull!.id}`, line: `${repo}: the crew stopped on "${title}"` }
243 : { id: `look:${c.pull!.id}`, line: `${repo}: "${title}" is green, waiting for your look` };
244 });
245 }
246
223247 export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] {
224248 const db = ctx.app.db;
225 const candidates = askCandidatesFor(ctx, allRepos);
249 const candidates = askCandidatesFor(db, ctx.auth.user, allRepos);
226250 // The pictures, in one read: a green pull's row leads with its preview.
227251 const repoIds = [...new Set(candidates.map((c) => c.repo.id))];
228252 const records = appsForRepos(db, repoIds);
test/notify.test.ts added +265
@@ -0,0 +1,265 @@
1 /**
2 * Something that newly needs you reaches your phone: one POST per ask, none
3 * for what was already told, none for what the person cannot read, and a URL
4 * that cannot be turned on the network.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { announceRun } from "../src/actions/runs.ts";
12 import { openPull } from "../src/actions/pulls.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import type { Repo } from "../src/core/repos.ts";
15 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import {
19 notifyHost,
20 notifyUrlOf,
21 parseNotifyUrl,
22 setNotifyUrl,
23 startNotifier,
24 type Notifier,
25 type Sender,
26 } from "../src/crew/notify.ts";
27
28 const URL_A = "https://ntfy.example.test/alice-secret-topic";
29 const URL_B = "https://ntfy.example.test/bob-secret-topic";
30
31 describe("notify", () => {
32 let dataDir: string;
33 let app: App;
34 let alice: User;
35 let bob: User;
36 let repo: Repo;
37 let notifier: Notifier;
38 const posts: { url: string; body: string }[] = [];
39 let healthy = true;
40 let clock = 1_000_000;
41 const send: Sender = async (url, body) => {
42 posts.push({ url, body });
43 return healthy;
44 };
45
46 const user = async (username: string, first = false): Promise<User> => {
47 const made = await createUser(app.db, {
48 username,
49 email: `${username}@forge.local`,
50 password: "a-good-passphrase",
51 adminIfFirst: first,
52 });
53 if (!made.ok) throw new Error(made.error);
54 return made.value;
55 };
56
57 /** A green pull, announced the way the checks worker announces it. */
58 async function greenPull(n: number, title: string): Promise<void> {
59 const branch = await commitToBranch(app, alice, repo, {
60 branch: `feature/n${n}`,
61 base: "main",
62 message: title,
63 files: [{ path: `N${n}.md`, content: `${n}\n` }],
64 });
65 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
66 const opened = await openPull(app, alice, repo, {
67 title,
68 body: "",
69 baseBranch: "main",
70 headBranch: `feature/n${n}`,
71 });
72 if (!opened.ok) throw new Error(String(opened.error));
73 const run = createRun(app.db, {
74 repoId: repo.id,
75 kind: "check",
76 issueId: opened.value.id,
77 env: `pull-${opened.value.number}`,
78 commitOid: opened.value.headOid,
79 reason: "pull",
80 });
81 claimRun(app.db, run.id);
82 const done = finishRun(app.db, run.id, { state: "ok", summary: "ok" });
83 announceRun(app, done!);
84 await notifier.settled();
85 }
86
87 beforeAll(async () => {
88 dataDir = await mkdtemp(join(tmpdir(), "forge-notify-"));
89 app = createApp({ dataDir, port: 0 });
90 alice = await user("alice", true);
91 bob = await user("bob");
92 const made = await createRepo(app, alice, {
93 name: "garden",
94 visibility: "private",
95 });
96 if (!made.ok) throw new Error(String(made.error));
97 repo = made.value;
98 await commitToBranch(app, alice, repo, {
99 message: "seed",
100 files: [{ path: "README.md", content: "# garden\n" }],
101 });
102 expect(setNotifyUrl(app.db, alice.id, URL_A).ok).toBe(true);
103 expect(setNotifyUrl(app.db, bob.id, URL_B).ok).toBe(true);
104 notifier = startNotifier(app, {
105 forgeUrl: "https://forge.example.test",
106 send,
107 now: () => clock,
108 });
109 await notifier.settled();
110 });
111 afterAll(async () => {
112 notifier.stop();
113 await rm(dataDir, { recursive: true, force: true });
114 });
115
116 test("a green pull sends one POST with a link to the asks screen; the same pull again sends none", async () => {
117 await greenPull(1, "Add a bench");
118 expect(posts).toHaveLength(1);
119 expect(posts[0]!.url).toBe(URL_A);
120 expect(posts[0]!.body).toContain("Add a bench");
121 expect(posts[0]!.body).toContain("https://forge.example.test/");
122 // The same pull, announced again: nothing new asks.
123 announceRun(
124 app,
125 createRun(app.db, {
126 repoId: repo.id,
127 kind: "check",
128 env: "main",
129 commitOid: "0".repeat(40),
130 reason: "push",
131 }),
132 );
133 await notifier.settled();
134 expect(posts).toHaveLength(1);
135 });
136
137 test("a person who cannot read the private repository is told nothing of it", () => {
138 expect(posts.some((p) => p.url === URL_B)).toBe(false);
139 expect(posts.some((p) => p.body.includes("bench") && p.url === URL_B)).toBe(
140 false,
141 );
142 });
143
144 test("a failing endpoint is paused, not retried; the pause ends", async () => {
145 healthy = false;
146 await greenPull(2, "Second");
147 expect(posts).toHaveLength(2);
148 await greenPull(3, "Third");
149 expect(posts).toHaveLength(2); // paused: dropped, not retried
150 healthy = true;
151 clock += 3_600_000 + 1;
152 await greenPull(4, "Fourth");
153 expect(posts).toHaveLength(3);
154 });
155
156 test("at most ten an hour per person", async () => {
157 const before = posts.length;
158 // The fourth pull already spent one of this hour's ten.
159 for (let i = 5; i < 20; i++) await greenPull(i, `Pull ${i}`);
160 expect(posts.length - before).toBe(9);
161 });
162 });
163
164 describe("the notify URL", () => {
165 test("an http: URL is refused; so are credentials, long, private and loopback hosts", () => {
166 const refused = (u: string) => {
167 const r = parseNotifyUrl(u);
168 return r.ok ? "ok" : r.error;
169 };
170 expect(refused("http://ntfy.sh/topic")).toBe("not_https");
171 expect(refused("https://user:[email protected]/topic")).toBe("credentials");
172 expect(refused("https://ntfy.sh/" + "a".repeat(200))).toBe("too_long");
173 expect(refused("not a url")).toBe("invalid_url");
174 for (const host of [
175 "localhost",
176 "127.0.0.1",
177 "10.0.0.5",
178 "192.168.1.1",
179 "169.254.169.254",
180 "0x7f.1",
181 "2130706433",
182 "[::1]",
183 "intranet",
184 "box.local",
185 ])
186 expect(refused(`https://${host}/t`)).toBe("private_host");
187 expect(refused("https://ntfy.sh/topic")).toBe("ok");
188 });
189
190 test("only the host is ever shown", () => {
191 expect(notifyHost("https://ntfy.example.test/secret-topic")).toBe(
192 "ntfy.example.test",
193 );
194 });
195 });
196
197 describe("the account page", () => {
198 let dataDir: string;
199 let app: App;
200 let servers: Servers;
201 let cookie: string;
202 let alice: User;
203 const secret = "https://ntfy.example.test/very-secret-topic";
204
205 beforeAll(async () => {
206 dataDir = await mkdtemp(join(tmpdir(), "forge-notify-page-"));
207 app = createApp({ dataDir, port: 0 });
208 servers = await serve(app);
209 const made = await createUser(app.db, {
210 username: "alice",
211 email: "[email protected]",
212 password: "a-long-passphrase",
213 adminIfFirst: true,
214 });
215 if (!made.ok) throw new Error(made.error);
216 alice = made.value;
217 const signin = await fetch(`${servers.url}/signin`, {
218 method: "POST",
219 body: new URLSearchParams({
220 username: "alice",
221 password: "a-long-passphrase",
222 }),
223 redirect: "manual",
224 });
225 cookie = signin.headers.get("set-cookie")!.split(";")[0]!;
226 });
227 afterAll(async () => {
228 servers.stop();
229 await rm(dataDir, { recursive: true, force: true });
230 });
231
232 const save = (url: string, headers: Record<string, string> = {}) =>
233 fetch(`${servers.url}/settings/notify`, {
234 method: "POST",
235 headers: { cookie, ...headers },
236 body: new URLSearchParams({ url }),
237 redirect: "manual",
238 });
239
240 test("an http: URL is refused and nothing is stored; a good one is stored and only its host shown", async () => {
241 const refused = await save("http://ntfy.example.test/t");
242 expect(refused.status).toBe(422);
243 expect(await refused.text()).toContain("Only https:");
244 expect(notifyUrlOf(app.db, alice.id)).toBeUndefined();
245
246 const saved = await save(secret);
247 expect(saved.status).toBe(303);
248 expect(notifyUrlOf(app.db, alice.id)).toBe(secret);
249 const page = await (
250 await fetch(`${servers.url}/settings`, { headers: { cookie } })
251 ).text();
252 expect(page).toContain("ntfy.example.test");
253 expect(page).not.toContain("very-secret-topic");
254 });
255
256 test("a cross-origin post changes nothing; an empty one turns it off", async () => {
257 const cross = await save("https://evil.example.test/x", {
258 origin: "https://evil.example.test",
259 });
260 expect(cross.status).toBe(403);
261 expect(notifyUrlOf(app.db, alice.id)).toBe(secret);
262 await save("");
263 expect(notifyUrlOf(app.db, alice.id)).toBeUndefined();
264 });
265 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs