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

Email verification's sentences live in one module, and the API and MCP read them from there #5574

Merged opened this

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

Merged

mason/forge:mason/5558-email-verification-s-sentences-live-in-o → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/5558-email-verification-s-sentences-live-in-o was merged into main as cc28f60.

Review

Sign in to review this change.
4 files changed +125 −6
src/actions/verify_words.ts added +11
@@ -0,0 +1,11 @@
1 /**
2 * What a person asking for another email-verification link is told when it
3 * is refused, keyed by the refusal. Every door — POST
4 * /api/v1/user/email/verify and the MCP `email_verify` tool — reads its
5 * sentence from here, so the doors cannot drift apart.
6 */
7 export const VERIFY_WORDS = {
8 too_soon: "a link was sent a moment ago; check your inbox, or try again in a few minutes",
9 failed: "the link could not be sent; try again",
10 no_mail: "this forge sends no mail yet; ask its owner to verify you",
11 } as const;
src/http/api.ts +4 −3
@@ -78,6 +78,7 @@ import {
7878 } from "../actions/accounts.ts";
7979 import { resendVerification } from "../actions/verify.ts";
8080 import { CAPPED_WORDS } from "../actions/mail.ts";
81 import { VERIFY_WORDS } from "../actions/verify_words.ts";
8182 import { editMessage } from "../actions/edit_words.ts";
8283 import { emailVerified } from "../core/verify.ts";
8384 import { readHealth } from "../actions/health.ts";
@@ -1390,12 +1391,12 @@ export function apiRoutes(router: Router<Handler>): void {
13901391 return json({ outcome: sent.value }, sent.value === "sent" ? 202 : 200);
13911392 if (sent.error === "unauthorized") return unauthorized();
13921393 if (sent.error === "too_soon")
1393 return apiError("too_soon", 429, "a link was sent a moment ago; check your inbox, or try again in a few minutes");
1394 return apiError("too_soon", 429, VERIFY_WORDS.too_soon);
13941395 if (sent.error === "no_mail")
1395 return apiError("no_mail", 503, "this forge sends no mail yet; ask its owner to verify you");
1396 return apiError("no_mail", 503, VERIFY_WORDS.no_mail);
13961397 if (sent.error === "capped")
13971398 return apiError("capped", 429, CAPPED_WORDS);
1398 return apiError("failed", 502, "the link could not be sent; try again");
1399 return apiError("failed", 502, VERIFY_WORDS.failed);
13991400 });
14001401
14011402 router.add("PATCH", "/api/v1/user", async (ctx) => {
src/http/mcp.ts +4 −3
@@ -55,6 +55,7 @@ import { PROVIDERS } from "../cloud/providers.ts";
5555 import { changeQuotas, readQuotas } from "../actions/quotas.ts";
5656 import { resendVerification } from "../actions/verify.ts";
5757 import { CAPPED_WORDS } from "../actions/mail.ts";
58 import { VERIFY_WORDS } from "../actions/verify_words.ts";
5859 import { SPEND_WORDS } from "../actions/spend_words.ts";
5960 import { changeSignup, readSignup } from "../actions/signup.ts";
6061 import { SIGNUP_MODES } from "../settings/signup.ts";
@@ -4738,12 +4739,12 @@ const TOOLS: readonly Tool[] = [
47384739 says: sent.value === "sent" ? "A new verification email is on its way." : "Your email is already verified.",
47394740 });
47404741 if (sent.error === "too_soon")
4741 return bad("too_soon", "a link was sent a moment ago; check your inbox, or try again in a few minutes");
4742 return bad("too_soon", VERIFY_WORDS.too_soon);
47424743 if (sent.error === "no_mail")
4743 return bad("no_mail", "this forge sends no mail yet; ask its owner to verify you");
4744 return bad("no_mail", VERIFY_WORDS.no_mail);
47444745 if (sent.error === "capped") return bad("capped", CAPPED_WORDS);
47454746 if (sent.error === "unauthorized") return bad("unauthorized", "sign in first");
4746 return bad("failed", "the link could not be sent; try again");
4747 return bad("failed", VERIFY_WORDS.failed);
47474748 },
47484749 },
47494750 {
test/verify-words.test.ts added +106
@@ -0,0 +1,106 @@
1 /**
2 * Asking for another verification link reads the same from every door: the
3 * API and the MCP `email_verify` tool both answer a refusal with the
4 * sentence `actions/verify_words.ts` holds, so a door that retypes the
5 * words fails here.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { VERIFY_WORDS } from "../src/actions/verify_words.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { createToken } from "../src/core/tokens.ts";
14 import { getUserByName } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { setLimit } from "../src/settings/limits.ts";
17 import { seedAdmin } from "./helpers/admin.ts";
18
19 describe("email verification's words", () => {
20 let dataDir: string;
21 let app: App;
22 let servers: Servers;
23 let id = 1;
24 let delivers = true;
25
26 beforeAll(async () => {
27 dataDir = await mkdtemp(join(tmpdir(), "forge-verify-words-"));
28 app = createApp({
29 dataDir,
30 port: 0,
31 url: "https://forge.example",
32 mailer: async () => delivers,
33 });
34 servers = await serve(app);
35 setLimit(app.db, "signup.per_source", 1000, "setup");
36 const owner = await seedAdmin(app, { username: "olive", email: "[email protected]", password: "owner-passphrase" });
37 if (!owner.ok) throw new Error(owner.error);
38 });
39 afterAll(async () => {
40 servers.stop();
41 app.close();
42 await rm(dataDir, { recursive: true, force: true });
43 });
44
45 const newcomer = async (name: string) => {
46 const res = await fetch(`${servers.url}/api/v1/users`, {
47 method: "POST",
48 headers: { "content-type": "application/json" },
49 body: JSON.stringify({ username: name, email: `${name}@outsider.test`, password: "outsider-passphrase" }),
50 });
51 expect(res.status).toBe(201);
52 const user = getUserByName(app.db, name)!;
53 return { user, token: createToken(app.db, user.id, "t", "rw").token };
54 };
55 const forgetLastSend = (userId: string) =>
56 app.db.query("UPDATE email_check SET last_sent_at = '2000-01-01T00:00:00.000Z' WHERE user_id = ?1").run(userId);
57
58 const viaApi = async (token: string) => {
59 const res = await fetch(`${servers.url}/api/v1/user/email/verify`, {
60 method: "POST",
61 headers: { authorization: `Bearer ${token}` },
62 });
63 const body: any = await res.json();
64 return { status: res.status, error: body.error as string, message: body.message as string };
65 };
66 const viaMcp = async (token: string) => {
67 const res = await fetch(`${servers.url}/mcp`, {
68 method: "POST",
69 headers: {
70 "content-type": "application/json",
71 accept: "application/json, text/event-stream",
72 authorization: `Bearer ${token}`,
73 },
74 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "email_verify", arguments: {} } }),
75 });
76 const body: any = await res.json();
77 return { isError: body.result.isError === true, text: body.result.content[0].text as string };
78 };
79
80 test("too soon: both doors answer the module's sentence", async () => {
81 const { token } = await newcomer("ada");
82 const api = await viaApi(token);
83 expect(api.status).toBe(429);

Nit: the toEqual on the next line already checks status, so this line can go.

84 expect(api).toEqual({ status: 429, error: "too_soon", message: VERIFY_WORDS.too_soon });
85 const mcp = await viaMcp(token);
86 expect(mcp.isError).toBe(true);
87 expect(mcp.text).toContain(VERIFY_WORDS.too_soon);
88 });
89
90 test("a send that fails: both doors answer the module's sentence", async () => {
91 const { user, token } = await newcomer("grace");
92 delivers = false;
93 try {
94 forgetLastSend(user.id);
95 const api = await viaApi(token);
96 expect(api.status).toBe(502);
97 expect(api).toEqual({ status: 502, error: "failed", message: VERIFY_WORDS.failed });
98 forgetLastSend(user.id);
99 const mcp = await viaMcp(token);
100 expect(mcp.isError).toBe(true);
101 expect(mcp.text).toContain(VERIFY_WORDS.failed);
102 } finally {
103 delivers = true;
104 }

Minor, not blocking: no_mail (503) is the third key in VERIFY_WORDS, but neither door is tested for it. A forge created without a mailer would cover it the same way the other two refusals are covered. Worth adding the next time this file is touched.

105 });
106 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs