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/<slug>, 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.
This commit is contained in:
Wanjohi
2026-09-28 23:56:13 +03:00
parent df657e84ab
commit bc59f7842b
3 changed files with 139 additions and 10 deletions
+44 -8
View File
@@ -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;
}
}
+31
View File
@@ -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);
});
});
+64 -2
View File
@@ -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/<slug>` 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 {