Public: anyone can read this; the crew can change it.
GET and PUT /api/v1/user/notify read and set your phone notify URL, as MCP `notify` and Settings already do #7893
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7884-get-and-put-api-v1-user-notify-read-and → main 5 commits fork 1 thread · 1 unresolved
6 files
changed +151
−2
docs/APPS.md +5 −2
| @@ -1489,9 +1489,12 @@ a link to the milestone (`docs/PLANNING.md`); its progress never does. | ||
| 1489 | 1489 | link-local, `.local`, `.internal` and bare hosts. The name is resolved and |
| 1490 | 1490 | judged again at send time and redirects are not followed. It is kept in the |
| 1491 | 1491 | user's settings, never logged, and the account page shows only its host. |
| 1492 | - *Doors.* Settings, MCP `notify` and the terminal's "Phone notifications" | |
| 1492 | - *Doors.* Settings, MCP `notify`, `GET`/`PUT /api/v1/user/notify` and the | |
| 1493 | terminal's "Phone notifications" | |
| 1493 | 1494 | screen (`src/tui/screens/notify.ts`: `s` sets, `x` turns off after a confirm) |
| 1494 | all go through `setNotifyUrl`. The terminal draws the typed URL as dots and | |
| 1495 | all go through `setNotifyUrl`. The API door is a person's only (an agent | |
| 1496 | gets 403) and `PUT` needs an admin-scope token; both answer only the host. | |
| 1497 | The terminal draws the typed URL as dots and | |
| 1495 | 1498 | shows only the host; an agent is not offered the screen. |
| 1496 | 1499 | - Not here yet: a health problem for an admin has no event to ride. |
| 1497 | 1500 |
src/contract/openapi/routes/getApiV1UserNotify.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/user/notify", | |
| 6 | summary: "Read whether your phone notify URL is set; answers { set, host } with the host only, never the full URL; a person's, an agent gets 403", | |
| 7 | auth: true, | |
| 8 | refusals: { unauthorized: "401: no valid credentials were sent", forbidden: "403: the caller is an agent" }, | |
| 9 | }; |
src/contract/openapi/routes/index.ts +2
| @@ -134,6 +134,7 @@ export { route as getApiV1User } from "./getApiV1User.ts"; | ||
| 134 | 134 | export { route as getApiV1UserAssigned } from "./getApiV1UserAssigned.ts"; |
| 135 | 135 | export { route as getApiV1UserKeys } from "./getApiV1UserKeys.ts"; |
| 136 | 136 | export { route as getApiV1UserNextSteps } from "./getApiV1UserNextSteps.ts"; |
| 137 | export { route as getApiV1UserNotify } from "./getApiV1UserNotify.ts"; | |
| 137 | 138 | export { route as getApiV1UserOrgs } from "./getApiV1UserOrgs.ts"; |
| 138 | 139 | export { route as getApiV1UserOriginApps } from "./getApiV1UserOriginApps.ts"; |
| 139 | 140 | export { route as getApiV1UserOrigins } from "./getApiV1UserOrigins.ts"; |
| @@ -290,4 +291,5 @@ export { route as putApiV1ReposOwnerNamePullsNumberHold } from "./putApiV1ReposO | ||
| 290 | 291 | export { route as putApiV1ReposOwnerNamePullsNumberLabels } from "./putApiV1ReposOwnerNamePullsNumberLabels.ts"; |
| 291 | 292 | export { route as putApiV1ReposOwnerNameUpstream } from "./putApiV1ReposOwnerNameUpstream.ts"; |
| 292 | 293 | export { route as putApiV1SecretsScopeName } from "./putApiV1SecretsScopeName.ts"; |
| 294 | export { route as putApiV1UserNotify } from "./putApiV1UserNotify.ts"; | |
| 293 | 295 | export { route as putApiV1UserPinsOwnerName } from "./putApiV1UserPinsOwnerName.ts"; |
src/contract/openapi/routes/putApiV1UserNotify.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "put", | |
| 5 | path: "/api/v1/user/notify", | |
| 6 | summary: "Set your phone notify URL from { url }, or turn it off with an empty url; needs a token with scope admin; answers { set, host }", | |
| 7 | auth: true, | |
| 8 | refusals: { unauthorized: "401: no valid credentials were sent", forbidden: "403: the caller is an agent, the token scope is below admin, or a browser session sent a cross-origin request", invalid_url: "422: the URL is refused, with the reason in message", invalid: "422: url was not text" }, | |
| 9 | }; |
src/http/api/user.ts +26
| @@ -12,6 +12,7 @@ import { connectOrigin, deleteOriginApp, disconnectOrigin, listOriginApps, listP | ||
| 12 | 12 | import { addSshKey, changePassword, deleteSshKey, updateProfile } from "../../actions/users.ts"; |
| 13 | 13 | import { VERIFY_WORDS } from "../../actions/verify_words.ts"; |
| 14 | 14 | import { resendVerification } from "../../actions/verify.ts"; |
| 15 | import { NOTIFY_ERRORS, notifyHost, notifyUrlOf, setNotifyUrl } from "../../crew/notify.ts"; | |
| 15 | 16 | import { listAssignedTo } from "../../core/issues.ts"; |
| 16 | 17 | import { nextStepsFor } from "../../core/next-steps.ts"; |
| 17 | 18 | import { listKeys, publicKey } from "../../core/keys.ts"; |
| @@ -203,6 +204,31 @@ export function userRoutes(router: Router<Handler>): void { | ||
| 203 | 204 | return new Response(null, { status: 204 }); |
| 204 | 205 | }); |
| 205 | 206 | |
| 207 | // The phone notify URL: a person's, and only its host is ever answered. | |
| 208 | const notifyState = (ctx: Ctx, userId: string) => { | |
| 209 | const host = notifyHost(notifyUrlOf(ctx.app.db, userId)); | |
| 210 | return json({ set: host !== undefined, host: host ?? null }); | |
| 211 | }; | |
| 212 | router.add("GET", "/api/v1/user/notify", (ctx) => { | |
| 213 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 214 | const user = ctx.auth.user; | |
| 215 | if (!user) return unauthorized(); | |
| 216 | if (user.kind === "agent") return apiError("forbidden", 403, "a notify URL is a person's, not an agent's"); | |
| 217 | return notifyState(ctx, user.id); | |
| 218 | }); | |
| 219 | router.add("PUT", "/api/v1/user/notify", async (ctx) => { | |
| 220 | const user = ctx.auth.user; | |
| 221 | if (user?.kind === "agent") return apiError("forbidden", 403, "a notify URL is a person's, not an agent's"); | |
| 222 | const denied = requireScope(ctx, "admin"); | |
| 223 | if (denied) return denied; | |
| 224 | const input = await body(ctx.request); | |
| 225 | if (typeof input.url !== "string") return apiError("invalid", 422, "send url as text, or empty to turn it off"); | |
| 226 | const saved = setNotifyUrl(ctx.app.db, user!.id, input.url); | |
| 227 | if (!saved.ok) return apiError("invalid_url", 422, NOTIFY_ERRORS[saved.error]); | |
| 228 | ctx.app.bus.append({ kind: "account.notify_set", actorId: user!.id }); | |
| 229 | return notifyState(ctx, user!.id); | |
| 230 | }); | |
| 231 | ||
| 206 | 232 | // ── origins ────────────────────────────────────────────────────────── |
| 207 | 233 | // |
| 208 | 234 | // An origin holds a credential for another forge, so every route here is |
test/api-user-notify.test.ts added +100
| @@ -0,0 +1,100 @@ | ||
| 1 | /** GET and PUT /api/v1/user/notify: read, set, clear and each refusal over HTTP (#7884). */ | |
| 2 | import { afterAll, beforeAll, 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 { createAgent } from "../src/actions/crew.ts"; | |
| 8 | import { NOTIFY_ERRORS } from "../src/crew/notify.ts"; | |
| 9 | import { createSession } from "../src/core/sessions.ts"; | |
| 10 | import { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | let dataDir: string; | |
| 18 | let app: App; | |
| 19 | let servers: Servers; | |
| 20 | let admin = ""; | |
| 21 | let rw = ""; | |
| 22 | let agent = ""; | |
| 23 | let cookie = ""; | |
| 24 | const events: string[] = []; | |
| 25 | ||
| 26 | const cells = process.env["FORGE_CELLS"]; | |
| 27 | const URL_ = "https://ntfy.example.com/secret-topic-token"; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | process.env["FORGE_CELLS"] = "off"; | |
| 31 | dataDir = await mkdtemp(join(tmpdir(), "forge-api-notify-")); | |
| 32 | app = createApp({ dataDir, port: 0 }); | |
| 33 | servers = await serve(app); | |
| 34 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 35 | if (!made.ok) throw new Error(String(made.error)); | |
| 36 | const alice = made.value; | |
| 37 | admin = createToken(app.db, alice.id, "a", "admin").token; | |
| 38 | rw = createToken(app.db, alice.id, "w", "rw").token; | |
| 39 | cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 40 | const bot = await createAgent(app, alice, { username: "bot" }); | |
| 41 | if (!bot.ok) throw new Error(String(bot.error)); | |
| 42 | agent = createToken(app.db, bot.value.user.id, "b", "admin").token; | |
| 43 | const append = app.bus.append.bind(app.bus); | |
| 44 | app.bus.append = ((e: { kind: string }) => (events.push(e.kind), append(e as never))) as typeof app.bus.append; | |
| 45 | }); | |
| 46 | afterAll(async () => { | |
| 47 | await servers.stop(); | |
| 48 | app.close(); | |
| 49 | if (cells === undefined) delete process.env["FORGE_CELLS"]; | |
| 50 | else process.env["FORGE_CELLS"] = cells; | |
| 51 | await rm(dataDir, { recursive: true, force: true }); | |
| 52 | }); | |
| 53 | ||
| 54 | const call = (method: string, token: string, body?: unknown, extra: Record<string, string> = {}) => | |
| 55 | fetch(`${servers.url}/api/v1/user/notify`, { | |
| 56 | method, | |
| 57 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...extra }, | |
| 58 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | |
| 59 | }); | |
| 60 | ||
| 61 | test("read, set, clear: the host only, and the bus hears each write", async () => { | |
| 62 | expect(await (await call("GET", admin)).json()).toEqual({ set: false, host: null }); | |
| 63 | const put = await call("PUT", admin, { url: URL_ }); | |
| 64 | expect(put.status).toBe(200); | |
| 65 | const text = await put.text(); | |
| 66 | expect(JSON.parse(text)).toEqual({ set: true, host: "ntfy.example.com" }); | |
| 67 | expect(text).not.toContain("secret-topic-token"); | |
| 68 | const got = await (await call("GET", rw)).text(); | |
| 69 | expect(JSON.parse(got)).toEqual({ set: true, host: "ntfy.example.com" }); | |
| 70 | expect(got).not.toContain("secret-topic-token"); | |
| 71 | const cleared = await call("PUT", admin, { url: "" }); | |
| 72 | expect(await cleared.json()).toEqual({ set: false, host: null }); | |
| 73 | expect(events.filter((k) => k === "account.notify_set").length).toBe(2); | |
| 74 | }); | |
| 75 | ||
| 76 | test("refusals: agent, scope, bad URL, bad body, cross-site, anonymous", async () => { | |
| 77 | expect((await call("GET", agent)).status).toBe(403); | |
| 78 | const a = await call("PUT", agent, { url: URL_ }); | |
| 79 | expect(a.status).toBe(403); | |
| 80 | expect(((await a.json()) as { error: string }).error).toBe("forbidden"); | |
| 81 | expect((await call("PUT", rw, { url: URL_ })).status).toBe(403); | |
| 82 | const bad = await call("PUT", admin, { url: "http://example.com/x" }); | |
| 83 | expect(bad.status).toBe(422); | |
| 84 | expect(((await bad.json()) as { message: string }).message).toBe(NOTIFY_ERRORS.not_https); | |
| 85 | expect((await call("PUT", admin, { url: 5 })).status).toBe(422); | |
| 86 | const cross = await fetch(`${servers.url}/api/v1/user/notify`, { | |
| 87 | method: "PUT", | |
| 88 | headers: { cookie, origin: "https://evil.example", "content-type": "application/json" }, | |
| 89 | body: JSON.stringify({ url: URL_ }), | |
| 90 | }); | |
| 91 | expect(cross.status).toBe(403); | |
| 92 | expect((await fetch(`${servers.url}/api/v1/user/notify`)).status).toBe(401); | |
| 93 | expect((await call("GET", admin)).status).toBe(200); | |
| 94 | expect(await (await call("GET", admin)).json()).toEqual({ set: false, host: null }); | |
| 95 | }); | |
| 96 | ||
| 97 | test("the OpenAPI document lists both", async () => { | |
| 98 | const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as { paths: Record<string, Record<string, unknown>> }; | |
| 99 | expect(Object.keys(doc.paths["/api/v1/user/notify"] ?? {}).sort()).toEqual(["get", "put"]); | |
| 100 | }); |
new above
offline
Docs: add
PUT /api/v1/user/notify(admin scope, person only) to the list of doors in docs/APPS.md (~1492) and to thenotifyrow in docs/MCP.md:119, which says "as Settings does" and does not name the API.