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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5646-prove-your-email-first-and-verify-your-e → main 3 commits fork 1 thread · 1 unresolved
10 files
changed +70
−8
src/actions/tell.ts +2 −1
| @@ -18,6 +18,7 @@ import { err, ok, type Result } from "../kernel/result.ts"; | ||
| 18 | 18 | import { requireParticipant } from "./access.ts"; |
| 19 | 19 | import { assignIssue, openIssue, type MirrorError } from "./issues.ts"; |
| 20 | 20 | import { requireVerified } from "./verify.ts"; |
| 21 | import { VERIFY_FIRST } from "./verify_words.ts"; | |
| 21 | 22 | |
| 22 | 23 | export interface Told { |
| 23 | 24 | readonly issue: Issue; |
| @@ -153,7 +154,7 @@ export async function tell( | ||
| 153 | 154 | } |
| 154 | 155 | |
| 155 | 156 | /** 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; | |
| 157 | 158 | |
| 158 | 159 | /** What both doors say when `tell` refuses a text over `MAX_BODY`. */ |
| 159 | 160 | 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"; | ||
| 23 | 23 | import { mayMail, sendCapped } from "./mail.ts"; |
| 24 | 24 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 25 | 25 | import { requireUser } from "./access.ts"; |
| 26 | import { VERIFY_FIRST } from "./verify_words.ts"; | |
| 26 | 27 | |
| 27 | 28 | /** The words every refusal carries, on every surface. */ |
| 28 | export const UNVERIFIED_MESSAGE = "verify your email first"; | |
| 29 | export const UNVERIFIED_MESSAGE = VERIFY_FIRST; | |
| 29 | 30 | |
| 30 | 31 | /** An account that may create and cost: its email is proven. */ |
| 31 | 32 | 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 | ||
| 1 | 11 | /** |
| 2 | 12 | * What a person asking for another email-verification link is told when it |
| 3 | 13 | * 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"; | |
| 1 | 2 | import { REPO, type Route } from "../shared.ts"; |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| @@ -10,7 +11,7 @@ export const route: Route = { | ||
| 10 | 11 | refusals: { |
| 11 | 12 | invalid: "422: visibility is public or private", |
| 12 | 13 | own: "409: this app is already yours", |
| 13 | unverified: "403: prove your email first", | |
| 14 | unverified: `403: ${PROVE_FIRST}`, | |
| 14 | 15 | limit: "429: at most so many copies a day; { resetsAt }", |
| 15 | 16 | repository_limit: "403: at the repository limit", |
| 16 | 17 | 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"; | |
| 1 | 2 | import { BROWSE, type Route } from "../shared.ts"; |
| 2 | 3 | |
| 3 | 4 | export const route: Route = { |
| @@ -10,7 +11,7 @@ export const route: Route = { | ||
| 10 | 11 | body: { branch: "string", base: "string", message: "string", files: "array", openPull: "object" }, |
| 11 | 12 | refusals: { |
| 12 | 13 | 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}`, | |
| 14 | 15 | origin_unreachable: "422: the origin could not be reached", |
| 15 | 16 | invalid_branch: "422: that branch is not one this forge can use", |
| 16 | 17 | unknown_base: "422: that base branch is not one this forge can use", |
src/http/api.ts +2 −1
| @@ -79,6 +79,7 @@ import { | ||
| 79 | 79 | verifyAccount, |
| 80 | 80 | } from "../actions/accounts.ts"; |
| 81 | 81 | import { resendVerification } from "../actions/verify.ts"; |
| 82 | import { PROVE_FIRST } from "../actions/verify_words.ts"; | |
| 82 | 83 | import { CAPPED_WORDS } from "../actions/mail.ts"; |
| 83 | 84 | import { VERIFY_WORDS } from "../actions/verify_words.ts"; |
| 84 | 85 | import { editMessage } from "../actions/edit_words.ts"; |
| @@ -5398,7 +5399,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5398 | 5399 | : made.error.kind === "own" |
| 5399 | 5400 | ? apiError("own", 409, APP_ALREADY_YOURS) |
| 5400 | 5401 | : made.error.kind === "unverified" |
| 5401 | ? apiError("unverified", 403, "prove your email first") | |
| 5402 | ? apiError("unverified", 403, PROVE_FIRST) | |
| 5402 | 5403 | : made.error.kind === "unauthorized" |
| 5403 | 5404 | ? unauthorized() |
| 5404 | 5405 | : notFound(); |
src/http/mcp/tools/make_it_yours.ts +2 −1
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { APP_ALREADY_YOURS } from "../../../actions/origin_app_words.ts"; |
| 2 | 2 | import { NOT_A_MEMBER } from "../../../core/roles.ts"; |
| 3 | 3 | import { COPIES_PER_DAY, makeItYours } from "../../../actions/make_it_yours.ts"; |
| 4 | import { PROVE_FIRST } from "../../../actions/verify_words.ts"; | |
| 4 | 5 | import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 5 | 6 | |
| 6 | 7 | export const tool: Tool = { |
| @@ -46,7 +47,7 @@ export const tool: Tool = { | ||
| 46 | 47 | : made.error.kind === "not_member" |
| 47 | 48 | ? bad("not_member", NOT_A_MEMBER) |
| 48 | 49 | : made.error.kind === "unverified" |
| 49 | ? bad("unverified", "prove your email first") | |
| 50 | ? bad("unverified", PROVE_FIRST) | |
| 50 | 51 | : bad("not_found", "no such app or repository (owner/name)"); |
| 51 | 52 | const { made: done, ...plan } = made.value; |
| 52 | 53 | return good( |
src/http/mcp/tools/new_app.ts +2 −1
| @@ -5,6 +5,7 @@ import { repositoryLimitMessage } from "../../../actions/repos.ts"; | ||
| 5 | 5 | import { newApp } from "../../../actions/apps.ts"; |
| 6 | 6 | import { appLimitWords } from "../../../actions/app_limit.ts"; |
| 7 | 7 | import { storageLimitMessage } from "../../../core/storage.ts"; |
| 8 | import { VERIFY_FIRST } from "../../../actions/verify_words.ts"; | |
| 8 | 9 | import { good, bad, type Tool, str } from "../shared.ts"; |
| 9 | 10 | |
| 10 | 11 | export const tool: Tool = { |
| @@ -42,7 +43,7 @@ export const tool: Tool = { | ||
| 42 | 43 | if (!made.ok) { |
| 43 | 44 | switch (made.error) { |
| 44 | 45 | case "unverified": |
| 45 | return bad("unverified", "verify your email first"); | |
| 46 | return bad("unverified", VERIFY_FIRST); | |
| 46 | 47 | case "app_limit": { |
| 47 | 48 | const who = getUserByName(ctx.app.db, owner || user.username); |
| 48 | 49 | 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"; | ||
| 9 | 9 | import { ARCHIVED_WHY } from "../core/repos.ts"; |
| 10 | 10 | import { NOT_A_MEMBER } from "../core/roles.ts"; |
| 11 | 11 | import { NEW_ACCOUNT_GUARD_MESSAGE } from "../core/capacity.ts"; |
| 12 | import { VERIFY_FIRST } from "../actions/verify_words.ts"; | |
| 12 | 13 | import type { User } from "../core/users.ts"; |
| 13 | 14 | import { crossOrigin, type Ctx } from "./context.ts"; |
| 14 | 15 | |
| @@ -238,7 +239,7 @@ export function respondAccess(error: string, status: number): Response { | ||
| 238 | 239 | // An account whose email is unproven may read, not create or cost: the |
| 239 | 240 | // same words on every surface (\`actions/verify.ts\`). |
| 240 | 241 | if (error === "unverified") |
| 241 | return json({ error, message: "verify your email first" }, 403); | |
| 242 | return json({ error, message: VERIFY_FIRST }, 403); | |
| 242 | 243 | // The new-account guard (\`guard_new_accounts\`): refused the way the |
| 243 | 244 | // hand-installed database trigger used to refuse, in its own words, so |
| 244 | 245 | // 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 | }); |
new above
offline
Nit:
api.tsnow imports from../actions/verify_words.tstwice (here andVERIFY_WORDStwo lines down). Fold them into oneimport { PROVE_FIRST, VERIFY_WORDS } from "../actions/verify_words.ts";the next time this file is touched.