Skip to content

Commit 202e67a

Browse files
committed
feat: machines are named <first>-<hostname>-<id>, and Integrations uses the window
Two things a board owes you, neither of which it was doing. **A machine you can tell apart.** The portal sent the literal string "machine" as the label for every registration, so the server's collision suffix did the naming and a fleet came out as "machine", "machine · 2", "machine · 11" — names that say nothing about whose machine it is or which one it is. The hostname was being reported all along; it arrived at enrolment and was written to the record and never used for anything a person reads. Naming now happens where the facts are. Registration mints the uid and the owner's first name and produces `sankara-pending-k3f9dq`; enrolment, which is the one moment the hostname exists, rewrites it to `sankara-sankaras-macbook-pro-k3f9dq`. Same uid on both sides, so a card that gained a real name is recognisably the same card. The rules live in shared/machine-name.mjs because the two ends of that handshake have to agree. Machines that enrolled before any of this are the entire existing fleet, so they are renamed the first time their owner loads the board — the only place holding both the email and the recorded hostname. It is guarded three ways: auto-generated names only, the caller's own rows only (the super admin sees every account's), and only when the name would actually change. A name a person typed is never touched, and renaming by hand opts a machine out permanently. **The right-hand third of the Integrations page.** `.mkt-page` set `max-width: none` to opt out of the 1180px reading width, but `.adm-page--wide` declares the same specificity later in the file and won outright, so a card grid — the one layout here that genuinely wants the whole window — was capped and left-aligned with a dead band beside it. Fixed with a compound selector. Card chips now show the part of the name that differs, since every machine on a board shares an owner prefix and a uid nobody reads. install.sh takes TOKENHUD_HOST for a box whose real hostname is not the name you think of it by, and says which name it is about to report.
1 parent d930bb1 commit 202e67a

7 files changed

Lines changed: 422 additions & 13 deletions

File tree

amplify/functions/api/handler.ts

Lines changed: 114 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@
3131
// second copy of it into a secondary index, on every single heartbeat.
3232

3333
import type { LambdaFunctionURLEvent, LambdaFunctionURLResult } from 'aws-lambda';
34-
import { randomUUID } from 'node:crypto';
34+
import { randomBytes, randomUUID } from 'node:crypto';
3535
import { gunzipSync, gzipSync } from 'node:zlib';
3636

