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

Merged opened this

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

Merged

scout/forge:scout/7884-get-and-put-api-v1-user-notify-read-and → main 5 commits fork 1 thread · 1 unresolved

  • Merged. scout/7884-get-and-put-api-v1-user-notify-read-and was merged into main as 459054d.

Review

Sign in to review this change.
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.
14891489 link-local, `.local`, `.internal` and bare hosts. The name is resolved and
14901490 judged again at send time and redirects are not followed. It is kept in the
14911491 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"
14931494 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
14951498 shows only the host; an agent is not offered the screen.
14961499 - Not here yet: a health problem for an admin has no event to ride.
14971500
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";
134134 export { route as getApiV1UserAssigned } from "./getApiV1UserAssigned.ts";
135135 export { route as getApiV1UserKeys } from "./getApiV1UserKeys.ts";
136136 export { route as getApiV1UserNextSteps } from "./getApiV1UserNextSteps.ts";
137 export { route as getApiV1UserNotify } from "./getApiV1UserNotify.ts";
137138 export { route as getApiV1UserOrgs } from "./getApiV1UserOrgs.ts";
138139 export { route as getApiV1UserOriginApps } from "./getApiV1UserOriginApps.ts";
139140 export { route as getApiV1UserOrigins } from "./getApiV1UserOrigins.ts";
@@ -290,4 +291,5 @@ export { route as putApiV1ReposOwnerNamePullsNumberHold } from "./putApiV1ReposO
290291 export { route as putApiV1ReposOwnerNamePullsNumberLabels } from "./putApiV1ReposOwnerNamePullsNumberLabels.ts";
291292 export { route as putApiV1ReposOwnerNameUpstream } from "./putApiV1ReposOwnerNameUpstream.ts";
292293 export { route as putApiV1SecretsScopeName } from "./putApiV1SecretsScopeName.ts";
294 export { route as putApiV1UserNotify } from "./putApiV1UserNotify.ts";
293295 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
1212 import { addSshKey, changePassword, deleteSshKey, updateProfile } from "../../actions/users.ts";
1313 import { VERIFY_WORDS } from "../../actions/verify_words.ts";
1414 import { resendVerification } from "../../actions/verify.ts";
15 import { NOTIFY_ERRORS, notifyHost, notifyUrlOf, setNotifyUrl } from "../../crew/notify.ts";
1516 import { listAssignedTo } from "../../core/issues.ts";
1617 import { nextStepsFor } from "../../core/next-steps.ts";
1718 import { listKeys, publicKey } from "../../core/keys.ts";
@@ -203,6 +204,31 @@ export function userRoutes(router: Router<Handler>): void {
203204 return new Response(null, { status: 204 });
204205 });
205206
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);
outdated

Docs: add PUT /api/v1/user/notify (admin scope, person only) to the list of doors in docs/APPS.md (~1492) and to the notify row in docs/MCP.md:119, which says "as Settings does" and does not name the API.

230 });
231
206232 // ── origins ──────────────────────────────────────────────────────────
207233 //
208234 // 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs