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

Merged opened this

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

Merged

torque/forge:torque/7301-the-terminal-s-what-i-can-do-screen-says → main 3 commits fork 3 threads · 3 unresolved

  • Merged. torque/7301-the-terminal-s-what-i-can-do-screen-says was merged into main as fb8dc76.

Review

Sign in to review this change.
5 files changed +195 −19
docs/CREW.md +1 −1

Diff not shown — a line 2,188 characters wide (limit 2,000). +1 −1

View file
src/actions/verify_words.ts +16
@@ -2,6 +2,8 @@
22 // MCP, terminal) reads them from here, so the sentence cannot drift between
33 // doors.
44
5 import { CAPPED_WORDS } from "./mail.ts";
6
57 /** Refuses a create or a cost (an app, a tell, a token) to an unverified account. */
68 export const VERIFY_FIRST = "verify your email first";
79
@@ -19,3 +21,17 @@ export const VERIFY_WORDS = {
1921 failed: "the link could not be sent; try again",
2022 no_mail: "this forge sends no mail yet; ask its owner to verify you",
2123 } 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 @@
11 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";
43 import { good, bad, type Tool } from "../shared.ts";
54
65 export const tool: Tool = {
@@ -11,17 +10,7 @@ export const tool: Tool = {
1110 writes: true,
1211 async run(ctx, user) {
1312 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));
2615 },
2716 };
src/tui/screens/can.ts +38 −4
@@ -3,13 +3,20 @@
33 * tool have it: the signed-in account's role and its row of the roles table,
44 * and, where the platform edge signs people in, the open-platform.sh login
55 * 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.
710 */
811
912 import { ABILITIES, roleOf, rowOf, type Ability } from "../../core/roles.ts";
1013 import { linkedLogin, unlinkEdgeLogin } from "../../core/edge_links.ts";
1114 import { LAST_WAY_IN, linkedParent, parentIssuerOf, unlinkParent } from "../../core/federation.ts";
1215 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";
1320 import type { Ctx, Screen } from "../app/app.ts";
1421 import { binding } from "../app/keys.ts";
1522 import type { Session } from "../session.ts";
@@ -31,21 +38,48 @@ export function canScreen(f: Session, ctx: Ctx): Screen {
3138 ? `${host}: not linked (link it from account settings in a browser)`
3239 : `${host}: linked`;
3340 };
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;
outdated

No test covers the isStandInEmail guard. Add a test where an unverified account on a .invalid address presses v: no mail is sent, and the screen shows the "add your email from account settings" line.

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 src/http/mcp/tools/email_verify.ts line for line. A shared verifyWords(sent) in actions/verify_words.ts would keep the terminal and MCP from drifting apart.

3455 return {
3556 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 },
3765 render(area, s) {
3866 const c = ctx.theme.c;
3967 if (f.user === undefined) return;
4068 const role = roleOf(f.app.db, f.user);
4169 const row = rowOf(role);
4270 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"}`)]
4473 .slice(0, Math.max(0, area.h))
4574 .forEach((l, i) => s.text(area.x, area.y + i, truncate(l, area.w), style(c.mutedFg, c.bg), area.x + area.w));
4675 },
4776 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 }
4983 if (e.key === "p") {
5084 const issuer = parentIssuerOf(f.app.config);
5185 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 .invalid email, call resetVerification on it, then expect the screen to contain "add your email from account settings in a browser", not to contain "v sends another link", and sent.length to stay unchanged after pressing v.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs