Files
netris-nestri/packages/core/src/machine/index.ts
Wanjohi 15631f5d25 feat(core,api): an organisation owns hardware, and a domain says who belongs
Two kinds of machine were modelled as one. A host somebody brings is theirs,
reached through a team, and should die with their account. A host bought to
serve other people's workloads is none of those things — and there was nowhere
to put it, so it had to be registered under an employee's personal team, where
it was that person's property and their account going away took it with them.

Ownership becomes an either/or. A machine names a team or an organisation,
exactly one, enforced by a check constraint rather than by convention: both
null is a host nothing can bill, and both set is two answers to "whose is
this?" where whichever join a query happens to take decides who pays. Hardware
an organisation owns has no team and no person at all, which is the point.

The organisation is deliberately not a billing subject and has no plan columns.
It says who owns the metal; a team pays for what it uses either way.

Membership is derived from a verified email domain rather than stored. An
address is already the root identity, so a second record of who belongs where
is a second answer that can disagree with the first — and deriving it means
signing in with a personal address still gets an ordinary personal account,
which is what lets one person hold a company account and use the consumer
product. Nothing is granted on an unverified domain or an unverified address:
either one is a string somebody typed.

Entitlement on fleet hardware refuses everyone for now, with a reason that says
so. What grants a run on metered hardware is a plan, and there is nothing to
ask yet, so it fails closed rather than giving the expensive case away. The
branch is written out so the plan check has one obvious place to land.

Routes are read-only, and nothing seeds an organisation. Creating one grants
membership to everyone who can receive mail at a domain, so it is an operator
action against the database — a migration that inserted one would insert it
into every deployment, including ones we have nothing to do with. See
docs/deploy.md.
2026-09-18 23:16:07 +03:00

482 lines
17 KiB
TypeScript

