diff --git a/apps/api/app/index.ts b/apps/api/app/index.ts index 232f6651..f1402331 100644 --- a/apps/api/app/index.ts +++ b/apps/api/app/index.ts @@ -11,6 +11,7 @@ import { type ContentfulStatusCode } from 'hono/utils/http-status'; import { auth } from './middleware/auth.js'; import { AccessTokenApi } from './routes/access-token.js'; import { BillingApi } from './routes/billing.js'; +import { BoxApi } from './routes/box.js'; import { EnrolmentApi } from './routes/enrolment.js'; import { GameApi } from './routes/game.js'; import { IndexApi } from './routes/index.js'; @@ -20,6 +21,7 @@ import { MachineApi } from './routes/machine.js'; import { OrganisationApi } from './routes/organisation.js'; import { SessionApi } from './routes/session.js'; import { SteamApi } from './routes/steam.js'; +import { TeamApi } from './routes/team.js'; import { UserApi } from './routes/user.js'; import { WaitlistApi } from './routes/waitlist.js'; @@ -51,6 +53,8 @@ const routes = app .route('/machine', MachineApi.route) .route('/machine', SessionApi.machineRoute) .route('/machine', EnrolmentApi.route) + .route('/team', TeamApi.route) + .route('/box', BoxApi.route) .route('/session', SessionApi.route) .route('/access-token', AccessTokenApi.route) .route('/waitlist', WaitlistApi.route) diff --git a/apps/api/app/routes/box.ts b/apps/api/app/routes/box.ts new file mode 100644 index 00000000..5cc0d6a1 --- /dev/null +++ b/apps/api/app/routes/box.ts @@ -0,0 +1,39 @@ +import { Actor } from '@nestri/core/actor'; +import { Box } from '@nestri/core/box/index'; +import { Examples } from '@nestri/core/examples'; +import { Hono } from 'hono'; +import { describeRoute } from 'hono-openapi'; +import { z } from 'zod'; + +import { ErrorResponses, notPublic, Result } from '../utils'; + +/** + * The caller's boxes. Read-only: a box is created by placement and its state + * is whatever its host last reported, so the list is the whole of what a + * person can ask here for now. + */ +export namespace BoxApi { + export const route = new Hono().use(notPublic).get( + '/', + describeRoute({ + tags: ['Box'], + summary: 'Your boxes', + description: + 'Every box you own, oldest first, with its state and — for one that stopped — why, as its host reported it.', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.array(Box.Info).meta({ description: 'Your boxes', example: [Examples.Box] }) + ) + } + }, + description: 'Your boxes' + }, + 401: ErrorResponses[401] + } + }), + async (c) => c.json({ data: await Box.listByUser(Actor.userID) }) + ); +} diff --git a/apps/api/app/routes/team.ts b/apps/api/app/routes/team.ts new file mode 100644 index 00000000..72274b0d --- /dev/null +++ b/apps/api/app/routes/team.ts @@ -0,0 +1,198 @@ +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'; +import { describeRoute } from 'hono-openapi'; +import { z } from 'zod'; + +import { ErrorResponses, notPublic, Result, validator } from '../utils'; + +/** + * The teams a caller belongs to: read them, rename one, see who is on it. + * + * Every read is membership-scoped in the same way — a team you are not on is + * a 404, never a 403, so an id cannot be probed for. Renaming takes an owner + * or an admin, because the slug is the team's address and changing it moves + * every link somebody saved. + */ +export namespace TeamApi { + const Mine = Team.Info.extend({ + role: Member.Info.shape.role + }).meta({ ref: 'TeamMembership', description: 'A team, and your role on it' }); + + const Person = z + .object({ + id: Member.Info.shape.id, + userId: Member.Info.shape.userId, + role: Member.Info.shape.role, + name: z.string(), + email: z.string().nullable() + }) + .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 }); + const team = member ? await Team.fromID(teamId) : null; + if (!member || !team) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'No such team, or you are not on it' + ); + } + return { member, team }; + } + + export const route = new Hono() + .use(notPublic) + .get( + '/', + describeRoute({ + tags: ['Team'], + summary: 'Your teams', + description: + 'Every team you are a member of, oldest membership first — so the first is your personal team, the one made when you signed up.', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.array(Mine).meta({ + description: 'Your teams', + example: [{ ...Examples.Team, role: 'owner' }] + }) + ) + } + }, + description: 'Your teams' + }, + 401: ErrorResponses[401] + } + }), + async (c) => { + const memberships = await Member.listByUser(Actor.userID); + const teams = await Promise.all( + memberships.map(async (m) => { + const team = await Team.fromID(m.teamId); + return team ? { ...Team.serialize(team), role: m.role } : null; + }) + ); + 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({ + tags: ['Team'], + summary: 'Rename a team', + description: + 'Change the name, the slug, or both. Owners and admins only. A slug another team holds is a 409.', + responses: { + 200: { + content: { 'application/json': { schema: Result(Team.Info) } }, + description: 'The team, renamed' + }, + 400: ErrorResponses[400], + 401: ErrorResponses[401], + 403: ErrorResponses[403], + 404: ErrorResponses[404], + 409: ErrorResponses[409] + } + }), + validator( + 'json', + z + .object({ + name: Team.Name.optional().meta({ description: 'Display name' }), + slug: Team.Slug.optional().meta({ description: 'URL-friendly unique slug' }) + }) + .refine((b) => b.name !== undefined || b.slug !== undefined, { + message: 'Give a name, a slug, or both' + }) + ), + async (c) => { + const body = c.req.valid('json'); + const { member, team } = await membership(c.req.param('id')); + if (member.role !== 'owner' && member.role !== 'admin') { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, + 'Only an owner or an admin can rename a team' + ); + } + try { + const renamed = await Team.rename({ id: team.id, ...body }); + return c.json({ data: renamed! }); + } catch (error) { + if (error instanceof Team.SlugTaken) throw slugTaken(); + throw error; + } + } + ) + .get( + '/:id/members', + describeRoute({ + tags: ['Team'], + summary: 'Who is on a team', + description: 'Members of a team you are on, oldest first, with their name and address.', + responses: { + 200: { + content: { 'application/json': { schema: Result(z.array(Person)) } }, + description: 'The members' + }, + 401: ErrorResponses[401], + 404: ErrorResponses[404] + } + }), + async (c) => { + const { team } = await membership(c.req.param('id')); + return c.json({ data: await Member.listPeople(team.id) }); + } + ); +} diff --git a/apps/api/test/team.test.ts b/apps/api/test/team.test.ts new file mode 100644 index 00000000..dee13a10 --- /dev/null +++ b/apps/api/test/team.test.ts @@ -0,0 +1,157 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { AccessToken } from '@nestri/core/access-token/index'; +import { Fixtures } from '@nestri/core/db/fixtures'; +import { testDb } from '@nestri/core/db/test'; +import { Identifier } from '@nestri/core/id'; +import { Member } from '@nestri/core/team/member'; + +import { app } from '../app/index'; +import './setup'; + +const sql = testDb(); +const createdUserIds: string[] = []; + +async function person(label: string) { + const owner = await Fixtures.owner(label); + createdUserIds.push(owner.userId); + const pat = await AccessToken.create({ + id: Identifier.ascending('accessToken'), + ownerUserId: owner.userId, + teamId: null, + name: label + }); + return { + ...owner, + headers: { authorization: `Bearer ${pat.token}`, 'content-type': 'application/json' } + }; +} + +function rename(who: { headers: Record }, teamId: string, body: unknown) { + return app.request(`/team/${teamId}`, { + method: 'PATCH', + headers: who.headers, + body: JSON.stringify(body) + }); +} + +/** Unique per run, so a slug from a previous run never collides. */ +const slug = (s: string) => `${s}-${Identifier.ascending('team').slice(-8).toLowerCase()}`; + +afterAll(async () => { + if (createdUserIds.length > 0) { + await sql`delete from "user" where id in ${sql(createdUserIds)}`; + } +}); + +describe('GET /team', () => { + test('lists your personal team, with your role on it', async () => { + const me = await person('team-list'); + const res = await app.request('/team', { headers: me.headers }); + expect(res.status).toBe(200); + const { data } = (await res.json()) as any; + expect(data).toHaveLength(1); + expect(data[0]).toMatchObject({ id: me.teamId, role: 'owner' }); + }); +}); + +describe('PATCH /team/:id', () => { + test('an owner renames name and slug', async () => { + const me = await person('team-rename'); + const s = slug('renamed'); + const res = await rename(me, me.teamId, { name: 'Renamed', slug: s }); + expect(res.status).toBe(200); + expect(((await res.json()) as any).data).toMatchObject({ name: 'Renamed', slug: s }); + }); + + test("someone else's team is a 404, not a 403", async () => { + const me = await person('team-probe'); + const them = await person('team-probed'); + const res = await rename(me, them.teamId, { name: 'Mine now' }); + expect(res.status).toBe(404); + }); + + test('a plain member cannot rename', async () => { + const owner = await person('team-owner'); + const member = await person('team-member'); + await Member.create({ + id: Identifier.ascending('teamMember'), + teamId: owner.teamId, + userId: member.userId, + role: 'member' + }); + const res = await rename(member, owner.teamId, { name: 'Coup' }); + expect(res.status).toBe(403); + }); + + test('a taken slug is a 409 on the slug', async () => { + const a = await person('team-slug-a'); + const b = await person('team-slug-b'); + const s = slug('taken'); + expect((await rename(a, a.teamId, { slug: s })).status).toBe(200); + const res = await rename(b, b.teamId, { slug: s }); + expect(res.status).toBe(409); + expect(((await res.json()) as any).param).toBe('slug'); + }); + + test('a malformed slug is refused before it reaches the database', async () => { + const me = await person('team-bad-slug'); + expect((await rename(me, me.teamId, { slug: 'Not A Slug' })).status).toBe(400); + expect((await rename(me, me.teamId, {})).status).toBe(400); + }); +}); + +describe('GET /team/:id/members', () => { + test('names the people on your team, and nobody can list a team they are not on', async () => { + const me = await person('team-people'); + const other = await person('team-people-other'); + const res = await app.request(`/team/${me.teamId}/members`, { headers: me.headers }); + expect(res.status).toBe(200); + const { data } = (await res.json()) as any; + expect(data).toEqual([ + expect.objectContaining({ userId: me.userId, role: 'owner', name: 'team-people' }) + ]); + const refused = await app.request(`/team/${other.teamId}/members`, { headers: me.headers }); + expect(refused.status).toBe(404); + }); +}); + +describe('GET /box', () => { + test('a person with no boxes gets an empty list', async () => { + const me = await person('box-none'); + const res = await app.request('/box', { headers: me.headers }); + expect(res.status).toBe(200); + 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 4c4cc381..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; @@ -230,6 +230,127 @@ export namespace Team { } ); + /** + * What a team's name and slug may be. The slug is the team's address, so + * it is what `createPersonal` makes: lowercase letters, digits and single + * hyphens. Exported so a client can check before it asks, rather than + * 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') + .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 { + constructor(public slug: string) { + super(`The slug ${slug} is taken`); + } + } + + /** + * Change a team's name, its slug, or both. + * + * Who may do this is the caller's business (the route checks the role); + * this only refuses a slug that is taken. The check before the write gives + * the ordinary answer, and the unique index is what actually holds when two + * people race for the same one. + */ + export const rename = fn( + z.object({ id: Info.shape.id, name: Name.optional(), slug: Slug.optional() }), + async (input) => { + if (input.slug) { + const holder = await fromSlug(input.slug); + if (holder && holder.id !== input.id) throw new SlugTaken(input.slug); + } + try { + return await Database.use(async (tx) => + tx + .update(TeamTable) + .set({ + ...(input.name !== undefined && { name: input.name }), + ...(input.slug !== undefined && { slug: input.slug }) + }) + .where(and(eq(TeamTable.id, input.id), isNull(TeamTable.timeDeleted))) + .returning() + .then((rows) => { + const row = rows.at(0); + return row ? serialize(row) : null; + }) + ); + } catch (error) { + const code = (error as { code?: string; cause?: { code?: string } }) ?? {}; + if (input.slug && (code.code === '23505' || code.cause?.code === '23505')) { + throw new SlugTaken(input.slug); + } + throw error; + } + } + ); + export function serialize(input: typeof TeamTable.$inferSelect): z.infer { return { id: input.id, diff --git a/packages/core/src/team/member.ts b/packages/core/src/team/member.ts index f918921f..f0b15c2b 100644 --- a/packages/core/src/team/member.ts +++ b/packages/core/src/team/member.ts @@ -4,6 +4,7 @@ import z from 'zod'; import { Database } from '../db/index.js'; import { Examples } from '../examples.js'; import { fn } from '../fn.js'; +import { UserTable } from '../user/user.sql.js'; import { TeamMemberRole, TeamMemberTable } from './member.sql.js'; export namespace Member { @@ -75,6 +76,33 @@ export namespace Member { }); }); + /** + * A team's members with who they are, for a page that lists them. One + * join rather than a lookup per member; a deleted user is not a member. + */ + export const listPeople = fn(Info.shape.teamId, async (teamId) => { + return Database.use(async (tx) => { + return tx + .select({ + id: TeamMemberTable.id, + userId: TeamMemberTable.userId, + role: TeamMemberTable.role, + name: UserTable.name, + email: UserTable.email + }) + .from(TeamMemberTable) + .innerJoin(UserTable, eq(UserTable.id, TeamMemberTable.userId)) + .where( + and( + eq(TeamMemberTable.teamId, teamId), + isNull(TeamMemberTable.timeDeleted), + isNull(UserTable.timeDeleted) + ) + ) + .orderBy(TeamMemberTable.timeCreated); + }); + }); + export const listByUser = fn(Info.shape.userId, async (userId) => { return Database.use(async (tx) => { return tx