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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/347-something-that-newly-needs-you-reaches-y → main 1 commit fork
7 files
changed +637
−10
docs/APPS.md +22
| @@ -790,6 +790,28 @@ of your own: | ||
| 790 | 790 | issue; it is for the repository's admins, like everything else about its |
| 791 | 791 | settings. |
| 792 | 792 | |
| 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 [](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 | ||
| 793 | 815 | ## Configuration |
| 794 | 816 | |
| 795 | 817 | | 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 { | ||
| 203 | 203 | } from "../web/pages/org.ts"; |
| 204 | 204 | import { |
| 205 | 205 | accountPage, |
| 206 | notifyUpdate, | |
| 206 | 207 | accountUpdate, |
| 207 | 208 | keyCreate, |
| 208 | 209 | keyDelete, |
| @@ -457,6 +458,7 @@ export function buildRouter(): Router<Handler> { | ||
| 457 | 458 | router.add("GET", "/runs/:id", runRedirect); |
| 458 | 459 | router.add("GET", "/settings", accountPage); |
| 459 | 460 | router.add("POST", "/settings", accountUpdate); |
| 461 | router.add("POST", "/settings/notify", notifyUpdate); | |
| 460 | 462 | router.add("GET", "/settings/password", passwordPage); |
| 461 | 463 | router.add("POST", "/settings/password", passwordUpdate); |
| 462 | 464 | router.add("GET", "/settings/tokens", tokensPage); |
src/main.ts +4
| @@ -25,6 +25,7 @@ import { STALE_AFTER_MS } from "./runs/lease.ts"; | ||
| 25 | 25 | import { startCrew } from "./crew/dispatch.ts"; |
| 26 | 26 | import { typeSafeDecider } from "./crew/decide.ts"; |
| 27 | 27 | import { graphInstagram } from "./crew/instagram.ts"; |
| 28 | import { startNotifier } from "./crew/notify.ts"; | |
| 28 | 29 | import { startPromptRoutine } from "./crew/prompts.ts"; |
| 29 | 30 | import { pruneTokens } from "./core/tokens.ts"; |
| 30 | 31 | |
| @@ -190,6 +191,8 @@ const crew = appsOn | ||
| 190 | 191 | // that connected an account, the top comment on its latest Reel is told to |
| 191 | 192 | // the crew. A minute's clock; nothing leaves the box until a source exists. |
| 192 | 193 | 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 }); | |
| 193 | 196 | |
| 194 | 197 | // The webhook delivery worker: poll the outbox for due deliveries. The event |
| 195 | 198 | // row is the source of truth, so a missed tick only delays delivery, never |
| @@ -276,6 +279,7 @@ function shutdown(): void { | ||
| 276 | 279 | clearInterval(leaseTimer); |
| 277 | 280 | Promise.all([ |
| 278 | 281 | prompts.stop(), |
| 282 | notifier.stop(), | |
| 279 | 283 | crew?.stop({ drain: true }), |
| 280 | 284 | apps?.stop(), |
| 281 | 285 | checks?.stop(), |
src/web/pages/account.ts +57 −2
| @@ -17,6 +17,12 @@ import { | ||
| 17 | 17 | deleteToken, |
| 18 | 18 | updateProfile, |
| 19 | 19 | } from "../../actions/users.ts"; |
| 20 | import { | |
| 21 | NOTIFY_ERRORS, | |
| 22 | notifyHost, | |
| 23 | notifyUrlOf, | |
| 24 | setNotifyUrl, | |
| 25 | } from "../../crew/notify.ts"; | |
| 20 | 26 | import { listKeys } from "../../core/keys.ts"; |
| 21 | 27 | import { listTokens, type TokenScope } from "../../core/tokens.ts"; |
| 22 | 28 | import type { Ctx } from "../../http/context.ts"; |
| @@ -75,6 +81,30 @@ interface ProfileForm { | ||
| 75 | 81 | errors?: { displayName?: string; email?: string }; |
| 76 | 82 | } |
| 77 | 83 | |
| 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 | ||
| 78 | 108 | function profileView(ctx: Ctx, values: ProfileForm): Html { |
| 79 | 109 | const user = ctx.auth.user!; |
| 80 | 110 | return card( |
| @@ -126,13 +156,38 @@ export function accountPage(ctx: Ctx): Response { | ||
| 126 | 156 | ctx, |
| 127 | 157 | "profile", |
| 128 | 158 | "Settings", |
| 129 | profileView(ctx, { | |
| 159 | html`${profileView(ctx, { | |
| 130 | 160 | displayName: ctx.auth.user.displayName, |
| 131 | 161 | email: ctx.auth.user.email, |
| 132 | }), | |
| 162 | })}${notifyView(ctx)}`, | |
| 133 | 163 | ); |
| 134 | 164 | } |
| 135 | 165 | |
| 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 | ||
| 136 | 191 | // POST /settings |
| 137 | 192 | export async function accountUpdate(ctx: Ctx): Promise<Response> { |
| 138 | 193 | if (!ctx.auth.user) return signInFirst(ctx); |
src/web/studio/asks.ts +32 −8
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * to the route a person could have posted to by hand (`actions.ts`). |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import type { Database } from "bun:sqlite"; | |
| 10 | 11 | import { previewEnv } from "../../apps/data.ts"; |
| 11 | 12 | import { crewForRepos } from "../../core/agents.ts"; |
| 12 | 13 | import { appsForRepos, getAppByRepo } from "../../core/apps.ts"; |
| @@ -100,12 +101,12 @@ export interface Decision { | ||
| 100 | 101 | } |
| 101 | 102 | |
| 102 | 103 | /** 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 { | |
| 104 | 105 | let latest = issue.createdAt; |
| 105 | for (const c of listComments(ctx.app.db, issue.id)) | |
| 106 | for (const c of listComments(db, issue.id)) | |
| 106 | 107 | if (c.authorName !== agentName && c.createdAt > latest) |
| 107 | 108 | latest = c.createdAt; |
| 108 | for (const r of listReviews(ctx.app.db, issue.id)) | |
| 109 | for (const r of listReviews(db, issue.id)) | |
| 109 | 110 | if (r.authorName !== agentName && r.createdAt > latest) |
| 110 | 111 | latest = r.createdAt; |
| 111 | 112 | return latest; |
| @@ -141,10 +142,12 @@ interface AskCandidate { | ||
| 141 | 142 | * each (`spentTodayForRepos`, `crewForRepos`), not `autonomyStatus` per |
| 142 | 143 | * repository. |
| 143 | 144 | */ |
| 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[] { | |
| 146 | 150 | const out: AskCandidate[] = []; |
| 147 | const user = ctx.auth.user; | |
| 148 | 151 | if (!user) return out; |
| 149 | 152 | // Shipping, closing and the day's ceiling are the owner's own decisions — |
| 150 | 153 | // 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[] { | ||
| 172 | 175 | // own machinery — a person's own failing pull never accrues tries, |
| 173 | 176 | // so it never earns a "stopped" card; it just stays waiting. |
| 174 | 177 | if (!issue.authorIsAgent) continue; |
| 175 | const since = lastHumanWord(ctx, issue, who); | |
| 178 | const since = lastHumanWord(db, issue, who); | |
| 176 | 179 | const tries = automaticRunsSince(db, issue.id, since); |
| 177 | 180 | const stopped = latestDecision(db, issue.id, "retry")?.answer === "stop"; |
| 178 | 181 | // 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[] { | ||
| 220 | 223 | return out; |
| 221 | 224 | } |
| 222 | 225 | |
| 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 | ||
| 223 | 247 | export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] { |
| 224 | 248 | const db = ctx.app.db; |
| 225 | const candidates = askCandidatesFor(ctx, allRepos); | |
| 249 | const candidates = askCandidatesFor(db, ctx.auth.user, allRepos); | |
| 226 | 250 | // The pictures, in one read: a green pull's row leads with its preview. |
| 227 | 251 | const repoIds = [...new Set(candidates.map((c) => c.repo.id))]; |
| 228 | 252 | 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 | }); |
new above
offline