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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5558-email-verification-s-sentences-live-in-o → main 2 commits fork 2 threads · 2 unresolved
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 { | ||
| 78 | 78 | } from "../actions/accounts.ts"; |
| 79 | 79 | import { resendVerification } from "../actions/verify.ts"; |
| 80 | 80 | import { CAPPED_WORDS } from "../actions/mail.ts"; |
| 81 | import { VERIFY_WORDS } from "../actions/verify_words.ts"; | |
| 81 | 82 | import { editMessage } from "../actions/edit_words.ts"; |
| 82 | 83 | import { emailVerified } from "../core/verify.ts"; |
| 83 | 84 | import { readHealth } from "../actions/health.ts"; |
| @@ -1390,12 +1391,12 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1390 | 1391 | return json({ outcome: sent.value }, sent.value === "sent" ? 202 : 200); |
| 1391 | 1392 | if (sent.error === "unauthorized") return unauthorized(); |
| 1392 | 1393 | 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); | |
| 1394 | 1395 | 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); | |
| 1396 | 1397 | if (sent.error === "capped") |
| 1397 | 1398 | 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); | |
| 1399 | 1400 | }); |
| 1400 | 1401 | |
| 1401 | 1402 | router.add("PATCH", "/api/v1/user", async (ctx) => { |
src/http/mcp.ts +4 −3
| @@ -55,6 +55,7 @@ import { PROVIDERS } from "../cloud/providers.ts"; | ||
| 55 | 55 | import { changeQuotas, readQuotas } from "../actions/quotas.ts"; |
| 56 | 56 | import { resendVerification } from "../actions/verify.ts"; |
| 57 | 57 | import { CAPPED_WORDS } from "../actions/mail.ts"; |
| 58 | import { VERIFY_WORDS } from "../actions/verify_words.ts"; | |
| 58 | 59 | import { SPEND_WORDS } from "../actions/spend_words.ts"; |
| 59 | 60 | import { changeSignup, readSignup } from "../actions/signup.ts"; |
| 60 | 61 | import { SIGNUP_MODES } from "../settings/signup.ts"; |
| @@ -4738,12 +4739,12 @@ const TOOLS: readonly Tool[] = [ | ||
| 4738 | 4739 | says: sent.value === "sent" ? "A new verification email is on its way." : "Your email is already verified.", |
| 4739 | 4740 | }); |
| 4740 | 4741 | 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); | |
| 4742 | 4743 | 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); | |
| 4744 | 4745 | if (sent.error === "capped") return bad("capped", CAPPED_WORDS); |
| 4745 | 4746 | 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); | |
| 4747 | 4748 | }, |
| 4748 | 4749 | }, |
| 4749 | 4750 | { |
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); | |
| 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: |
||
| 105 | }); | |
| 106 | }); |
new above
offline
Nit: the
toEqualon the next line already checksstatus, so this line can go.