Public: anyone can read this; the crew can change it.
The terminal's "What I can do" screen says when your email is unverified, and `v` sends a new verification email, as MCP `email_verify` does #7314
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7301-the-terminal-s-what-i-can-do-screen-says → main 3 commits fork 3 threads · 3 unresolved
docs/CREW.md +1 −1
Diff not shown — a line 2,188 characters wide (limit 2,000). +1 −1
View filesrc/actions/verify_words.ts +16
| @@ -2,6 +2,8 @@ | ||
| 2 | 2 | // MCP, terminal) reads them from here, so the sentence cannot drift between |
| 3 | 3 | // doors. |
| 4 | 4 | |
| 5 | import { CAPPED_WORDS } from "./mail.ts"; | |
| 6 | ||
| 5 | 7 | /** Refuses a create or a cost (an app, a tell, a token) to an unverified account. */ |
| 6 | 8 | export const VERIFY_FIRST = "verify your email first"; |
| 7 | 9 | |
| @@ -19,3 +21,17 @@ export const VERIFY_WORDS = { | ||
| 19 | 21 | failed: "the link could not be sent; try again", |
| 20 | 22 | no_mail: "this forge sends no mail yet; ask its owner to verify you", |
| 21 | 23 | } as const; |
| 24 | ||
| 25 | /** | |
| 26 | * The sentence for each outcome of `resendVerification`, so the terminal and | |
| 27 | * MCP `email_verify` say the same thing for the same answer. | |
| 28 | */ | |
| 29 | export function verifyWords( | |
| 30 | outcome: "sent" | "already" | "unauthorized" | "too_soon" | "no_mail" | "capped" | "failed", | |
| 31 | ): string { | |
| 32 | if (outcome === "sent") return "A new verification email is on its way."; | |
| 33 | if (outcome === "already") return "Your email is already verified."; | |
| 34 | if (outcome === "capped") return CAPPED_WORDS; | |
| 35 | if (outcome === "unauthorized") return "sign in first"; | |
| 36 | return VERIFY_WORDS[outcome]; | |
| 37 | } |
src/http/mcp/tools/email_verify.ts +3 −14
| @@ -1,6 +1,5 @@ | ||
| 1 | 1 | import { resendVerification } from "../../../actions/verify.ts"; |
| 2 | import { CAPPED_WORDS } from "../../../actions/mail.ts"; | |
| 3 | import { VERIFY_WORDS } from "../../../actions/verify_words.ts"; | |
| 2 | import { verifyWords } from "../../../actions/verify_words.ts"; | |
| 4 | 3 | import { good, bad, type Tool } from "../shared.ts"; |
| 5 | 4 | |
| 6 | 5 | export const tool: Tool = { |
| @@ -11,17 +10,7 @@ export const tool: Tool = { | ||
| 11 | 10 | writes: true, |
| 12 | 11 | async run(ctx, user) { |
| 13 | 12 | const sent = await resendVerification(ctx.app, user); |
| 14 | if (sent.ok) | |
| 15 | return good({ | |
| 16 | outcome: sent.value, | |
| 17 | says: sent.value === "sent" ? "A new verification email is on its way." : "Your email is already verified.", | |
| 18 | }); | |
| 19 | if (sent.error === "too_soon") | |
| 20 | return bad("too_soon", VERIFY_WORDS.too_soon); | |
| 21 | if (sent.error === "no_mail") | |
| 22 | return bad("no_mail", VERIFY_WORDS.no_mail); | |
| 23 | if (sent.error === "capped") return bad("capped", CAPPED_WORDS); | |
| 24 | if (sent.error === "unauthorized") return bad("unauthorized", "sign in first"); | |
| 25 | return bad("failed", VERIFY_WORDS.failed); | |
| 13 | if (sent.ok) return good({ outcome: sent.value, says: verifyWords(sent.value) }); | |
| 14 | return bad(sent.error, verifyWords(sent.error)); | |
| 26 | 15 | }, |
| 27 | 16 | }; |
src/tui/screens/can.ts +38 −4
| @@ -3,13 +3,20 @@ | ||
| 3 | 3 | * tool have it: the signed-in account's role and its row of the roles table, |
| 4 | 4 | * and, where the platform edge signs people in, the open-platform.sh login |
| 5 | 5 | * linked to the account; `u` unlinks it (SSH carries no edge header, so |
| 6 | * linking is the web page's alone). | |
| 6 | * linking is the web page's alone). An unverified email says so in one line | |
| 7 | * and what it holds back; `v` asks `resendVerification` for another link, as | |
| 8 | * the account page and MCP `email_verify` do, and says the outcome in the | |
| 9 | * API's words — never the link. | |
| 7 | 10 | */ |
| 8 | 11 | |
| 9 | 12 | import { ABILITIES, roleOf, rowOf, type Ability } from "../../core/roles.ts"; |
| 10 | 13 | import { linkedLogin, unlinkEdgeLogin } from "../../core/edge_links.ts"; |
| 11 | 14 | import { LAST_WAY_IN, linkedParent, parentIssuerOf, unlinkParent } from "../../core/federation.ts"; |
| 12 | 15 | import { activeEdgeToken } from "../../http/edge.ts"; |
| 16 | import { resendVerification } from "../../actions/verify.ts"; | |
| 17 | import { verifyWords } from "../../actions/verify_words.ts"; | |
| 18 | import { emailVerified } from "../../core/verify.ts"; | |
| 19 | import { isStandInEmail } from "../../core/users.ts"; | |
| 13 | 20 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 21 | import { binding } from "../app/keys.ts"; |
| 15 | 22 | import type { Session } from "../session.ts"; |
| @@ -31,21 +38,48 @@ export function canScreen(f: Session, ctx: Ctx): Screen { | ||
| 31 | 38 | ? `${host}: not linked (link it from account settings in a browser)` |
| 32 | 39 | : `${host}: linked`; |
| 33 | 40 | }; |
| 41 | // Agents are always verified (`emailVerified`), so they are never offered `v`. | |
| 42 | const unverified = (): boolean => | |
| 43 | f.user !== undefined && f.user.kind !== "agent" && !emailVerified(f.app.db, f.user.id); | |
| 44 | const mayResend = (): boolean => unverified() && !isStandInEmail(f.user!.email); | |
| 45 | let verifySaid: string | undefined; | |
| 46 | const verifyLine = (): string | undefined => { | |
| 47 | if (!unverified()) return undefined; | |
| 48 | const fix = mayResend() ? "v sends another link" : "add your email from account settings in a browser"; | |
| 49 | return `Your email is not verified: until it is you can read and talk, but not make repositories, apps or runs. ${fix}.`; | |
| 50 | }; | |
| 51 | const resend = async (): Promise<void> => { | |
| 52 | const sent = await resendVerification(f.app, f.user); | |
| 53 | verifySaid = verifyWords(sent.ok ? sent.value : sent.error); | |
| 54 | }; | |
outdated
Not blocking: this repeats |
||
| 34 | 55 | return { |
| 35 | 56 | title: "What I can do", |
| 36 | keys: [binding(["u"], "unlink open-platform.sh"), binding(["p"], "unlink parent forge"), binding(["esc"], "back")], | |
| 57 | get keys() { | |
| 58 | return [ | |
| 59 | ...(mayResend() ? [binding(["v"], "send another verification email")] : []), | |
| 60 | binding(["u"], "unlink open-platform.sh"), | |
| 61 | binding(["p"], "unlink parent forge"), | |
| 62 | binding(["esc"], "back"), | |
| 63 | ]; | |
| 64 | }, | |
| 37 | 65 | render(area, s) { |
| 38 | 66 | const c = ctx.theme.c; |
| 39 | 67 | if (f.user === undefined) return; |
| 40 | 68 | const role = roleOf(f.app.db, f.user); |
| 41 | 69 | const row = rowOf(role); |
| 42 | 70 | const login = edgeLogin(); |
| 43 | [`You are ${role} here.`, ...(login === undefined ? [] : [login]), ...(parentLine() === undefined ? [] : [parentLine()!]), ...(refused === undefined ? [] : [refused]), ...(Object.keys(ABILITIES) as Ability[]).map((a) => `${ABILITIES[a]}: ${row[a] ? "yes" : "no"}`)] | |
| 71 | const verify = verifyLine(); | |
| 72 | [`You are ${role} here.`, ...(verify === undefined ? [] : [verify]), ...(verifySaid === undefined ? [] : [verifySaid]), ...(login === undefined ? [] : [login]), ...(parentLine() === undefined ? [] : [parentLine()!]), ...(refused === undefined ? [] : [refused]), ...(Object.keys(ABILITIES) as Ability[]).map((a) => `${ABILITIES[a]}: ${row[a] ? "yes" : "no"}`)] | |
| 44 | 73 | .slice(0, Math.max(0, area.h)) |
| 45 | 74 | .forEach((l, i) => s.text(area.x, area.y + i, truncate(l, area.w), style(c.mutedFg, c.bg), area.x + area.w)); |
| 46 | 75 | }, |
| 47 | 76 | key(e) { |
| 48 | if (e.ctrl || e.alt || (e.key !== "u" && e.key !== "p") || f.user === undefined) return false; | |
| 77 | if (e.ctrl || e.alt || (e.key !== "u" && e.key !== "p" && e.key !== "v") || f.user === undefined) return false; | |
| 78 | if (e.key === "v") { | |
| 79 | if (!mayResend()) return false; | |
| 80 | void ctx.track(resend()); | |
| 81 | return true; | |
| 82 | } | |
| 49 | 83 | if (e.key === "p") { |
| 50 | 84 | const issuer = parentIssuerOf(f.app.config); |
| 51 | 85 | const done = issuer === undefined ? undefined : unlinkParent(f.app.db, f.user.id, issuer); |
test/tui-verify.test.ts added +137
| @@ -0,0 +1,137 @@ | ||
| 1 | /** | |
| 2 | * "What I can do" in the terminal says when the email is unverified, and `v` | |
| 3 | * sends another link through `resendVerification`, as MCP `email_verify` | |
| 4 | * does: the same limit, the API's words, never the link (#7301). | |
| 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 as Forge } from "../src/app.ts"; | |
| 11 | import { VERIFY_WORDS } from "../src/actions/verify_words.ts"; | |
| 12 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 13 | import { markVerified, resetVerification } from "../src/core/verify.ts"; | |
| 14 | import type { Mail } from "../src/kernel/mail.ts"; | |
| 15 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { settleApp } from "./helpers/tui.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | const LINE = "Your email is not verified"; | |
| 24 | ||
| 25 | describe("the terminal's unverified-email line and v", () => { | |
| 26 | let dataDir: string; | |
| 27 | let forge: Forge; | |
| 28 | const sent: Mail[] = []; | |
| 29 | ||
| 30 | async function shown(username: string, presses: string[] = []): Promise<string> { | |
| 31 | const terminal = headlessTerminal(240, 32); | |
| 32 | const user = getUserByName(forge.db, username)!; | |
| 33 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 34 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 35 | app.start(); | |
| 36 | const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 37 | app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 38 | for (const ch of "What I can do") key(ch); | |
| 39 | key("enter"); | |
| 40 | await settleApp(app); | |
| 41 | for (const k of presses) { | |
| 42 | key(k); | |
| 43 | await settleApp(app); | |
| 44 | } | |
| 45 | const text = app.screen.toText(); | |
| 46 | app.stop(); | |
| 47 | return text; | |
| 48 | } | |
| 49 | ||
| 50 | beforeAll(async () => { | |
| 51 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-verify-")); | |
| 52 | forge = createApp({ | |
| 53 | dataDir, | |
| 54 | port: 0, | |
| 55 | url: "https://forge.example", | |
| 56 | mailer: async (mail) => { | |
| 57 | sent.push(mail); | |
| 58 | return true; | |
| 59 | }, | |
| 60 | }); | |
| 61 | for (const [username, adminIfFirst] of [["alice", true], ["newt", false], ["robo", false]] as const) { | |
| 62 | const made = await createUser(forge.db, { | |
| 63 | origin: "seed", | |
| 64 | trusted: true, | |
| 65 | username, | |
| 66 | email: `${username}@forge.local`, | |
| 67 | password: "a-good-passphrase", | |
| 68 | adminIfFirst, | |
| 69 | }); | |
| 70 | if (!made.ok) throw new Error(made.error); | |
| 71 | } | |
| 72 | resetVerification(forge.db, getUserByName(forge.db, "newt")!.id); | |
| 73 | const stand = await createUser(forge.db, { | |
| 74 | origin: "seed", | |
| 75 | trusted: true, | |
| 76 | username: "nomail", | |
| 77 | email: "[email protected]", | |
| 78 | password: "a-good-passphrase", | |
| 79 | }); | |
| 80 | if (!stand.ok) throw new Error(stand.error); | |
| 81 | resetVerification(forge.db, stand.value.id); | |
| 82 | const bot = getUserByName(forge.db, "robo")!; | |
| 83 | forge.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(bot.id); | |
| 84 | resetVerification(forge.db, bot.id); | |
| 85 | }); | |
| 86 | afterAll(async () => { | |
| 87 | forge.close(); | |
| 88 | await rm(dataDir, { recursive: true, force: true }); | |
| 89 | }); | |
| 90 | ||
| 91 | test("unverified shows the line; v sends one mail; a second within the limit is refused in words", async () => { | |
| 92 | const before = await shown("newt"); | |
| 93 | expect(before).toContain(LINE); | |
| 94 | expect(before).toContain("v sends another link"); | |
| 95 | expect(sent.length).toBe(0); | |
| 96 | ||
| 97 | const first = await shown("newt", ["v"]); | |
| 98 | expect(sent.length).toBe(1); | |
| 99 | expect(sent[0]!.to).toBe("[email protected]"); | |
| 100 | expect(first).toContain("A new verification email is on its way."); | |
| 101 | expect(first).not.toContain("token="); | |
| 102 | ||
| 103 | const second = await shown("newt", ["v"]); | |
| 104 | expect(sent.length).toBe(1); | |
| 105 | expect(second).toContain(VERIFY_WORDS.too_soon); | |
| 106 | expect(second).not.toContain("token="); | |
| 107 | }); | |
| 108 | ||
| 109 | test("verified shows nothing, and v sends nothing", async () => { | |
| 110 | markVerified(forge.db, getUserByName(forge.db, "newt")!.id, new Date().toISOString()); | |
outdated
Add the placeholder case next to this one: create an account with a |
||
| 111 | const before = sent.length; | |
| 112 | const screen = await shown("newt", ["v"]); | |
| 113 | expect(screen).not.toContain(LINE); | |
| 114 | expect(screen).not.toContain("verification email"); | |
| 115 | expect(sent.length).toBe(before); | |
| 116 | expect(await shown("alice")).not.toContain(LINE); | |
| 117 | }); | |
| 118 | ||
| 119 | test("an agent is never offered v", async () => { | |
| 120 | const before = sent.length; | |
| 121 | const screen = await shown("robo", ["v"]); | |
| 122 | expect(screen).not.toContain(LINE); | |
| 123 | expect(screen).not.toContain("verification email"); | |
| 124 | expect(sent.length).toBe(before); | |
| 125 | }); | |
| 126 | ||
| 127 | test("a placeholder address is told to add an email in a browser, and v sends nothing", async () => { | |
| 128 | const before = sent.length; | |
| 129 | const screen = await shown("nomail", ["v"]); | |
| 130 | expect(screen).toContain(LINE); | |
| 131 | expect(screen).toContain("add your email from account settings in a browser"); | |
| 132 | expect(screen).not.toContain("v sends another link"); | |
| 133 | expect(screen).not.toContain("send another verification email"); | |
| 134 | expect(screen).not.toContain("verification email is on its way"); | |
| 135 | expect(sent.length).toBe(before); | |
| 136 | }); | |
| 137 | }); |
No test covers the
isStandInEmailguard. Add a test where an unverified account on a.invalidaddress pressesv: no mail is sent, and the screen shows the "add your email from account settings" line.