feat(machine): an install command that adds a host to the fleet

An install token can now name an organisation instead of a team, and the
host it registers belongs to that organisation's fleet. Only members of
the organisation may mint one, since fleet hardware is rented to other
people. A token for both is refused, by the route and by a check
constraint, as it already is for a machine.
This commit is contained in:
Wanjohi
2026-10-07 09:42:51 +03:00
parent 47f8ff6f61
commit d92bc13ed9
8 changed files with 3892 additions and 29 deletions
+30 -2
View File
@@ -175,7 +175,7 @@ export namespace MachineApi {
tags: ['Machine'],
summary: 'Issue an install token',
description:
'Mint a one-time token that registers one host to a team when the installer presents it. It expires after an hour and is spent by its first use, because it travels in a command a person pastes and so ends up in shell history.',
'Mint a one-time token that registers one host to a team — or, with `organisationId`, to your organisation’s fleet — when the installer presents it. It expires after an hour and is spent by its first use, because it travels in a command a person pastes and so ends up in shell history.',
responses: {
200: {
content: {
@@ -201,11 +201,15 @@ export namespace MachineApi {
z.object({
teamId: z.string().optional().meta({
description: 'Team the host will belong to. Defaults to the caller\u2019s personal team'
}),
organisationId: z.string().optional().meta({
description:
'Register the host to this organisation’s fleet instead of to a team. You must belong to the organisation'
})
})
),
async (c) => {
const { teamId } = c.req.valid('json');
const { teamId, organisationId } = c.req.valid('json');
const actor = Actor.use();
if (actor.type !== 'user' && actor.type !== 'member') {
throw new VisibleError(
@@ -215,6 +219,30 @@ export namespace MachineApi {
);
}
if (organisationId) {
if (teamId) {
throw new VisibleError(
'validation',
ErrorCodes.Validation.INVALID_PARAMETER,
'A host belongs to a team or to an organisation, not both',
'organisationId'
);
}
// Fleet hardware is rented to strangers, so only the
// organisation's own people may add to it.
if (!(await Organisation.isMember(Actor.userID, organisationId))) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'You do not belong to that organisation'
);
}
const issued = await InstallToken.create({ organisationId, userId: Actor.userID });
return c.json({
data: { token: issued.token, expiresAt: issued.expiresAt.toISOString() }
});
}
// Same resolution and the same membership rule as `/register`: a
// token is a deferred registration, so it may not reach a team the
// caller could not register into directly.
+74
View File
@@ -61,6 +61,7 @@ afterAll(async () => {
await sql`delete from session where box_id in (select id from box where user_id = any(${createdUserIds}))`;
await sql`delete from box where user_id = any(${createdUserIds})`;
await sql`delete from game where id = any(${createdGameIds})`;
await sql`delete from install_token where created_by_user_id = any(${createdUserIds})`;
await sql`delete from machine where owner_user_id = any(${createdUserIds})`;
await sql`delete from organisation where id = any(${createdOrgIds})`;
await sql`delete from "user" where id = any(${createdUserIds})`;
@@ -206,3 +207,76 @@ describe('Billing a run on the fleet', () => {
expect(closed[0]!.ended_at).not.toBeNull();
});
});
describe('An install token for the fleet', () => {
async function member(domain: string, organisationId: string) {
const userId = Identifier.ascending('user');
await sql`insert into "user" (id, name, email, email_verified) values (${userId}, 'staff', ${'staff@' + domain}, true)`;
createdUserIds.push(userId);
const pat = await AccessToken.create({
id: Identifier.ascending('accessToken'),
ownerUserId: userId,
teamId: null,
name: 'staff'
});
return {
userId,
organisationId,
headers: { authorization: `Bearer ${pat.token}`, 'content-type': 'application/json' }
};
}
async function org() {
const id = Identifier.ascending('organisation');
const domain = `${id.slice(-10).toLowerCase()}.example.test`;
await sql`insert into organisation (id, name, slug, domain, domain_verified)
values (${id}, 'Fleet', ${'fleet-' + id.slice(-10).toLowerCase()}, ${domain}, true)`;
createdOrgIds.push(id);
return { id, domain };
}
test('a member mints one, and the host it registers belongs to the fleet', async () => {
const o = await org();
const staff = await member(o.domain, o.id);
const minted = await app.request('/machine/install-token', {
method: 'POST',
headers: staff.headers,
body: JSON.stringify({ organisationId: o.id })
});
expect(minted.status).toBe(200);
const { token } = ((await minted.json()) as any).data;
const installed = await app.request('/machine/install', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token, label: 'fleet-host' })
});
expect(installed.status).toBe(200);
const { machineId } = ((await installed.json()) as any).data;
const machine = await Machine.fromID(machineId);
expect(machine?.organisationId).toBe(o.id);
expect(machine?.teamId).toBeNull();
});
test('somebody outside the organisation is refused', async () => {
const o = await org();
const outsider = await person('fleet-outsider');
const res = await app.request('/machine/install-token', {
method: 'POST',
headers: outsider.headers,
body: JSON.stringify({ organisationId: o.id })
});
expect(res.status).toBe(403);
});
test('naming a team and an organisation is a 400', async () => {
const o = await org();
const staff = await member(o.domain, o.id);
const res = await app.request('/machine/install-token', {
method: 'POST',
headers: staff.headers,
body: JSON.stringify({ organisationId: o.id, teamId: 'tem_whatever' })
});
expect(res.status).toBe(400);
});
});