add space-themed names to org name generator

This commit is contained in:
swithek 2026-10-05 18:27:06 +02:00
parent 3d81fdeafd
commit eadca45942
18 changed files with 432 additions and 40 deletions

View File

@ -93,8 +93,9 @@ limit. Members are unlimited by default.
The workspace limit defaults to `1`, which runs the image as a single
workspace:
- The first boot creates a workspace named Oxynote and its admin. Sign in as
`admin@example.com` with the password `oxynote-admin-1234`, then change
- The first boot creates a workspace and its admin. The workspace gets the
name of a place in space, such as Sea of Clouds, picked at random. Sign in
as `admin@example.com` with the password `oxynote-admin-1234`, then change
the email address, the password and the workspace name in the settings.
The admin's address receives no email, so a new address is confirmed by a
link sent to the new address only.

View File

@ -16,13 +16,12 @@ import { visit } from "./page"
// Changing the port means changing that service.
export const SINGLE_WORKSPACE_URL = "http://localhost:19082"
// the admin and workspace the instance creates at its first boot. The
// operator README documents these values.
// the admin the instance creates at its first boot. The operator README
// documents these values.
export const DEFAULT_ADMIN: Credentials = {
email: "admin@example.com",
password: "oxynote-admin-1234",
}
export const DEFAULT_WORKSPACE_NAME = "Oxynote"
// authPost calls one of better-auth's endpoints on the instance as the
// context's user. better-auth refuses a request without a trusted origin,
@ -38,6 +37,26 @@ export function authPost(
})
}
// defaultWorkspaceName reads the name of the instance's one workspace as
// the context's user. The instance picks the name at its first boot, so a
// test cannot know it up front.
export async function defaultWorkspaceName(
request: APIRequestContext,
): Promise<string> {
const response = await request.get(
`${SINGLE_WORKSPACE_URL}/auth-realtime/api/auth/organization/list`,
{ headers: { Origin: SINGLE_WORKSPACE_URL } },
)
expect(response.ok()).toBe(true)
const [workspace] = (await response.json()) as { name: string }[]
if (!workspace) {
throw new Error("the instance has no workspace")
}
return workspace.name
}
// signedInContext opens a browser context already signed in with the given
// credentials. It signs in over the API, since the login page itself is
// what other tests drive.

View File

@ -18,10 +18,11 @@ export interface Workspace {
// newWorkspace mints a workspace nobody else uses. The slug is what the
// server checks for uniqueness, so it carries the random part; the name
// only has to pass validation, which allows no spaces.
// only has to pass validation. It holds a space, which a link has to
// encode.
export function newWorkspace(): Workspace {
return {
name: "Acme-Corp",
name: "Acme Corp",
slug: `ws-${randomUUID().slice(0, 8)}`,
}
}

View File

@ -29,12 +29,12 @@ test.describe("onboarding", () => {
).toBeVisible()
})
test("rejects a workspace name with spaces", async ({ page, request }) => {
test("rejects a workspace name with a slash", async ({ page, request }) => {
const credentials = await signUpAndVerify(page, request)
await submitLoginForm(page, credentials)
await expect(page).toHaveURL(/\/welcome$/, { timeout: 15_000 })
await submitWorkspaceForm(page, { ...newWorkspace(), name: "Acme Corp" })
await submitWorkspaceForm(page, { ...newWorkspace(), name: "Acme/Corp" })
await expect(
page.getByText(t("onboarding.welcome.errors.workspace-name-regex")),

View File

@ -12,7 +12,7 @@ import { visit } from "../helpers/page"
import {
authPost,
DEFAULT_ADMIN,
DEFAULT_WORKSPACE_NAME,
defaultWorkspaceName,
joinThroughInvitation,
signedInContext,
SINGLE_WORKSPACE_URL,
@ -48,7 +48,9 @@ test.describe("a single-workspace instance", () => {
await expect(page).toHaveURL(/-[a-z0-9]{20}$/, { timeout: 15_000 })
await expect(
page.getByRole("button", { name: DEFAULT_WORKSPACE_NAME }),
page.getByRole("button", {
name: await defaultWorkspaceName(page.request),
}),
).toBeVisible()
})

View File

@ -31,7 +31,9 @@ opens the pool, connects to Valkey (only when
no secondary storage) and listens. Everything else is a factory taking what
it needs. Modules: `env.ts` (zod config), `core.ts` (every call into core),
`db.ts` (the `Store`), `bootstrap.ts` (the single-organization mode's
organization and admin, created before listening), `reporting.ts`,
organization and admin, created before listening),
`organization-names.ts` (the names that organization can start with),
`reporting.ts`,
`logging.ts`, `headers.ts`, `auth.ts` (`createAuth` + better-auth
callbacks), `hocuspocus.ts` (`createHocuspocus`, `createDocumentHooks`,
`flushDocument`), `routes.ts` (`createRoutes` → Hono app), `operations.ts`

View File

@ -253,7 +253,20 @@ describe("invitationLink", () => {
})
expect(link).toBe(
"http://localhost:8080/accept-invite?id=inv-1&email=a@b.c&inviter=Ada&orgName=Acme&orgId=org-1",
"http://localhost:8080/accept-invite?id=inv-1&email=a%40b.c&inviter=Ada&orgName=Acme&orgId=org-1",
)
})
it("encodes names that hold spaces", ({ expect }) => {
const link = invitationLink(testEnv(), {
id: "inv-1",
email: "a@b.c",
inviter: { user: { name: "Ada Lovelace" } },
organization: { id: "org-1", name: "Sea of Clouds" },
})
expect(link).toBe(
"http://localhost:8080/accept-invite?id=inv-1&email=a%40b.c&inviter=Ada+Lovelace&orgName=Sea+of+Clouds&orgId=org-1",
)
})
})
@ -400,7 +413,7 @@ describe("createOrganizationHooks", () => {
{
email: "a@b.c",
organization: "Acme",
link: "http://localhost:8080/accept-invite?id=inv-1&email=a@b.c&inviter=Ada&orgName=Acme&orgId=org-1",
link: "http://localhost:8080/accept-invite?id=inv-1&email=a%40b.c&inviter=Ada&orgName=Acme&orgId=org-1",
},
)
})

