Public: anyone can read this; the crew can change it.
Taken and reserved names, and an empty email, are said from one module by every way an account or org is made #5715
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5653-taken-and-reserved-names-and-an-empty-em → main 3 commits fork 1 thread · 1 unresolved
9 files
changed +72
−25
src/actions/first_admin.ts +4 −4
| @@ -30,7 +30,7 @@ import { bucketOf } from "../http/peer.ts"; | ||
| 30 | 30 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 31 | 31 | import { limitValue } from "../settings/limits.ts"; |
| 32 | 32 | import { handOver, spawnedOrg } from "../spawn/claim.ts"; |
| 33 | import { EMAIL_TAKEN_MESSAGE, INVALID_USERNAME_MESSAGE } from "./signup_words.ts"; | |
| 33 | import { EMAIL_TAKEN_MESSAGE, EMAIL_WANTED, INVALID_USERNAME_MESSAGE, NAME_RESERVED, USERNAME_TAKEN } from "./signup_words.ts"; | |
| 34 | 34 | |
| 35 | 35 | const USERNAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$/; |
| 36 | 36 | |
| @@ -50,10 +50,10 @@ export const FIRST_ADMIN_WORDS: Record<FirstAdminError, string> = { | ||
| 50 | 50 | refused: |
| 51 | 51 | "That first-admin code does not work: it may be mistyped, used, expired or replaced, or this forge already has an admin. Ask the operator to run --first-admin again.", |
| 52 | 52 | invalid_username: INVALID_USERNAME_MESSAGE, |
| 53 | invalid_email: "An email address, please.", | |
| 54 | username_taken: "That username is taken.", | |
| 53 | invalid_email: EMAIL_WANTED, | |
| 54 | username_taken: USERNAME_TAKEN, | |
| 55 | 55 | email_taken: EMAIL_TAKEN_MESSAGE, |
| 56 | username_reserved: "That name is reserved.", | |
| 56 | username_reserved: NAME_RESERVED, | |
| 57 | 57 | unfinished: "The account could not be finished. Try again.", |
| 58 | 58 | }; |
| 59 | 59 |
src/actions/signup_words.ts +8 −4
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | * The sentences every way of making an account says when a username is | |
| 3 | * malformed or an email is already held: the sign-up form, the passkey | |
| 4 | * sign-up, a claim, an everyday invite and the first-admin door. One place, | |
| 5 | * so they read the same wherever they are said. | |
| 2 | * The sentences a person reads when the name or email they chose for a new | |
| 3 | * account or org will not do. Every door that makes one (the sign-up and | |
| 4 | * claim pages, an everyday invite, the first admin, the crew and org forms, | |
| 5 | * passkey sign-up) says them from here, so they read the same everywhere. | |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | /** A username outside the safe-name alphabet. */ |
| @@ -10,3 +10,7 @@ export const INVALID_USERNAME_MESSAGE = "Usernames are letters, digits, dots and | ||
| 10 | 10 | |
| 11 | 11 | /** An email that already belongs to an account. */ |
| 12 | 12 | export const EMAIL_TAKEN_MESSAGE = "That email already has an account."; |
| 13 | ||
| 14 | export const USERNAME_TAKEN = "That username is taken."; | |
| 15 | export const NAME_RESERVED = "That name is reserved."; | |
| 16 | export const EMAIL_WANTED = "An email address, please."; |
src/web/pages/claim.ts +4 −4
| @@ -58,7 +58,7 @@ import { passkeyJs } from "../passkey-client.ts"; | ||
| 58 | 58 | import { passkeyNote, passkeyState } from "../passkey-state.ts"; |
| 59 | 59 | import { btn, card, field as formField } from "../ui.ts"; |
| 60 | 60 | import { sessionCookie, signinAllowed } from "./site.ts"; |
| 61 | import { EMAIL_TAKEN_MESSAGE } from "../../actions/signup_words.ts"; | |
| 61 | import { EMAIL_TAKEN_MESSAGE, NAME_RESERVED, USERNAME_TAKEN } from "../../actions/signup_words.ts"; | |
| 62 | 62 | |
| 63 | 63 | const USERNAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$/; |
| 64 | 64 | |
| @@ -257,7 +257,7 @@ function problems(ctx: Ctx, username: string, email: string, needEmail: boolean) | ||
| 257 | 257 | if (!USERNAME.test(username)) |
| 258 | 258 | errors.username = "Usernames are letters, digits, dots, dashes."; |
| 259 | 259 | else if (getUserByName(ctx.app.db, username)) |
| 260 | errors.username = "That username is taken."; | |
| 260 | errors.username = USERNAME_TAKEN; | |
| 261 | 261 | if (email !== "" ? !givenEmail(email) : needEmail) |
| 262 | 262 | errors.email = "That does not look like an email address."; |
| 263 | 263 | return errors; |
| @@ -348,8 +348,8 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 348 | 348 | made.error === "email_taken" |
| 349 | 349 | ? { email: EMAIL_TAKEN_MESSAGE } |
| 350 | 350 | : made.error === "username_reserved" |
| 351 | ? { username: "That name is reserved." } | |
| 352 | : { username: "That username is taken." }, | |
| 351 | ? { username: NAME_RESERVED } | |
| 352 | : { username: USERNAME_TAKEN }, | |
| 353 | 353 | ); |
| 354 | 354 | } |
| 355 | 355 | if (credential !== undefined && !addPasskey(ctx.app.db, made.value.id, "Passkey", credential).ok) { |
src/web/pages/crew.ts +3 −2
| @@ -61,6 +61,7 @@ import { | ||
| 61 | 61 | import { accountSections } from "./origins.ts"; |
| 62 | 62 | import { markOf } from "../../core/marks.ts"; |
| 63 | 63 | import { limitValue } from "../../settings/limits.ts"; |
| 64 | import { USERNAME_TAKEN } from "../../actions/signup_words.ts"; | |
| 64 | 65 | |
| 65 | 66 | /** The admin, or the response that turns everyone else away. */ |
| 66 | 67 | function requireAdmin(ctx: Ctx): Response | undefined { |
| @@ -146,8 +147,8 @@ interface AgentValues { | ||
| 146 | 147 | |
| 147 | 148 | const agentErrors = (ctx: Ctx): Record<string, string> => ({ |
| 148 | 149 | invalid_username: "Usernames are letters, digits, dots, dashes.", |
| 149 | username_taken: "That username is taken.", | |
| 150 | email_taken: "That username is taken.", | |
| 150 | username_taken: USERNAME_TAKEN, | |
| 151 | email_taken: USERNAME_TAKEN, | |
| 151 | 152 | invalid_model: `A model is up to ${MODEL_MAX} characters with no spaces.`, |
| 152 | 153 | brief_too_long: `A brief is at most ${BRIEF_MAX} characters.`, |
| 153 | 154 | invalid_budget: `A budget is a number of dollars between 0 and ${limitValue(ctx.app.db, "agent.budget_max_usd")}.`, |
src/web/pages/everyday.ts +4 −4
| @@ -33,7 +33,7 @@ import { passkeyNote } from "../passkey-state.ts"; | ||
| 33 | 33 | import { btn, card, field as formField } from "../ui.ts"; |
| 34 | 34 | import { signOutHere } from "../../actions/signins.ts"; |
| 35 | 35 | import { sessionCookie } from "./site.ts"; |
| 36 | import { EMAIL_TAKEN_MESSAGE } from "../../actions/signup_words.ts"; | |
| 36 | import { EMAIL_TAKEN_MESSAGE, EMAIL_WANTED, USERNAME_TAKEN } from "../../actions/signup_words.ts"; | |
| 37 | 37 | |
| 38 | 38 | const USERNAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$/; |
| 39 | 39 | |
| @@ -133,7 +133,7 @@ function everydayFields(ctx: Ctx, user: User, offerKeep: boolean): Html { | ||
| 133 | 133 | /** Why `username` cannot be the everyday account's name, or undefined. */ |
| 134 | 134 | function nameProblem(ctx: Ctx, username: string): string | undefined { |
| 135 | 135 | if (!USERNAME.test(username)) return "Usernames are letters, digits, dots, dashes."; |
| 136 | if (getUserByName(ctx.app.db, username)) return "That username is taken."; | |
| 136 | if (getUserByName(ctx.app.db, username)) return USERNAME_TAKEN; | |
| 137 | 137 | return undefined; |
| 138 | 138 | } |
| 139 | 139 | |
| @@ -152,7 +152,7 @@ export async function everydayOptions(ctx: Ctx): Promise<Response> { | ||
| 152 | 152 | const data = await boundedBody(ctx.request, STRANGER_MAX); |
| 153 | 153 | if (data === "too_large") return refuse(413, "payload too large"); |
| 154 | 154 | const username = str(data.username).trim(); |
| 155 | const bad = nameProblem(ctx, username) ?? (str(data.email).includes("@") ? undefined : "An email address, please."); | |
| 155 | const bad = nameProblem(ctx, username) ?? (str(data.email).includes("@") ? undefined : EMAIL_WANTED); | |
| 156 | 156 | if (bad !== undefined) return refuse(422, bad); |
| 157 | 157 | return json(makeOptions(ctx, "everyday", username)); |
| 158 | 158 | } |
| @@ -192,7 +192,7 @@ export async function everydayCreate(ctx: Ctx): Promise<Response> { | ||
| 192 | 192 | if (!made.ok) |
| 193 | 193 | return say( |
| 194 | 194 | made.error === "username_taken" |
| 195 | ? "That username is taken." | |
| 195 | ? USERNAME_TAKEN | |
| 196 | 196 | : made.error === "email_taken" |
| 197 | 197 | ? EMAIL_TAKEN_MESSAGE |
| 198 | 198 | : made.error === "username_reserved" |
src/web/pages/org.ts +2 −1
| @@ -58,6 +58,7 @@ import { | ||
| 58 | 58 | time, |
| 59 | 59 | field as uiField, |
| 60 | 60 | } from "../ui.ts"; |
| 61 | import { NAME_RESERVED } from "../../actions/signup_words.ts"; | |
| 61 | 62 | |
| 62 | 63 | const PER_PAGE = 20; |
| 63 | 64 | |
| @@ -459,7 +460,7 @@ export async function newOrgSubmit(ctx: Ctx): Promise<Response> { | ||
| 459 | 460 | if (!created.ok) { |
| 460 | 461 | const messages: Record<string, string> = { |
| 461 | 462 | name_taken: "That name is already taken.", |
| 462 | name_reserved: "That name is reserved.", | |
| 463 | name_reserved: NAME_RESERVED, | |
| 463 | 464 | invalid_name: "Letters, digits, dots and dashes only.", |
| 464 | 465 | unauthorized: "Sign in to create an organization.", |
| 465 | 466 | }; |
src/web/pages/signup-passkey.ts +3 −3
| @@ -17,7 +17,7 @@ import { clientAddress, clientBucket } from "../../http/peer.ts"; | ||
| 17 | 17 | import { boundedBody, json, str } from "../../http/respond.ts"; |
| 18 | 18 | import { relying, store } from "./passkeys.ts"; |
| 19 | 19 | import { EDGE_NAME_MESSAGE, inviteOf, pinnedTo, safeNext, sessionCookie, signinAllowed, signupSlot } from "./site.ts"; |
| 20 | import { EMAIL_TAKEN_MESSAGE, INVALID_USERNAME_MESSAGE } from "../../actions/signup_words.ts"; | |
| 20 | import { EMAIL_TAKEN_MESSAGE, INVALID_USERNAME_MESSAGE, NAME_RESERVED, USERNAME_TAKEN } from "../../actions/signup_words.ts"; | |
| 21 | 21 | import { PASSKEY_EXPIRED, PASSKEY_REJECTED, PASSKEY_TAKEN } from "../passkey-words.ts"; |
| 22 | 22 | |
| 23 | 23 | const refuse = (status: number, message: string) => |
| @@ -26,8 +26,8 @@ const refuse = (status: number, message: string) => | ||
| 26 | 26 | /** The words for each way a sign-up can fail, written for anyone. */ |
| 27 | 27 | const WORDS: Record<string, string> = { |
| 28 | 28 | invalid_username: INVALID_USERNAME_MESSAGE, |
| 29 | username_taken: "That username is taken.", | |
| 30 | username_reserved: "That name is reserved.", | |
| 29 | username_taken: USERNAME_TAKEN, | |
| 30 | username_reserved: NAME_RESERVED, | |
| 31 | 31 | email_taken: EMAIL_TAKEN_MESSAGE, |
| 32 | 32 | invalid_email: "That does not look like an email address.", |
| 33 | 33 | edge_name: EDGE_NAME_MESSAGE, |
src/web/pages/site.ts +3 −3
| @@ -54,7 +54,7 @@ import { existsSync } from "node:fs"; | ||
| 54 | 54 | import { join } from "node:path"; |
| 55 | 55 | import { hasAdmin } from "../../spawn/claim.ts"; |
| 56 | 56 | import { getSignup, type SignupMode } from "../../settings/signup.ts"; |
| 57 | import { EMAIL_TAKEN_MESSAGE } from "../../actions/signup_words.ts"; | |
| 57 | import { EMAIL_TAKEN_MESSAGE, NAME_RESERVED, USERNAME_TAKEN } from "../../actions/signup_words.ts"; | |
| 58 | 58 | |
| 59 | 59 | export function sessionCookie( |
| 60 | 60 | value: string, |
| @@ -661,8 +661,8 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 661 | 661 | invalid_username: { |
| 662 | 662 | username: "Usernames are letters, digits, dots, dashes.", |
| 663 | 663 | }, |
| 664 | username_taken: { username: "That username is taken." }, | |
| 665 | username_reserved: { username: "That name is reserved." }, | |
| 664 | username_taken: { username: USERNAME_TAKEN }, | |
| 665 | username_reserved: { username: NAME_RESERVED }, | |
| 666 | 666 | email_taken: { email: EMAIL_TAKEN_MESSAGE }, |
| 667 | 667 | password_too_long: { password: PASSWORD_TOO_LONG }, |
| 668 | 668 | weak_password: { password: "Passwords need at least 12 characters." }, |
test/name-words.test.ts added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | /** | |
| 2 | * A taken username, a reserved name and a missing email are said from one | |
| 3 | * module, src/actions/signup_words.ts. Every door that makes an account or | |
| 4 | * an org imports the sentence; none types its own copy, so a copy typed | |
| 5 | * back 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 { EMAIL_WANTED, NAME_RESERVED, USERNAME_TAKEN } from "../src/actions/signup_words.ts"; | |
| 12 | ||
| 13 | const ROOT = join(import.meta.dir, ".."); | |
| 14 | ||
| 15 | const DOORS = [ | |
| 16 | "src/actions/first_admin.ts", | |
| 17 | "src/web/pages/claim.ts", | |
| 18 | "src/web/pages/crew.ts", | |
| 19 | "src/web/pages/everyday.ts", | |
| 20 | "src/web/pages/org.ts", | |
| 21 | "src/web/pages/signup-passkey.ts", | |
| 22 | "src/web/pages/site.ts", | |
| 23 | ]; | |
| 24 | ||
| 25 | const SENTENCES = [USERNAME_TAKEN, NAME_RESERVED, EMAIL_WANTED]; | |
| 26 | ||
| 27 | describe("name words", () => { | |
| 28 | test("the module says each sentence as it always read", () => { | |
| 29 | expect(USERNAME_TAKEN).toBe("That username is taken."); | |
| 30 | expect(NAME_RESERVED).toBe("That name is reserved."); | |
| 31 | expect(EMAIL_WANTED).toBe("An email address, please."); | |
| 32 | }); | |
| 33 | ||
| 34 | for (const file of DOORS) { | |
| 35 | test(`${file} types none of the sentences itself`, () => { | |
| 36 | const text = readFileSync(join(ROOT, file), "utf8"); | |
| 37 | for (const sentence of SENTENCES) expect(text).not.toContain(sentence); | |
| 38 | expect(text).toContain("signup_words.ts"); | |
| 39 | }); | |
| 40 | } | |
| 41 | }); |
new above
offline
The taken-name sentence on the line above is still typed here and differs from the account wording. Fold it into signup_words.ts.