Merge branch 'feat/team-and-box-reads' into dev

This commit is contained in:
Wanjohi
2026-10-07 08:49:07 +03:00
6 changed files with 548 additions and 1 deletions
+4
View File
@@ -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)
+39
View File
@@ -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) })
);
}
+198
View File
@@ -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) });
}
);
}
+157
View File
@@ -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<string, string> }, 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);
});
});
+122 -1
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;
@@ -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/<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')
.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<typeof Info> {
return {
id: input.id,
+28
View File
@@ -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