import { randomBytes } from 'node:crypto';
import { and, eq, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { ErrorCodes, VisibleError } from '../error.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { Member } from '../team/member.js';
import { MachineTable } from './machine.sql.js';
import { Slug } from './slug.js';
/**
* Registered host identity.
*
* A box trades an owner-supplied token for an assigned id and a secret, then
* authenticates as itself. The alternative — deriving an id from
* `/etc/machine-id` or a hardware fingerprint — was rejected: self-hosted
* boxes mean the operator is not automatically trusted, and every such input
* is operator-editable, so uniqueness would rest on nobody choosing to lie.
*/
export namespace Machine {
/** Length in bytes before base64url encoding. */
const SECRET_BYTES = 32;
/**
* A host's endpoint id: 32 bytes of public key, lowercase hex.
*
* Checked for shape and nothing else. What it addresses is meaningless
* here — this is a string the control plane stores and hands back — so the
* only thing worth refusing is a value that cannot possibly be one, which
* is what keeps a truncated or double-encoded id from being written and
* then failing far away, at whoever tries to dial it.
*/
export const EndpointId = z.string().regex(/^[0-9a-f]{64}$/, {
message: 'An endpoint id is 64 lowercase hex characters'
});
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the machine',
example: Examples.Machine.id
}),
ownerUserId: z.string().nullable().meta({
description:
'The user who registered this machine, or null for hardware an organisation owns outright — a company card is nobody\u2019s personal property',
example: Examples.Machine.ownerUserId
}),
teamId: z.string().nullable().meta({
description:
'The team that owns this hardware, for a host somebody brought. Null exactly when organisationId is set',
example: Examples.Machine.teamId
}),
organisationId: z.string().nullable().meta({
description:
'The organisation that owns this hardware outright, for a host serving workloads rather than its owner\u2019s. Null exactly when teamId is set',
example: null
}),
label: z.string().meta({
description: 'Human-readable name for the box',
example: Examples.Machine.label
}),
slug: z.string().meta({
description:
'The name this host is reached at, as the first label of its hostname. Minted here, unique across the fleet, and deliberately not the id \u2014 an id is monotonic, cannot be rotated, and would travel into every redirect URL the sign-in produces',
example: Examples.Machine.slug
}),
lastSeen: z.iso.datetime().optional().nullable().meta({
description: 'When this machine last authenticated',
example: Examples.Machine.lastSeen
}),
endpointId: EndpointId.optional().nullable().meta({
description:
'Where this host can be reached, as its own endpoint id. Null until the host has reported one — it holds the secret half of this identity, so it is the only thing that can say what the public half is',
example: Examples.Machine.endpointId
})
})
.meta({
ref: 'Machine',
description: 'A registered nessh host',
example: Examples.Machine
});
export type Info = z.infer<typeof Info>;
function generateSecret(): string {
return `msk_${randomBytes(SECRET_BYTES).toString('base64url')}`;
}
async function hashSecret(secret: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/** Postgres refusing a second row for the same key. */
function isUniqueViolation(err: unknown): boolean {
const e = err as { code?: string; cause?: { code?: string } };
return e?.code === '23505' || e?.cause?.code === '23505';
}
/** Length-independent, content-constant comparison of two hex digests. */
function secureEquals(a: string, b: string): boolean {
if (a.length !== b.length) {
return false;
}
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
/**
* Register a box. The secret is returned here and nowhere else — it is
* stored only as a digest, so a lost secret means re-registering rather
* than looking it up.
*/
export const register = fn(
Info.pick({ id: true, ownerUserId: true, teamId: true, label: true })
.extend({ organisationId: Info.shape.organisationId.optional() })
.refine((v) => (v.teamId === null) !== ((v.organisationId ?? null) === null), {
message: 'A machine belongs to a team or to an organisation, and not to both'
}),
async (input) => {
const secret = generateSecret();
const secretHash = await hashSecret(secret);
// The name space holds tens of millions, so a collision is rare
// enough that retrying is cheaper than checking first -- and a
// check-then-insert would be wrong as well as slower, because two
// registrations in the same instant both read "free" and both write.
// The unique index is the thing that actually decides.
for (let attempt = 0; attempt < SLUG_ATTEMPTS; attempt++) {
const slug = Slug.generate();
try {
await Database.use(async (tx) => {
await tx.insert(MachineTable).values({
id: input.id,
ownerUserId: input.ownerUserId,
teamId: input.teamId,
organisationId: input.organisationId ?? null,
label: input.label,
slug,
secretHash,
lastSeen: null
});
});
return { id: input.id, secret, slug };
} catch (err) {
if (!isUniqueViolation(err)) {
throw err;
}
// Only a name collision is worth another go. A duplicate id
// or secret means something is wrong that a new name cannot
// fix, and retrying would bury it.
if (!isSlugViolation(err)) {
throw err;
}
}
}
throw new VisibleError(
'internal',
ErrorCodes.Server.INTERNAL_ERROR,
'Could not mint an unused hostname for this machine'
);
}
);
/** How many names to try before giving up. See `register`. */
const SLUG_ATTEMPTS = 5;
/** Whether the constraint Postgres refused on was the hostname. */
function isSlugViolation(err: unknown): boolean {
const text = String(
(err as { constraint?: string })?.constraint ??
(err as { cause?: { constraint?: string } })?.cause?.constraint ??
(err as Error)?.message ??
''
);
return text.includes('machine_slug_unique');
}
/** Resolve a hostname label to the machine it names. */
export const fromSlug = fn(Info.shape.slug, async (slug) => {
return Database.use(async (tx) => {
return tx
.select()
.from(MachineTable)
.where(and(eq(MachineTable.slug, slug), isNull(MachineTable.timeDeleted)))
.then((rows) => {
const row = rows.at(0);
return row ? serialize(row) : null;
});
});
});
/**
* Resolve credentials to a machine, or `null`. Looks the row up by id and
* then compares digests, so a wrong id and a wrong secret are refused the
* same way and neither reveals which half was wrong.
*/
export const authenticate = fn(
z.object({ id: z.string(), secret: z.string() }),
async (input) => {
return Database.use(async (tx) => {
return tx
.select()
.from(MachineTable)
.where(and(eq(MachineTable.id, input.id), isNull(MachineTable.timeDeleted)))
.then(async (rows) => {
const row = rows.at(0);
if (!row) {
return null;
}
if (!secureEquals(row.secretHash, await hashSecret(input.secret))) {
return null;
}
// Serialized here, so `secretHash` never leaves this
// function even in memory — the caller cannot leak what
// it was never handed.
return serialize(row);
});
});
}
);
/**
* Move a host to a different team.
*
* There is no "out of a team" any more: `teamId` is notNull, so a host always
* belongs to exactly one and the single-operator case is a team of one rather
* than a null. What used to be *unscope* is now *move to my personal team*,
* which the caller names explicitly. ref(d-0048)
*
* Scoped to the owner in the query itself, so a machine belonging to
* someone else is a miss rather than a permission check that could be
* forgotten. Membership of the *target* team is the caller's to verify —
* this function knows about machines, not about who belongs where.
*
* Deliberately not ownership transfer. Scoping keeps the same owner and
* should be easy; handing a box to a different person should not be, and
* is left to re-registration until renting makes it worth building.
*/
export const setTeam = fn(
Info.pick({ id: true }).extend({ ownerUserId: z.string(), teamId: z.string() }),
async (input) => {
return Database.use(async (tx) => {
return tx
.update(MachineTable)
.set({ teamId: input.teamId })
.where(
and(
eq(MachineTable.id, input.id),
eq(MachineTable.ownerUserId, input.ownerUserId),
isNull(MachineTable.timeDeleted)
)
)
.returning()
.then((rows) => {
const row = rows.at(0);
return row ? serialize(row) : null;
});
});
}
);
/**
* How often a host should say it is alive, in seconds.
*
* Returned to the host on every heartbeat rather than compiled into it: the
* cadence is the control plane's business, and a fleet whose interval can
* only be changed by shipping a new agent is a fleet whose interval never
* changes. Thirty seconds is a placeholder — it is short enough that a dead
* host is noticed within a session's setup, and long enough to be free.
*/
export const HEARTBEAT_SECONDS = 30;
/**
* A host is considered offline once it has missed this many heartbeats.
*
* Three rather than one, because a single missed beat is a lost packet and
* calling that "offline" would make placement flap.
*/
export const OFFLINE_AFTER_MISSED = 3;
/**
* Record that a host is alive, and say when that was.
*
* Returns the stored timestamp rather than void so a caller can hand it
* straight back to the host — which is what lets a heartbeat be one round
* trip instead of a write followed by a read.
*
* `endpointId` rides along for the same reason. Where a host is reachable
* is a fact about the host, it changes when the agent's identity does, and
* a caller that has just proved it is that host is the only one who can
* report it — so it is written by the call that already says "still here",
* in the same statement, rather than by a second one that could succeed
* alone and leave the two facts disagreeing.
*/
export const touchLastSeen = fn(
Info.pick({ id: true }).extend({ endpointId: EndpointId.optional() }),
async (input) => {
try {
return await Database.use(async (tx) => {
return tx
.update(MachineTable)
.set({
lastSeen: sql`now()`,
// Omitted rather than nulled when it is absent: a caller
// that does not mention where it is has not moved, and
// clearing the column would deregister a working host
// from every route that reads it.
...(input.endpointId ? { endpointId: input.endpointId } : {})
})
.where(eq(MachineTable.id, input.id))
.returning({ lastSeen: MachineTable.lastSeen })
.then((rows) => rows.at(0)?.lastSeen ?? null);
});
} catch (err) {
// Another host already holds this endpoint id. That is a
// conflict rather than a fault: the unique index is the
// invariant, so the database refusing is the expected way to
// find out, and letting it surface as a 500 would tell a host
// its beat broke the server.
//
// Checked-then-written would be worse rather than better. Two
// hosts reporting the same id in the same instant both read
// "nobody holds it" and both write, which is precisely what the
// index is for — so the read would add a query and remove
// nothing.
if (isUniqueViolation(err)) {
throw new VisibleError(
'already_exists',
ErrorCodes.Validation.ALREADY_EXISTS,
'Another machine is already reachable at that endpoint id'
);
}
throw err;
}
}
);
/**
* Whether a host has beaten recently enough to place work on.
*
* Derived from `lastSeen` rather than stored as a column, so there is no
* state to go stale when nothing is running to clear it — a host that stops
* beating becomes offline by the passage of time, which is the one mechanism
* that cannot itself fail.
*/
export function isOnline(lastSeen: Date | string | null): boolean {
if (!lastSeen) {
return false;
}
const at = lastSeen instanceof Date ? lastSeen : new Date(lastSeen);
const age = (Date.now() - at.getTime()) / 1000;
return age <= HEARTBEAT_SECONDS * OFFLINE_AFTER_MISSED;
}
export const fromID = fn(Info.shape.id, async (id) => {
return Database.use(async (tx) => {
return tx
.select()
.from(MachineTable)
.where(and(eq(MachineTable.id, id), isNull(MachineTable.timeDeleted)))
.then((rows) => {
const row = rows.at(0);
return row ? serialize(row) : null;
});
});
});
/** Why a user may — or may not — use a box. */
export const Entitlement = z.object({
entitled: z.boolean(),
/**
* `owner`, `team`, `fleet`, or `none`. Present so a refusal can explain
* itself rather than being an unexplained no.
*/
reason: z.enum(['owner', 'team', 'fleet', 'none'])
});
export type Entitlement = z.infer<typeof Entitlement>;
/**
* Whether a user may use a box.
*
* The whole access model in one function: a box someone brought is open to
* its owner and to the team it was registered under, and hardware an
* organisation owns outright is open to whoever has paid for a run on it.
*
* Membership is read live rather than cached in the machine row, so
* removing someone from a team takes their box access with it and nobody
* has to remember to revoke anything.
*
* **Fleet hardware refuses everyone for now, and that is deliberate.** What
* grants it is a plan, and nothing here can yet ask whether a user has one
* — so the honest answer is no rather than a yes that would hand out metered
* hardware for free. Failing closed on the expensive case is the cheap
* mistake to make; the branch is written out so there is one obvious place
* for the plan check to land. todo(d-0051)
*/
export const entitlement = fn(
z.object({ machineId: z.string(), userId: z.string() }),
async (input): Promise<Entitlement> => {
const machine = await fromID(input.machineId);
if (!machine) {
return { entitled: false, reason: 'none' };
}
if (machine.organisationId) {
// Fleet hardware. Not the owner's and not a team's, so neither
// test below means anything here.
return { entitled: false, reason: 'fleet' };
}
if (machine.ownerUserId && machine.ownerUserId === input.userId) {
return { entitled: true, reason: 'owner' };
}
if (!machine.teamId) {
// A solo box. Nobody but the owner, whatever else is true.
return { entitled: false, reason: 'none' };
}
const membership = await Member.findByTeamAndUser({
teamId: machine.teamId,
userId: input.userId
});
return membership ? { entitled: true, reason: 'team' } : { entitled: false, reason: 'none' };
}
);
/** Every host an organisation owns outright — its fleet. */
export const listByOrganisation = fn(z.string(), async (organisationId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(MachineTable)
.where(
and(eq(MachineTable.organisationId, organisationId), isNull(MachineTable.timeDeleted))
)
.orderBy(MachineTable.timeCreated)
.then((rows) => rows.map(serialize));
});
});
export const listByOwner = fn(z.string(), async (ownerUserId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(MachineTable)
.where(and(eq(MachineTable.ownerUserId, ownerUserId), isNull(MachineTable.timeDeleted)))
.orderBy(MachineTable.timeCreated)
.then((rows) => rows.map(serialize));
});
});
export const remove = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(MachineTable)
.set({ timeDeleted: sql`now()` })
.where(eq(MachineTable.id, id));
});
});
export function serialize(input: typeof MachineTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
ownerUserId: input.ownerUserId,
teamId: input.teamId,
organisationId: input.organisationId,
label: input.label,
slug: input.slug,
lastSeen: input.lastSeen?.toISOString() ?? null,
endpointId: input.endpointId
};
}
}