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
@@ -0,0 +1,4 @@
ALTER TABLE "install_token" ALTER COLUMN "team_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "install_token" ADD COLUMN "organisation_id" char(30);--> statement-breakpoint
ALTER TABLE "install_token" ADD CONSTRAINT "install_token_organisation_id_organisation_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisation"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "install_token" ADD CONSTRAINT "install_token_one_owner" CHECK (("install_token"."team_id" is null) != ("install_token"."organisation_id" is null));
File diff suppressed because it is too large Load Diff
@@ -134,6 +134,13 @@
"when": 1791354383696,
"tag": "0018_free_weekend_trial",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1791355334692,
"tag": "0019_install_token_for_a_fleet",
"breakpoints": true
}
]
}
+11 -6
View File
@@ -1,12 +1,15 @@
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { check, index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid, utc } from '../db/types.js';
import { OrganisationTable } from '../organisation/organisation.sql.js';
import { TeamTable } from '../team/team.sql.js';
import { UserTable } from '../user/user.sql.js';
import { MachineTable } from './machine.sql.js';
/**
* A one-time credential that registers exactly one machine to one team.
* A one-time credential that registers exactly one machine to one team, or to
* one organisation's fleet — never both, by the same rule a machine has.
*
* It exists so that installing on a host is a command a person pastes, rather
* than a user session copied onto a machine. It travels in that command, so it
@@ -18,9 +21,10 @@ export const InstallTokenTable = pgTable(
{
...id,
...timestamps,
teamId: ulid('team_id')
.notNull()
.references(() => TeamTable.id, { onDelete: 'cascade' }),
teamId: ulid('team_id').references(() => TeamTable.id, { onDelete: 'cascade' }),
organisationId: ulid('organisation_id').references(() => OrganisationTable.id, {
onDelete: 'cascade'
}),
// Who asked for it. They become the machine's owner, as they would have
// by registering it with their own session.
createdByUserId: ulid('created_by_user_id')
@@ -35,6 +39,7 @@ export const InstallTokenTable = pgTable(
},
(t) => [
uniqueIndex('install_token_hash_unique').on(t.tokenHash),
index('install_token_team_idx').on(t.teamId)
index('install_token_team_idx').on(t.teamId),
check('install_token_one_owner', sql`(${t.teamId} is null) != (${t.organisationId} is null)`)
]
);
@@ -30,12 +30,14 @@ describe('Install tokens', () => {
const registered = await InstallToken.redeem({ token, label: 'host' });
expect(registered?.secret.startsWith('msk_')).toBe(true);
const rows = await sql`select team_id, owner_user_id from machine where id = ${registered!.machineId}`;
const rows =
await sql`select team_id, owner_user_id from machine where id = ${registered!.machineId}`;
expect(rows[0]!.team_id).toBe(owner.teamId);
expect(rows[0]!.owner_user_id).toBe(owner.userId);
// Only the digest is kept, and the row records what it produced.
const tok = await sql`select token_hash, machine_id from install_token where team_id = ${owner.teamId}`;
const tok =
await sql`select token_hash, machine_id from install_token where team_id = ${owner.teamId}`;
expect(tok[0]!.token_hash).not.toBe(token);
expect(tok[0]!.machine_id).toBe(registered!.machineId);
});
+36 -19
View File
@@ -31,25 +31,40 @@ export namespace InstallToken {
.join('');
}
/** Issue a token for `teamId`. The caller has already checked membership. */
export const create = fn(z.object({ teamId: z.string(), userId: z.string() }), async (input) => {
const token = generate();
const tokenHash = await digest(token);
const expiresAt = await Database.use(async (tx) =>
tx
.insert(InstallTokenTable)
.values({
id: Identifier.ascending('installToken'),
teamId: input.teamId,
createdByUserId: input.userId,
tokenHash,
expiresAt: sql`now() + interval '${sql.raw(String(TTL_MINUTES))} minutes'`
})
.returning({ expiresAt: InstallTokenTable.expiresAt })
.then((rows) => rows[0]!.expiresAt)
);
return { token, expiresAt };
});
/**
* Issue a token for a team, or for an organisation's fleet. The caller has
* already checked membership of whichever it names.
*/
export const create = fn(
z
.object({
teamId: z.string().optional(),
organisationId: z.string().optional(),
userId: z.string()
})
.refine((v) => !v.teamId !== !v.organisationId, {
message: 'A token is for a team or for an organisation, and not both'
}),
async (input) => {
const token = generate();
const tokenHash = await digest(token);
const expiresAt = await Database.use(async (tx) =>
tx
.insert(InstallTokenTable)
.values({
id: Identifier.ascending('installToken'),
teamId: input.teamId ?? null,
organisationId: input.organisationId ?? null,
createdByUserId: input.userId,
tokenHash,
expiresAt: sql`now() + interval '${sql.raw(String(TTL_MINUTES))} minutes'`
})
.returning({ expiresAt: InstallTokenTable.expiresAt })
.then((rows) => rows[0]!.expiresAt)
);
return { token, expiresAt };
}
);
/**
* Spend a token and register the machine it was issued for.
@@ -81,6 +96,7 @@ export namespace InstallToken {
.returning({
id: InstallTokenTable.id,
teamId: InstallTokenTable.teamId,
organisationId: InstallTokenTable.organisationId,
userId: InstallTokenTable.createdByUserId
})
.then((rows) => rows.at(0) ?? null)
@@ -91,6 +107,7 @@ export namespace InstallToken {
id: Identifier.ascending('machine'),
ownerUserId: spent.userId,
teamId: spent.teamId,
organisationId: spent.organisationId,
label: input.label
});