View File

@ -151,16 +151,18 @@ export interface InvitationData {
// the invite link the emailed button points at. Everything the
// accept-invite page needs to render before the user has a session travels
// in the query string.
// in the query string. The values are encoded, because a name can hold
// spaces.
export function invitationLink(env: Env, data: InvitationData): string {
return (
env.organizationInvitationUrl +
`?id=${data.id}` +
`&email=${data.email}` +
`&inviter=${data.inviter.user.name}` +
`&orgName=${data.organization.name}` +
`&orgId=${data.organization.id}`
)
const query = new URLSearchParams({
id: data.id,
email: data.email,
inviter: data.inviter.user.name,
orgName: data.organization.name,
orgId: data.organization.id,
})
return `${env.organizationInvitationUrl}?${query.toString()}`
}
// bind an MCP token to the user's organization at issuance, the same way

View File

@ -42,6 +42,7 @@ function deps(
accounts,
hashPassword: vi.fn().mockResolvedValue("hashed"),
createOrganization: vi.fn().mockResolvedValue({}),
random: vi.fn().mockReturnValue(0),
}
}
@ -71,11 +72,14 @@ describe("bootstrapSingleOrganization", () => {
password: "hashed",
})
expect(d.createOrganization).toHaveBeenCalledWith({
name: "Oxynote",
slug: "oxynote",
name: "Sea of Tranquility",
slug: "sea-of-tranquility",
userId: "admin-1",
})
expect.soft(d.log.warn).toHaveBeenCalledTimes(1)
expect.soft(d.random).toHaveBeenCalledTimes(1)
expect.soft(d.log.warn).toHaveBeenCalledExactlyOnceWith(
expect.stringContaining("Sea of Tranquility"),
)
})
it("reuses an admin left over from a failed run", async ({
@ -86,8 +90,8 @@ describe("bootstrapSingleOrganization", () => {
await bootstrapSingleOrganization(d)
expect(d.createOrganization).toHaveBeenCalledWith({
name: "Oxynote",
slug: "oxynote",
name: "Sea of Tranquility",
slug: "sea-of-tranquility",
userId: "admin-0",
})
expect.soft(d.accounts.createUser).not.toHaveBeenCalled()
@ -111,6 +115,7 @@ describe("bootstrapSingleOrganization", () => {
expect.soft(d.accounts.findUserByEmail).not.toHaveBeenCalled()
expect.soft(d.createOrganization).not.toHaveBeenCalled()
expect.soft(d.random).not.toHaveBeenCalled()
expect.soft(d.log.warn).not.toHaveBeenCalled()
})

View File

@ -1,6 +1,7 @@
import type { Store } from "./db.js"
import type { Env } from "./env.js"
import type { Logger } from "./logging.js"
import { pickOrganization } from "./organization-names.js"
// the first account of a single-organization deployment. No mail reaches
// the address, because example.com is reserved (RFC 2606). The password
@ -11,11 +12,6 @@ export const bootstrapAdmin = {
name: "Admin",
}
const bootstrapOrganization = {
name: "Oxynote",
slug: "oxynote",
}
// the parts of better-auth's internal adapter that the bootstrap uses.
export interface AccountAdapter {
findUserByEmail(email: string): Promise<{ user: { id: string } } | null>
@ -44,6 +40,8 @@ export interface BootstrapDeps {
slug: string
userId: string
}) => Promise<unknown>
// picks the organization's name. Math.random outside tests.
random: () => number
}
// bootstrapSingleOrganization creates the organization and its admin when a
@ -57,6 +55,7 @@ export async function bootstrapSingleOrganization({
accounts,
hashPassword,
createOrganization,
random,
}: BootstrapDeps): Promise<void> {
if (env.maxOrganizations !== 1) {
return
@ -91,13 +90,15 @@ export async function bootstrapSingleOrganization({
userId = user.id
}
const organization = pickOrganization(random)
await createOrganization({
...bootstrapOrganization,
...organization,
userId,
})
log.warn(
`created the ${bootstrapOrganization.name} organization. Sign in as ${bootstrapAdmin.email} with the default password and change both.`,
`created the ${organization.name} organization. Sign in as ${bootstrapAdmin.email} with the default password and change both.`,
)
}

View File

@ -53,6 +53,7 @@ await bootstrapSingleOrganization({
hashPassword: (password) => authContext.password.hash(password),
createOrganization: (organization) =>
auth.api.createOrganization({ body: organization }),
random: Math.random,
})
const {

View File

@ -0,0 +1,80 @@
import { describe, it } from "vitest"
import { organizationNames, pickOrganization } from "./organization-names.js"
// what the web forms accept as a workspace name and as its slug.
const NAME_RULE = /^[a-zA-Z0-9-_ ]+$/
const SLUG_RULE = /^[a-zA-Z0-9-_]+$/
// the web app cuts a name it puts in a link at this length.
const MAX_NAME_LENGTH = 20
describe("organizationNames", () => {
it("holds only names the workspace name rule accepts", ({ expect }) => {
expect(organizationNames.length).toBeGreaterThan(200)
expect.soft(
organizationNames.filter(
(name) =>
!NAME_RULE.test(name) ||
name.length > MAX_NAME_LENGTH,
),
).toEqual([])
})
})
describe("pickOrganization", () => {
it.for([
{
name: "the first name at zero",
input: 0,
expected: {
name: "Sea of Tranquility",
slug: "sea-of-tranquility",
},
},
{
name: "the last name just below one",
input: 0.999999,
expected: {
name: "Photon Sphere",
slug: "photon-sphere",
},
},
])("picks $name", ({ input, expected }, { expect }) => {
expect(pickOrganization(() => input)).toEqual(expected)
})
it.for([
{ name: "one", input: 1 },
{ name: "a negative number", input: -0.5 },
])(
"throws when the random number is $name",
({ input }, { expect }) => {
expect(() => pickOrganization(() => input)).toThrow(
"the random number is not between zero and one",
)
},
)
it("gives every name a form the workspace rules accept", ({
expect,
}) => {
// the half step keeps a rounding error from landing on the name
// before
const organizations = organizationNames.map((_, index) =>
pickOrganization(
() => (index + 0.5) / organizationNames.length,
),
)
expect(organizations.map(({ name }) => name)).toEqual(
organizationNames,
)
expect.soft(
organizations.filter(
({ slug }) => !SLUG_RULE.test(slug),
),
).toEqual([])
expect.soft(
new Set(organizations.map(({ slug }) => slug)).size,
).toBe(organizationNames.length)
})
})

View File

@ -0,0 +1,242 @@
// names a single-organization deployment can start with. Each is the real
// name of a place in space. Each passes the workspace name rule of the web
// forms. None is longer than 20 characters, which is where the web app
// cuts a name it puts in a link.
export const organizationNames = [
// the Moon's seas, lakes and bays, by the English form of their names
"Sea of Tranquility",
"Sea of Serenity",
"Sea of Clouds",
"Sea of Nectar",
"Sea of Islands",
"Sea of Waves",
"Lake of Dreams",
"Lake of Time",
"Lake of Spring",
"Lake of Summer",
"Lake of Autumn",
"Wintry Lake",
"Lake of Hope",
"Lake of Joy",
"Lake of Perseverance",
"Bay of Dew",
"Bay of Harmony",
"Bay of Trust",
// landing sites and landmarks on the Moon
"Tranquility Base",
"Hadley Base",
"Hadley Rille",
"Hadley Delta",
"Descartes Highlands",
"Fra Mauro Highlands",
"Marius Hills",
"Reiner Gamma",
"Alpine Valley",
"Serpentine Ridge",
"Lunar Alps",
"Cape Fresnel",
"Sculptured Hills",
"North Massif",
"South Massif",
"Family Mountain",
"Silver Spur",
"Apollo Basin",
"Lunar South Pole",
"Shackleton Crater",
"Tycho Crater",
"Copernicus Crater",
"Clavius Crater",
"Camelot Crater",
"North Ray Crater",
"South Ray Crater",
"Plum Crater",
"Cone Crater",
"Surveyor Crater",
"Babbage Crater",
"Boole Crater",
"Lovelace Crater",
"Hilbert Crater",
"Euler Crater",
"Gauss Crater",
"Hypatia Crater",
"Kepler Crater",
"Archimedes Crater",
"Pythagoras Crater",
// Mars
"Bradbury Landing",
"Green Valley",
"Eagle Crater",
"Endurance Crater",
"Victoria Crater",
"Endeavour Crater",
"Bonneville Crater",
"Gusev Crater",
"Gale Crater",
"Jezero Crater",
"Meridiani Planum",
"Oxia Planum",
"Planum Boreum",
"Planum Australe",
"Utopia Planitia",
"Elysium Planitia",
"Arcadia Planitia",
"Olympus Mons",
"Pavonis Mons",
"Elysium Mons",
"Tharsis Montes",
"Valles Marineris",
"Candor Chasma",
"Ophir Chasma",
"Ares Vallis",
"Kasei Valles",
"Syrtis Major",
"Lake of the Sun",
"Meridian Bay",
"Bay of the Dawn",
"Golden Plain",
"Labyrinth of Night",
"Mariner Valley",
"Mount Sharp",
"Yellowknife Bay",
"Glen Torridon",
"Bagnold Dunes",
"Vera Rubin Ridge",
"Marathon Valley",
"Perseverance Valley",
"Cape York",
"Solander Point",
"Greeley Haven",
"Murray Ridge",
"Bright Angel",
"Rock Garden",
"Mermaid Dune",
// other planets, moons, rings and asteroids
"Caloris Basin",
"Caloris Montes",
"Borealis Planitia",
"Maxwell Montes",
"Artemis Chasma",
"Tara Regio",
"Galileo Regio",
"Ontario Lacus",
"Tiger Stripes",
"Ithaca Chasma",
"Odysseus Crater",
"Herschel Crater",
"Cassini Regio",
"Cassini Division",
"Roche Division",
"Encke Gap",
"Keeler Gap",
"Huygens Gap",
"Maxwell Gap",
"Colombo Gap",
"Phoebe Ring",
"Gossamer Rings",
"Adams Ring",
"Verona Rupes",
"Tenzing Montes",
"Wright Mons",
"Piccard Mons",
"Voyager Terra",
"Pioneer Terra",
"Viking Terra",
"Hayabusa Terra",
"Lowell Regio",
"Burney Crater",
"Argo Chasma",
"Kubrick Mons",
"Butler Mons",
"Ahuna Mons",
"Hanami Planum",
"Muses Sea",
// orbits, belts and landmarks of the sky
"Low Earth Orbit",
"Geostationary Orbit",
"Halo Orbit",
"Lagrange Point",
"Van Allen Belt",
"Asteroid Belt",
"Kirkwood Gap",
"Kuiper Belt",
"Oort Cloud",
"Hill Sphere",
"Planet Nine",
"Goldilocks Zone",
"Habitable Zone",
"Pale Blue Dot",
"Blue Marble",
"Proxima Centauri",
"Tau Ceti",
"Solar Neighborhood",
"Gould Belt",
"Radcliffe Wave",
"Orion Arm",
"Orion Spur",
"Perseus Arm",
"Sagittarius Arm",
"Galactic Center",
"Fermi Bubbles",
"Milky Way",
"Great Attractor",
"Cosmic Web",
"Summer Triangle",
"Winter Hexagon",
"Big Dipper",
"Jewel Box",
"Cosmic Cliffs",
"Cosmic Reef",
"Mystic Mountain",
"Cygnus Loop",
"Orion Nebula",
"Carina Nebula",
"Lagoon Nebula",
"Waterfall Nebula",
"Eagle Nebula",
"Helix Nebula",
"Ring Nebula",
"Rosette Nebula",
"Veil Nebula",
"Crab Nebula",
"Horsehead Nebula",
"Trifid Nebula",
"Butterfly Nebula",
"Pelican Nebula",
"Flame Nebula",
"Owl Nebula",
"Bubble Nebula",
"Heart Nebula",
"Cone Nebula",
"Andromeda Galaxy",
"Whirlpool Galaxy",
"Pinwheel Galaxy",
"Sunflower Galaxy",
"Cartwheel Galaxy",
"Triangulum Galaxy",
"Fireworks Galaxy",
"Tadpole Galaxy",
"Antennae Galaxies",
"Hubble Deep Field",
"Sunburst Arc",
"Cosmic Horseshoe",
"Einstein Ring",
"Photon Ring",
"Photon Sphere",
]
// pickOrganization picks the name and the slug of a new organization. The
// random number is at least zero and below one, as Math.random answers.
export function pickOrganization(random: () => number): {
name: string
slug: string
} {
const name =
organizationNames[
Math.floor(random() * organizationNames.length)
]
if (name === undefined) {
throw new Error("the random number is not between zero and one")
}
return { name, slug: name.toLowerCase().replaceAll(" ", "-") }
}

View File

@ -153,6 +153,27 @@ describe("<WorkspaceSection>", { concurrent: false }, () => {
})
})
it("saves a name made of several words", async ({ expect }) => {
seedWorkspace()
const calls = mockAuthEndpoint("organization/update", () => ({
id: "org-1",
}))
const { wrapper } = await mountSection()
await nameInput(wrapper).setValue("Sea of Clouds")
await nameInput(wrapper).trigger("blur")
// the save chains vee-validate's validation, the update request and
// a refetch, none of which the component signals the end of
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(calls[0]?.body).toEqual({
data: { name: "Sea of Clouds" },
organizationId: "org-1",
})
})
it("saves nothing when the name is unchanged", async ({ expect }) => {
seedWorkspace()
const calls = mockAuthEndpoint("organization/update", () => ({
@ -179,7 +200,9 @@ describe("<WorkspaceSection>", { concurrent: false }, () => {
await nameInput(wrapper).trigger("blur")
await vi.waitFor(() => {
expect(wrapper.text()).toContain("Only letters, numbers, hyphens")
expect(wrapper.text()).toContain(
t("settings.workspace.errors.name-regex"),
)
}, WAIT_FOR_OPTIONS)
expect(calls).toHaveLength(0)
})

View File

@ -31,7 +31,7 @@ const formSchema = toTypedSchema(
.trim()
.min(2)
.max(50)
.regex(/^[a-zA-Z0-9-_]+$/, {
.regex(/^[a-zA-Z0-9-_ ]+$/, {
message: t("settings.workspace.errors.name-regex"),
})
.default(fetchOrganization.state.value.data?.data?.name || ""),

View File

@ -25,7 +25,7 @@ const formSchema = toTypedSchema(
.trim()
.min(2)
.max(50)
.regex(/^[a-zA-Z0-9-_]+$/, {
.regex(/^[a-zA-Z0-9-_ ]+$/, {
message: t("onboarding.welcome.errors.workspace-name-regex"),
}),
workspaceSlug: z

View File

@ -160,7 +160,7 @@
"logout": "Log out"
},
"errors": {
"workspace-name-regex": "Only letters, numbers, hyphens, and underscores can be used",
"workspace-name-regex": "Only letters, numbers, spaces, hyphens, and underscores can be used",
"workspace-url-regex": "Only letters, numbers, hyphens, and underscores can be used",
"workspace-url-taken": "This workspace URL is already taken",
"workspace-url-invalid": "This workspace URL is invalid",

View File

@ -450,7 +450,7 @@
}
},
"errors": {
"name-regex": "Only letters, numbers, hyphens, and underscores can be used",
"name-regex": "Only letters, numbers, spaces, hyphens, and underscores can be used",
"toasts": {
"logo": {
"title": "Workspace logo couldn't be changed",