37+
import {
38+
firstNameFrom, looksGenerated, machineName, shortUid,
39+
} from '../../../shared/machine-name.mjs';
3740
import { liveness, mergeEntries, profileOf } from '../../../shared/profile.mjs';
3841
import { cached, fail, json, preflight, type Cors } from './http';
3942
import { callerOf, type Caller } from './jwt';
@@ -92,6 +95,22 @@ function parseJson(
9295
const str = (v: unknown, max = 200) =>
9396
(typeof v === 'string' && v.length <= max ? v : '');
9497

98+
/* A name nobody else on this board is using.
99+
*
100+
* The generated names carry six random characters precisely so this never
101+
* fires, but a person renaming machines by hand can still collide, and the
102+
* board's own rename endpoint refuses duplicates — so registration must not be
103+
* able to create one behind its back. */
104+
function uniqueLabel(desired: string, taken: Set<string>): string {
105+
if (!taken.has(desired)) return desired;
106+
let n = 2;
107+
while (taken.has(`${desired} · ${n}`)) n++;
108+
return `${desired} · ${n}`;
109+
}
110+
111+
/* Six characters of collision insurance, from the runtime's real CSPRNG. */
112+
const newUid = () => shortUid((n: number) => randomBytes(n));
113+
95114
/* ── the public identity of an account ──────────────────────────────── */
96115

97116
// The Cognito sub is an internal identifier and has no business appearing on a
@@ -173,13 +192,17 @@ async function register(
173192
const enrollTokenHash = str(req?.enrollTokenHash, 64);
174193
const pairingCode = str(req?.pairingCode, 7);
175194
const expiresAt = str(req?.enrollTokenExpiresAt, 40);
195+
// The label is optional on purpose. At this point in the handshake nobody
196+
// knows what this machine is called — the agent has not run yet — so a name
197+
// supplied here can only be one a person typed. When they did not type one,
198+
// this function names the machine itself and `claim` finishes the job once
199+
// the agent reports its hostname.
176200
if (
177-
!label ||
178201
!/^[0-9a-f]{64}$/.test(enrollTokenHash) ||
179202
!/^[A-Z2-9]{3}-[A-Z2-9]{3}$/.test(pairingCode) ||
180203
!Number.isFinite(Date.parse(expiresAt))
181204
) {
182-
return fail(400, 'a registration needs a label, a token hash, a pairing code and an expiry', cors);
205+
return fail(400, 'a registration needs a token hash, a pairing code and an expiry', cors);
183206
}
184207
// A link that outlives the sitting it was minted in is a link somebody
185208
// pastes into a terminal a week later and cannot explain.
@@ -189,18 +212,30 @@ async function register(
189212
}
190213

191214
const taken = new Set((await store.listMachines(caller.sub)).map((m) => m.label));
192-
let name = label;
193-
if (taken.has(name)) {
194-
let n = 2;
195-
while (taken.has(`${name} · ${n}`)) n++;
196-
name = `${name} · ${n}`;
197-
}
215+
216+
// `uid` and `ownerFirst` are minted here and never change, so the name this
217+
// machine gets now and the name it gets when the agent enrols differ in
218+
// exactly one part — the hostname. A card that gained a real name is
219+
// recognisably the same card, which it would not be if the whole string
220+
// were rebuilt from scratch.
221+
const uid = newUid();
222+
const ownerFirst = firstNameFrom(caller.email);
223+
// A label a person typed is theirs and survives enrolment untouched. One
224+
// that only looks generated — including the "machine · 11" shape this
225+
// replaces — is treated as no label at all.
226+
const chosen = label && !looksGenerated(label) ? label : '';
227+
const labelAuto = !chosen;
228+
const name = uniqueLabel(
229+
chosen || machineName({ email: caller.email, uid, fallbackHost: 'pending' }),
230+
taken,
231+
);
198232

199233
const id = randomUUID();
200234
const now = new Date().toISOString();
201235
await store.putMachine({
202236
pk: store.userPk(caller.sub), sk: store.machineSk(id),
203237
id, sub: caller.sub, label: name,
238+
labelAuto, uid, ownerFirst,
204239
status: 'registered',
205240
pairingCode,
206241
enrollTokenHash,
@@ -247,9 +282,27 @@ async function claim(event: LambdaFunctionURLEvent): Promise<LambdaFunctionURLRe
247282
// One link binds to one machine; a retry from the same machine refreshes it.
248283
if (m.installId && m.installId !== installId) return fail(410, 'enrollment link already used');
249284

285+
// The hostname has just arrived, and this is the only moment it ever will —
286+
// so this is where an auto-named machine stops being `sankara-pending-k3f9dq`
287+
// and becomes `sankara-sankaras-macbook-pro-k3f9dq`. Rows written before
288+
// this existed have no `labelAuto`, so their names are judged by shape,
289+
// which is what rescues the "machine · 11" fleet on the next enrolment.
290+
const auto = m.labelAuto ?? looksGenerated(m.label);
291+
const uid = m.uid || newUid();
292+
let label = m.label;
293+
if (auto) {
294+
const taken = new Set(
295+
(await store.listMachines(m.sub)).filter((x) => x.id !== m.id).map((x) => x.label),
296+
);
297+
label = uniqueLabel(machineName({ first: m.ownerFirst, hostname: host, uid }), taken);
298+
}
299+
250300
await store.updateMachine(m.sub, m.id, {
251301
installId,
252302
hostname: host,
303+
label,
304+
labelAuto: auto,
305+
uid,
253306
platform: str(req?.platform, 120),
254307
agentVersion: str(req?.agentVersion, 40),
255308
manifestDigest: str(req?.manifestDigest, 128),
@@ -407,13 +460,63 @@ function publicMachine(m: Record<string, any>) {
407460
};
408461
}
409462

463+
/* Give a real name to machines that enrolled before names meant anything.
464+
*
465+
* The naming happens at registration and at enrolment, which does nothing for
466+
* a machine that is already active — and the fleet this was written for is
467+
* entirely already active, sitting on the board as "machine", "machine · 2",
468+
* "machine · 11". Everything needed to fix them is here and nowhere else: the
469+
* owner's email is on the caller, and the hostname was recorded when the agent
470+
* enrolled. So the board renames them the first time their owner looks at it.
471+
*
472+
* Guarded three ways. Only auto-generated names are touched, so a name a
473+
* person typed is never overwritten. Only the caller's own machines are
474+
* touched, because the super admin sees every account's rows and must not
475+
* stamp their own first name across all of them. And the write only happens
476+
* when the name would actually change, so this costs nothing on every
477+
* subsequent poll. */
478+
async function backfillNames(caller: Caller, rows: store.Machine[]): Promise<void> {
479+
const mine = rows.filter((m) => m.sub === caller.sub);
480+
const stale = mine.filter(
481+
(m) => (m.labelAuto ?? looksGenerated(m.label)) && (m.hostname || m.label),
482+
);
483+
if (!stale.length) return;
484+
485+
const taken = new Set(mine.map((m) => m.label));
486+
const first = firstNameFrom(caller.email);
487+
488+
for (const m of stale) {
489+
const uid = m.uid || newUid();
490+
// A machine that enrolled has a hostname; one still waiting to keeps the
491+
// provisional shape until it does, rather than being named after nothing.
492+
const desired = machineName({
493+
first, hostname: m.hostname, uid, fallbackHost: m.hostname ? undefined : 'pending',
494+
});
495+
if (desired === m.label) continue;
496+
taken.delete(m.label);
497+
const label = uniqueLabel(desired, taken);
498+
taken.add(label);
499+
try {
500+
await store.updateMachine(caller.sub, m.id, { label, labelAuto: true, uid, ownerFirst: first });
501+
// The caller is about to be handed these rows, so update them in place
502+
// rather than making the new name wait for the next poll.
503+
m.label = label; m.labelAuto = true; m.uid = uid; m.ownerFirst = first;
504+
} catch {
505+
// A rename that loses a race is not worth failing a board render over —
506+
// the next poll tries again.
507+
}
508+
}
509+
}
510+
410511
async function overview(caller: Caller, event: LambdaFunctionURLEvent, cors: Cors) {
411512
const admin = isSuperAdmin(caller);
412513
const rows = admin
413514
? await store.listAllMachines()
414515
: await store.listMachines(caller.sub);
415516
rows.sort((a, b) => ((a.createdAt ?? '') < (b.createdAt ?? '') ? 1 : -1));
416517

518+
await backfillNames(caller, rows);
519+
417520
// `?live=0` asks for the rows without the readings — a much smaller answer,
418521
// and all the portal needs while it is only watching liveness.
419522
const wantLive = (event.queryStringParameters?.live ?? '1') !== '0';
@@ -479,7 +582,8 @@ async function machineAction(
479582
const taken = (await store.listMachines(ownerSub))
480583
.some((other) => other.id !== id && other.label === label);
481584
if (taken) return fail(409, 'another machine on this board already has that name', cors);
482-
await store.updateMachine(ownerSub, id, { label });
585+
// Naming a machine by hand opts it out of ever being renamed again.
586+
await store.updateMachine(ownerSub, id, { label, labelAuto: false });
483587
return json(200, { ok: true, label }, cors);
484588
}
485589

shared/machine-name.mjs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/* How a machine gets its name.
2+
*
3+
* A board is only useful if you can tell one row from another, and the old
4+
* default could not: the browser sent the literal string "machine" for every
5+
* registration, so the server's collision suffix did the naming and a fleet
6+
* came out as "machine", "machine · 2", "machine · 11". Those names say
7+
* nothing about whose machine it is, which machine it is, or which of two
8+
* identical laptops you are looking at.
9+
*
10+
* The shape is `<first>-<host>-<uid>`, and each part earns its place:
11+
*
12+
* first the owner's first name, from their sign-in email. On a shared or
13+
* team board this is the part that says whose laptop this is.
14+
* host the machine's own hostname, slugged. This is the part a human
15+
* recognises — it is the same string they see in their shell prompt.
16+
* uid six random characters. Two people really do own two machines both
17+
* called `mbp`, and a name that collides is a name that gets a " · 2"
18+
* bolted onto it, which is where this started.
19+
*
20+
* The pieces arrive at different times, which is the whole reason this module
21+
* is shared rather than inlined. `first` and `uid` are known in the browser at
22+
* registration; `host` is not known until the agent enrols and reports it. The
23+
* name is therefore built twice — provisionally at registration, finally at
24+
* enrolment — and both callers have to agree on the rules or the machine
25+
* appears to rename itself for no reason.
26+
*/
27+
28+
/* DynamoDB is happy with far more, but `str(req?.label, 120)` in the ingest
29+
handler is the real ceiling and the rename endpoint enforces the same. */
30+
export const MAX_LABEL = 120;
31+
32+
/* No vowels beyond `a`, no `0/O/1/I/L/U` — the same instinct as the pairing
33+
alphabet. A name that gets read aloud over a desk should not turn on
34+
whether that character was a one or an ell. */
35+
const UID_ALPHABET = 'abcdefghjkmnpqrstvwxyz23456789';
36+
const UID_LEN = 6;
37+
38+
/* Lowercase, alphanumeric, single dashes, no leading or trailing dash. Applied
39+
to every part so the joined name has exactly one kind of separator and the
40+
whole thing survives a URL, a filename and a shell word unquoted. */
41+
function slug(s) {
42+
return String(s ?? '')
43+
.toLowerCase()
44+
.replace(/[^a-z0-9]+/g, '-')
45+
.replace(/^-+|-+$/g, '');
46+
}
47+
48+
/* The owner's first name, from the address they signed in with.
49+
*
50+
* `sankara.telukutla@gmail.com` and `sankara@reddy.sh` both give `sankara`.
51+
* Plus-addressing is cut before the split so `sankara+work@…` does not become
52+
* `sankara-work`. An address that yields nothing usable — a UUID alias, a
53+
* digits-only local part — falls back rather than producing a name that is
54+
* somehow worse than no name at all. */
55+
export function firstNameFrom(email, fallback = 'user') {
56+
const local = String(email ?? '').split('@')[0].split('+')[0];
57+
const first = slug(local).split('-').filter(Boolean)[0] ?? '';
58+
// A purely numeric "first name" is an alias, not a person.
59+
if (!first || /^[0-9]+$/.test(first)) return fallback;
60+
return first;
61+
}
62+
63+
/* The machine's own name, as a human would recognise it.
64+
*
65+
* Hostnames arrive dressed for a network, not for a list: `Sankaras-MacBook-
66+
* Pro.local`, `web-01.prod.internal`. The first label is the part that
67+
* identifies the machine and the rest identifies the network it happened to
68+
* be on when it enrolled — which can change without the machine changing. */
69+
export function hostSlug(hostname, fallback = 'machine') {
70+
const firstLabel = String(hostname ?? '').trim().split('.')[0];
71+
return slug(firstLabel) || fallback;
72+
}
73+
74+
/* Six characters of collision insurance.
75+
*
76+
* Takes an optional source of randomness so a caller with one already — the
77+
* browser's crypto, the Lambda's — does not have to reach for Math.random,
78+
* and so the tests can be deterministic. */
79+
export function shortUid(randomBytes) {
80+
const bytes = randomBytes
81+
? randomBytes(UID_LEN)
82+
: globalThis.crypto?.getRandomValues?.(new Uint8Array(UID_LEN));
83+
if (!bytes) throw new Error('shortUid needs a source of randomness');
84+
let out = '';
85+
for (let i = 0; i < UID_LEN; i++) out += UID_ALPHABET[bytes[i] % UID_ALPHABET.length];
86+
return out;
87+
}
88+
89+
/* Assemble `<first>-<host>-<uid>`, within the length ceiling.
90+
*
91+
* When the whole thing is too long it is the host that gives way, never the
92+
* uid: the uid is what makes the name unique, and a truncated uid is a name
93+
* that can collide again. `first` is short by construction, so in practice
94+
* this only ever bites a machine with a genuinely enormous hostname. */
95+
export function machineName({ email, first: given, hostname, uid, fallbackFirst, fallbackHost } = {}) {
96+
// `first` wins over `email` because the two callers know different things.
97+
// The browser and the register handler hold the signed-in address; the
98+
// enrol handler does not — it is answering an agent, not a person — and
99+
// reads back the first name that registration stamped on the machine.
100+
const first = slug(given) || firstNameFrom(email, fallbackFirst ?? 'user');
101+
const host = hostSlug(hostname, fallbackHost ?? 'machine');
102+
const tail = uid ? `-${uid}` : '';
103+
const room = MAX_LABEL - first.length - 1 - tail.length;
104+
const clipped = host.length > room ? host.slice(0, Math.max(1, room)).replace(/-+$/, '') : host;
105+
return `${first}-${clipped}${tail}`;
106+
}
107+
108+
/* Is this name one we generated, rather than one a person typed?
109+
*
110+
* Only auto-generated names get rewritten when the hostname finally arrives.
111+
* Somebody who took the trouble to call a machine "build box" should find it
112+
* still called that after the agent enrols. The stored `labelAuto` flag is the
113+
* authority; this is the fallback for rows written before the flag existed,
114+
* including the "machine · 11" generation that prompted all of this. */
115+
export function looksGenerated(label) {
116+
const s = String(label ?? '').trim();
117+
if (!s) return true;
118+
if (/^machine(\s·\s\d+)?$/.test(s)) return true;
119+
return new RegExp(`^[a-z0-9]+-[a-z0-9-]+-[${UID_ALPHABET}]{${UID_LEN}}$`).test(s);
120+
}
121+
122+
/* The part of a generated name that actually distinguishes one machine.
123+
*
124+
* Every machine on one board shares the same `<first>-` prefix and carries a
125+
* uid that exists for the database's benefit rather than the reader's, so a
126+
* list of them is a column of near-identical strings differing in the middle —
127+
* the worst possible shape to scan. In a tight space, show the middle. The
128+
* full name still belongs in a tooltip, and anywhere there is room for it.
129+
*
130+
* A name a person typed is returned untouched: they chose those words. */
131+
export function shortMachineName(label) {
132+
const s = String(label ?? '').trim();
133+
if (!looksGenerated(s)) return s;
134+
const parts = s.split('-');
135+
// <first>-<host…>-<uid>: drop the ends, keep everything between them.
136+
if (parts.length >= 3) {
137+
const middle = parts.slice(1, -1).join('-');
138+
if (middle) return middle;
139+
}
140+
return s;
141+
}

0 commit comments

Comments
 (0)