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

"prove your email first" and "verify your email first" live in one module, read by every door that holds back an unverified account #5664

Merged opened this

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

Merged

mason/forge:mason/5646-prove-your-email-first-and-verify-your-e → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/5646-prove-your-email-first-and-verify-your-e was merged into main as 025f91f.

Review

Sign in to review this change.
10 files changed +70 −8
src/actions/tell.ts +2 −1
@@ -18,6 +18,7 @@ import { err, ok, type Result } from "../kernel/result.ts";
1818 import { requireParticipant } from "./access.ts";
1919 import { assignIssue, openIssue, type MirrorError } from "./issues.ts";
2020 import { requireVerified } from "./verify.ts";
21 import { VERIFY_FIRST } from "./verify_words.ts";
2122
2223 export interface Told {
2324 readonly issue: Issue;
@@ -153,7 +154,7 @@ export async function tell(
153154 }
154155
155156 /** What every door says when a tell is refused for an unverified address. */
156 export const UNVERIFIED_WORDS = "verify your email first";
157 export const UNVERIFIED_WORDS = VERIFY_FIRST;
157158
158159 /** What both doors say when `tell` refuses a text over `MAX_BODY`. */
159160 export const TOO_LONG_WORDS = "That is too long to tell in one go.";
src/actions/verify.ts +2 −1
@@ -23,9 +23,10 @@ import { sendMail, type Mailer } from "../kernel/mail.ts";
2323 import { mayMail, sendCapped } from "./mail.ts";
2424 import { err, ok, type Result } from "../kernel/result.ts";
2525 import { requireUser } from "./access.ts";
26 import { VERIFY_FIRST } from "./verify_words.ts";
2627
2728 /** The words every refusal carries, on every surface. */
28 export const UNVERIFIED_MESSAGE = "verify your email first";
29 export const UNVERIFIED_MESSAGE = VERIFY_FIRST;
2930
3031 /** An account that may create and cost: its email is proven. */
3132 export function requireVerified(
src/actions/verify_words.ts +10
@@ -1,3 +1,13 @@
1 // The words a door says about email verification. Every surface (page, API,
2 // MCP, terminal) reads them from here, so the sentence cannot drift between
3 // doors.
4
5 /** Refuses a create or a cost (an app, a tell, a token) to an unverified account. */
6 export const VERIFY_FIRST = "verify your email first";
7
8 /** Refuses making an app your own to an unverified account. */
9 export const PROVE_FIRST = "prove your email first";
10
111 /**
212 * What a person asking for another email-verification link is told when it
313 * is refused, keyed by the refusal. Every door — POST
src/contract/openapi/routes/postApiV1ReposOwnerNameMakeItYours.ts +2 −1
@@ -1,3 +1,4 @@
1 import { PROVE_FIRST } from "../../../actions/verify_words.ts";
12 import { REPO, type Route } from "../shared.ts";
23
34 export const route: Route = {
@@ -10,7 +11,7 @@ export const route: Route = {
1011 refusals: {
1112 invalid: "422: visibility is public or private",
1213 own: "409: this app is already yours",
13 unverified: "403: prove your email first",
14 unverified: `403: ${PROVE_FIRST}`,
1415 limit: "429: at most so many copies a day; { resetsAt }",
1516 repository_limit: "403: at the repository limit",
1617 storage_limit: "403: at the storage limit",
src/contract/openapi/routes/postApiV1UserOriginsIdBrowseOwnerNameContribute.ts +2 −1
@@ -1,3 +1,4 @@
1 import { PROVE_FIRST } from "../../../actions/verify_words.ts";
12 import { BROWSE, type Route } from "../shared.ts";
23
34 export const route: Route = {
@@ -10,7 +11,7 @@ export const route: Route = {
1011 body: { branch: "string", base: "string", message: "string", files: "array", openPull: "object" },
1112 refusals: {
1213 not_found: "404: no such origin of yours, or no such repository there",
13 unverified: "403: prove your email first",
14 unverified: `403: ${PROVE_FIRST}`,
1415 origin_unreachable: "422: the origin could not be reached",
1516 invalid_branch: "422: that branch is not one this forge can use",
1617 unknown_base: "422: that base branch is not one this forge can use",
src/http/api.ts +2 −1
@@ -79,6 +79,7 @@ import {
7979 verifyAccount,
8080 } from "../actions/accounts.ts";
8181 import { resendVerification } from "../actions/verify.ts";
82 import { PROVE_FIRST } from "../actions/verify_words.ts";

Nit: api.ts now imports from ../actions/verify_words.ts twice (here and VERIFY_WORDS two lines down). Fold them into one import { PROVE_FIRST, VERIFY_WORDS } from "../actions/verify_words.ts"; the next time this file is touched.

8283 import { CAPPED_WORDS } from "../actions/mail.ts";
8384 import { VERIFY_WORDS } from "../actions/verify_words.ts";
8485 import { editMessage } from "../actions/edit_words.ts";
@@ -5398,7 +5399,7 @@ export function apiRoutes(router: Router<Handler>): void {
53985399 : made.error.kind === "own"
53995400 ? apiError("own", 409, APP_ALREADY_YOURS)
54005401 : made.error.kind === "unverified"
5401 ? apiError("unverified", 403, "prove your email first")
5402 ? apiError("unverified", 403, PROVE_FIRST)
54025403 : made.error.kind === "unauthorized"
54035404 ? unauthorized()
54045405 : notFound();
src/http/mcp/tools/make_it_yours.ts +2 −1
@@ -1,6 +1,7 @@
11 import { APP_ALREADY_YOURS } from "../../../actions/origin_app_words.ts";
22 import { NOT_A_MEMBER } from "../../../core/roles.ts";
33 import { COPIES_PER_DAY, makeItYours } from "../../../actions/make_it_yours.ts";
4 import { PROVE_FIRST } from "../../../actions/verify_words.ts";
45 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
56
67 export const tool: Tool = {
@@ -46,7 +47,7 @@ export const tool: Tool = {
4647 : made.error.kind === "not_member"
4748 ? bad("not_member", NOT_A_MEMBER)
4849 : made.error.kind === "unverified"
49 ? bad("unverified", "prove your email first")
50 ? bad("unverified", PROVE_FIRST)
5051 : bad("not_found", "no such app or repository (owner/name)");
5152 const { made: done, ...plan } = made.value;
5253 return good(
src/http/mcp/tools/new_app.ts +2 −1
@@ -5,6 +5,7 @@ import { repositoryLimitMessage } from "../../../actions/repos.ts";
55 import { newApp } from "../../../actions/apps.ts";
66 import { appLimitWords } from "../../../actions/app_limit.ts";
77 import { storageLimitMessage } from "../../../core/storage.ts";
8 import { VERIFY_FIRST } from "../../../actions/verify_words.ts";
89 import { good, bad, type Tool, str } from "../shared.ts";
910
1011 export const tool: Tool = {
@@ -42,7 +43,7 @@ export const tool: Tool = {
4243 if (!made.ok) {
4344 switch (made.error) {
4445 case "unverified":
45 return bad("unverified", "verify your email first");
46 return bad("unverified", VERIFY_FIRST);
4647 case "app_limit": {
4748 const who = getUserByName(ctx.app.db, owner || user.username);
4849 return bad("app_limit", who ? appLimitWords(ctx.app, who.id) : "App limit reached.");
src/http/respond.ts +2 −1
@@ -9,6 +9,7 @@ import { scopeAllows, type TokenScope } from "../core/tokens.ts";
99 import { ARCHIVED_WHY } from "../core/repos.ts";
1010 import { NOT_A_MEMBER } from "../core/roles.ts";
1111 import { NEW_ACCOUNT_GUARD_MESSAGE } from "../core/capacity.ts";
12 import { VERIFY_FIRST } from "../actions/verify_words.ts";
1213 import type { User } from "../core/users.ts";
1314 import { crossOrigin, type Ctx } from "./context.ts";
1415
@@ -238,7 +239,7 @@ export function respondAccess(error: string, status: number): Response {
238239 // An account whose email is unproven may read, not create or cost: the
239240 // same words on every surface (\`actions/verify.ts\`).
240241 if (error === "unverified")
241 return json({ error, message: "verify your email first" }, 403);
242 return json({ error, message: VERIFY_FIRST }, 403);
242243 // The new-account guard (\`guard_new_accounts\`): refused the way the
243244 // hand-installed database trigger used to refuse, in its own words, so
244245 // clients see no difference between the trigger and the setting.
test/verify-gate-words.test.ts added +44
@@ -0,0 +1,44 @@
1 /**
2 * An unverified account held back reads the same from every door (page,
3 * API, MCP, terminal) because the sentence is said from one place:
4 * src/actions/verify_words.ts. Each door imports it; a copy typed back into
5 * a door's source fails here.
6 */
7
8 import { describe, expect, test } from "bun:test";
9 import { readFileSync } from "node:fs";
10 import { join } from "node:path";
11 import { PROVE_FIRST, VERIFY_FIRST } from "../src/actions/verify_words.ts";
12 import { UNVERIFIED_WORDS } from "../src/actions/tell.ts";
13 import { UNVERIFIED_MESSAGE } from "../src/actions/verify.ts";
14
15 const ROOT = join(import.meta.dir, "..");
16 const DOORS = [
17 "src/http/api.ts",
18 "src/http/mcp/tools/new_app.ts",
19 "src/http/mcp/tools/make_it_yours.ts",
20 "src/http/respond.ts",
21 "src/actions/tell.ts",
22 "src/actions/verify.ts",
23 "src/contract/openapi/routes/postApiV1ReposOwnerNameMakeItYours.ts",
24 "src/contract/openapi/routes/postApiV1UserOriginsIdBrowseOwnerNameContribute.ts",
25 ];
26
27 describe("the words that hold back an unverified account", () => {
28 test("the module says each sentence, and the old names read it", () => {
29 expect(VERIFY_FIRST).toBe("verify your email first");
30 expect(PROVE_FIRST).toBe("prove your email first");
31 expect(UNVERIFIED_MESSAGE).toBe(VERIFY_FIRST);
32 expect(UNVERIFIED_WORDS).toBe(VERIFY_FIRST);
33 });
34
35 for (const file of DOORS) {
36 test(`${file} imports the sentences rather than typing them`, () => {
37 const text = readFileSync(join(ROOT, file), "utf8");
38 expect(text).toContain("verify_words.ts");
39 for (const sentence of [VERIFY_FIRST, PROVE_FIRST]) {
40 expect(text.includes(sentence)).toBe(false);
41 }
42 });
43 }
44 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs