feat(core): one-time install tokens that register a host to a team

Installing on a host should be a command a person pastes, not a user
session copied onto a machine. A token is issued for a team, spent by its
first use, expires after an hour and is stored only as a digest, because
it travels in that command and so lands in shell history.

Redeeming is one conditional update inside the registration transaction:
concurrent redemptions of one token register exactly one machine, and a
registration that fails leaves the token unspent.
This commit is contained in:
Wanjohi
2026-09-28 08:36:09 +03:00
parent 451b495de6
commit f691b56c0c
7 changed files with 3910 additions and 124 deletions
@@ -0,0 +1,18 @@
CREATE TABLE "install_token" (
"id" char(30) PRIMARY KEY NOT NULL,
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"team_id" char(30) NOT NULL,
"created_by_user_id" char(30) NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"redeemed_at" timestamp with time zone,
"machine_id" char(30)
);
--> statement-breakpoint
ALTER TABLE "install_token" ADD CONSTRAINT "install_token_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "install_token" ADD CONSTRAINT "install_token_created_by_user_id_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "install_token" ADD CONSTRAINT "install_token_machine_id_machine_id_fk" FOREIGN KEY ("machine_id") REFERENCES "public"."machine"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "install_token_hash_unique" ON "install_token" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "install_token_team_idx" ON "install_token" USING btree ("team_id");
File diff suppressed because it is too large Load Diff
+131 -124
View File
@@ -1,125 +1,132 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1784801002476,
"tag": "0000_quick_dark_phoenix",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1785312635128,
"tag": "0001_opposite_senator_kelly",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1785379712946,
"tag": "0002_light_mesmero",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1785382013687,
"tag": "0003_many_pyro",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1785588097470,
"tag": "0004_remove_user_download_add_game_download",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1785909838801,
"tag": "0005_flaky_may_parker",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1786205230097,
"tag": "0006_waitlist_verification_game_aliases",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1788460224524,
"tag": "0007_box_session_team_notnull",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1788547836146,
"tag": "0008_session_one_active_run_per_box",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1788555252186,
"tag": "0009_email_is_the_root_identity",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1788590292860,
"tag": "0010_device_authorization_grant",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1788607804606,
"tag": "0011_auth_state_in_postgres",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1788691753961,
"tag": "0012_steam_enrolment_without_a_token",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1788725541386,
"tag": "0013_machine_endpoint_id",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1789680491539,
"tag": "0014_machine_public_label",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1789762221718,
"tag": "0015_organisation_owns_fleet_hardware",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1789765075037,
"tag": "0016_burn_counters_and_rate_segments",
"breakpoints": true
}
]
}
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1784801002476,
"tag": "0000_quick_dark_phoenix",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1785312635128,
"tag": "0001_opposite_senator_kelly",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1785379712946,
"tag": "0002_light_mesmero",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1785382013687,
"tag": "0003_many_pyro",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1785588097470,
"tag": "0004_remove_user_download_add_game_download",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1785909838801,
"tag": "0005_flaky_may_parker",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1786205230097,
"tag": "0006_waitlist_verification_game_aliases",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1788460224524,
"tag": "0007_box_session_team_notnull",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1788547836146,
"tag": "0008_session_one_active_run_per_box",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1788555252186,
"tag": "0009_email_is_the_root_identity",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1788590292860,
"tag": "0010_device_authorization_grant",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1788607804606,
"tag": "0011_auth_state_in_postgres",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1788691753961,
"tag": "0012_steam_enrolment_without_a_token",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1788725541386,
"tag": "0013_machine_endpoint_id",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1789680491539,
"tag": "0014_machine_public_label",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1789762221718,
"tag": "0015_organisation_owns_fleet_hardware",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1789765075037,
"tag": "0016_burn_counters_and_rate_segments",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1790573670492,
"tag": "0017_install_token",
"breakpoints": true
}
]
}
+1
View File
@@ -13,6 +13,7 @@ export namespace Identifier {
userFingerprint: 'ufp',
pairingCode: 'pai',
machine: 'mch',
installToken: 'mit',
box: 'box',
session: 'ses',
accessToken: 'pat',
@@ -0,0 +1,40 @@
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid, utc } from '../db/types.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.
*
* 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
* lands in shell history: that is why it is single-use, expires in minutes,
* and is stored only as a digest.
*/
export const InstallTokenTable = pgTable(
'install_token',
{
...id,
...timestamps,
teamId: ulid('team_id')
.notNull()
.references(() => TeamTable.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')
.notNull()
.references(() => UserTable.id, { onDelete: 'cascade' }),
tokenHash: text('token_hash').notNull(),
expiresAt: utc('expires_at').notNull(),
redeemedAt: utc('redeemed_at'),
// The machine it made. Null until redeemed; kept afterwards so a support
// conversation can say which command produced which host.
machineId: ulid('machine_id').references(() => MachineTable.id, { onDelete: 'set null' })
},
(t) => [
uniqueIndex('install_token_hash_unique').on(t.tokenHash),
index('install_token_team_idx').on(t.teamId)
]
);
@@ -0,0 +1,68 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { Fixtures } from '../db/fixtures.js';
import { testDb } from '../db/test.js';
import { InstallToken } from './install-token.js';
const sql = testDb();
const createdUserIds: string[] = [];
async function newOwner(label: string) {
const o = await Fixtures.owner(label);
createdUserIds.push(o.userId);
return o;
}
afterAll(async () => {
if (createdUserIds.length > 0) {
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
createdUserIds.length = 0;
}
});
describe('Install tokens', () => {
test('a token registers one machine to its team, owned by whoever issued it', async () => {
const owner = await newOwner('nit-ok');
const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId });
expect(token.startsWith('nit_')).toBe(true);
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}`;
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}`;
expect(tok[0]!.token_hash).not.toBe(token);
expect(tok[0]!.machine_id).toBe(registered!.machineId);
});
test('a token is spent by its first use', async () => {
const owner = await newOwner('nit-once');
const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId });
expect(await InstallToken.redeem({ token, label: 'a' })).not.toBeNull();
expect(await InstallToken.redeem({ token, label: 'b' })).toBeNull();
});
test('two hosts racing one token register exactly one machine', async () => {
const owner = await newOwner('nit-race');
const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId });
const results = await Promise.all(
Array.from({ length: 5 }, (_, i) => InstallToken.redeem({ token, label: `h${i}` }))
);
expect(results.filter(Boolean)).toHaveLength(1);
const machines = await sql`select id from machine where team_id = ${owner.teamId}`;
expect(machines).toHaveLength(1);
});
test('expired and unknown tokens are refused the same way', async () => {
const owner = await newOwner('nit-expired');
const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId });
await sql`update install_token set expires_at = now() - interval '1 minute' where team_id = ${owner.teamId}`;
expect(await InstallToken.redeem({ token, label: 'late' })).toBeNull();
expect(await InstallToken.redeem({ token: 'nit_nosuchtoken', label: 'x' })).toBeNull();
});
});
+108
View File
@@ -0,0 +1,108 @@
import { randomBytes } from 'node:crypto';
import { and, eq, gt, isNull, sql } from 'drizzle-orm';
import { z } from 'zod';
import { Database } from '../db/index.js';
import { fn } from '../fn.js';
import { Identifier } from '../id.js';
import { Machine } from './index.js';
import { InstallTokenTable } from './install-token.sql.js';
export namespace InstallToken {
/** 128 bits: guessing one inside its lifetime is not a strategy. */
const TOKEN_BYTES = 16;
/**
* How long a token lives. Long enough to copy a command, open a terminal on
* another machine and run it; short enough that one found in shell history
* tomorrow is worthless.
*/
export const TTL_MINUTES = 60;
function generate(): string {
return `nit_${randomBytes(TOKEN_BYTES).toString('base64url')}`;
}
async function digest(token: string): Promise<string> {
const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token));
return Array.from(new Uint8Array(d))
.map((b) => b.toString(16).padStart(2, '0'))
.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 };
});
/**
* Spend a token and register the machine it was issued for.
*
* Spending is one conditional UPDATE, so two hosts presenting the same token
* at the same instant cannot both win — the loser sees no row and is refused.
* Refusal is `null` for every reason (unknown, expired, already used), so the
* answer teaches a caller nothing about which tokens exist.
*/
export const redeem = fn(
z.object({ token: z.string(), label: z.string().min(1).max(64) }),
async (input) => {
const tokenHash = await digest(input.token);
// One transaction, so a registration that fails leaves the token
// unspent rather than burning the only copy the person has.
return Database.transaction(async () => {
const spent = await Database.use(async (tx) =>
tx
.update(InstallTokenTable)
.set({ redeemedAt: sql`now()` })
.where(
and(
eq(InstallTokenTable.tokenHash, tokenHash),
isNull(InstallTokenTable.redeemedAt),
isNull(InstallTokenTable.timeDeleted),
gt(InstallTokenTable.expiresAt, sql`now()`)
)
)
.returning({
id: InstallTokenTable.id,
teamId: InstallTokenTable.teamId,
userId: InstallTokenTable.createdByUserId
})
.then((rows) => rows.at(0) ?? null)
);
if (!spent) return null;
const machine = await Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: spent.userId,
teamId: spent.teamId,
label: input.label
});
await Database.use(async (tx) =>
tx
.update(InstallTokenTable)
.set({ machineId: machine.id })
.where(eq(InstallTokenTable.id, spent.id))
);
return { machineId: machine.id, slug: machine.slug, secret: machine.secret };
});
}
);
}