From bc59f7842bab9f48ebe23d80b56860e9191da63a Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Mon, 28 Sep 2026 23:56:13 +0300 Subject: [PATCH] feat(api): create a team; slugs the website serves as pages are reserved POST /team makes another team with the caller as owner. The website now serves a team at nestri.io/, so Team.Slug refuses its top-level pages (home, new, pricing, ...) and a personal team whose generated slug would be one gets a suffix instead. --- apps/api/app/routes/team.ts | 52 ++++++++++++++++++++++---- apps/api/test/team.test.ts | 31 ++++++++++++++++ packages/core/src/team/index.ts | 66 ++++++++++++++++++++++++++++++++- 3 files changed, 139 insertions(+), 10 deletions(-) diff --git a/apps/api/app/routes/team.ts b/apps/api/app/routes/team.ts index d4a43e9d..72274b0d 100644 --- a/apps/api/app/routes/team.ts +++ b/apps/api/app/routes/team.ts @@ -1,6 +1,7 @@ import { Actor } from '@nestri/core/actor'; import { ErrorCodes, VisibleError } from '@nestri/core/error'; import { Examples } from '@nestri/core/examples'; +import { Identifier } from '@nestri/core/id'; import { Team } from '@nestri/core/team/index'; import { Member } from '@nestri/core/team/member'; import { Hono } from 'hono'; @@ -32,6 +33,14 @@ export namespace TeamApi { }) .meta({ ref: 'TeamPerson', description: 'A member of a team, and who they are' }); + const slugTaken = () => + new VisibleError( + 'already_exists', + ErrorCodes.Validation.ALREADY_EXISTS, + 'That slug is taken', + 'slug' + ); + /** The caller's membership of a team, or the 404 that hides whether it exists. */ async function membership(teamId: string) { const member = await Member.findByTeamAndUser({ teamId, userId: Actor.userID }); @@ -83,6 +92,40 @@ export namespace TeamApi { return c.json({ data: teams.filter((t) => t !== null) }); } ) + .post( + '/', + describeRoute({ + tags: ['Team'], + summary: 'Create a team', + description: + 'A new team with you as its owner. Everyone already has a personal team from sign-up; this is another one, for people or hardware you want kept apart. A taken slug is a 409.', + responses: { + 200: { + content: { 'application/json': { schema: Result(Mine) } }, + description: 'The team, with you as owner' + }, + 400: ErrorResponses[400], + 401: ErrorResponses[401], + 409: ErrorResponses[409] + } + }), + validator('json', z.object({ name: Team.Name, slug: Team.Slug })), + async (c) => { + const { name, slug } = c.req.valid('json'); + if (await Team.fromSlug(slug)) throw slugTaken(); + const id = Identifier.ascending('team'); + try { + await Team.create({ id, name, slug }); + } catch (error) { + // The unique index, when two people race for the same slug. + const code = (error as { code?: string; cause?: { code?: string } }) ?? {}; + if (code.code === '23505' || code.cause?.code === '23505') throw slugTaken(); + throw error; + } + const team = await Team.fromID(id); + return c.json({ data: { ...Team.serialize(team!), role: 'owner' as const } }); + } + ) .patch( '/:id', describeRoute({ @@ -127,14 +170,7 @@ export namespace TeamApi { const renamed = await Team.rename({ id: team.id, ...body }); return c.json({ data: renamed! }); } catch (error) { - if (error instanceof Team.SlugTaken) { - throw new VisibleError( - 'already_exists', - ErrorCodes.Validation.ALREADY_EXISTS, - 'That slug is taken', - 'slug' - ); - } + if (error instanceof Team.SlugTaken) throw slugTaken(); throw error; } } diff --git a/apps/api/test/team.test.ts b/apps/api/test/team.test.ts index d51b092f..dee13a10 100644 --- a/apps/api/test/team.test.ts +++ b/apps/api/test/team.test.ts @@ -124,3 +124,34 @@ describe('GET /box', () => { expect(((await res.json()) as any).data).toEqual([]); }); }); + +describe('POST /team', () => { + test('creates a second team with you as owner, listed after your personal one', async () => { + const me = await person('team-create'); + const s = slug('second'); + const res = await app.request('/team', { + method: 'POST', + headers: me.headers, + body: JSON.stringify({ name: 'Second', slug: s }) + }); + expect(res.status).toBe(200); + expect(((await res.json()) as any).data).toMatchObject({ + name: 'Second', + slug: s, + role: 'owner' + }); + const list = ((await (await app.request('/team', { headers: me.headers })).json()) as any).data; + expect(list.map((t: any) => t.slug)).toEqual([expect.any(String), s]); + expect(list[0].id).toBe(me.teamId); + }); + + test('a taken slug is a 409, and a page of the site is a 400', async () => { + const me = await person('team-create-taken'); + const s = slug('dupe'); + const post = (body: unknown) => + app.request('/team', { method: 'POST', headers: me.headers, body: JSON.stringify(body) }); + expect((await post({ name: 'A', slug: s })).status).toBe(200); + expect((await post({ name: 'B', slug: s })).status).toBe(409); + expect((await post({ name: 'C', slug: 'pricing' })).status).toBe(400); + }); +}); diff --git a/packages/core/src/team/index.ts b/packages/core/src/team/index.ts index 07d50c16..51c6442f 100644 --- a/packages/core/src/team/index.ts +++ b/packages/core/src/team/index.ts @@ -190,7 +190,7 @@ export namespace Team { .replace(/^-|-$/g, '') .slice(0, 50); - const existing = await fromSlug(baseSlug); + const existing = RESERVED.has(baseSlug) || (await fromSlug(baseSlug)); const slug = existing ? `${baseSlug}-${String(Math.floor(Math.random() * 9999)).padStart(4, '0')}` : baseSlug; @@ -237,10 +237,72 @@ export namespace Team { * keeping a second copy of the rule that drifts. */ export const Name = z.string().trim().min(1).max(64); + /** + * Slugs a team may not have, because the website serves a team at + * `nestri.io/` and these are its own top-level pages, present or + * planned. A team called `pricing` would either shadow the page or be + * unreachable, and neither is a thing to discover after someone picked it. + */ + export const RESERVED = new Set([ + 'about', + 'account', + 'activity', + 'admin', + 'api', + 'app', + 'assets', + 'auth', + 'billing', + 'blog', + 'blogs', + 'boxes', + 'careers', + 'changelog', + 'collab', + 'compare', + 'contact', + 'dashboard', + 'docs', + 'edge', + 'enterprise', + 'games', + 'gaming', + 'help', + 'home', + 'install', + 'installation', + 'legal', + 'login', + 'logout', + 'machines', + 'nesbox', + 'nesgpu', + 'nesktop', + 'neslink', + 'neslite', + 'nessh', + 'nestri', + 'new', + 'pricing', + 'privacy', + 'products', + 'security', + 'settings', + 'signup', + 'static', + 'status', + 'support', + 'team', + 'teams', + 'terms', + 'www' + ]); + export const Slug = z .string() .max(50) - .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'lowercase letters, numbers and single hyphens'); + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'lowercase letters, numbers and single hyphens') + .refine((s) => !RESERVED.has(s), 'that name is taken by a page on the site'); /** A slug somebody else's team already has. */ export class SlugTaken extends Error {