holger-ui 3.1.0

Holger's web console: `cargo install holger-ui`, point it at a Holger with HOLGER_UI_HOLGER, open http://127.0.0.1:3210/ and sign in. It serves the console bundle and a same-origin pass-through to that Holger, and nothing else.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
const msg = document.getElementById('msg');
function show(text, cls) { msg.textContent = text; msg.className = 'msg ' + (cls || ''); }

// ★ **The invite is read FIRST, before anything reads it.**
//
// `INVITE` is a `const`, and the panel below calls `inviteSecret()` on its first
// line. Declared after it — as gunnar-ui's once was — that call lands in the
// temporal dead zone and throws `Cannot access 'INVITE' before initialization`,
// which kills the REST of this script: no `/auth/status` read, and no `onclick`
// on either button. Measured 2026-09-13 on gunnar's page as it shipped: the
// login button did nothing at all. Position is the fix and position is the whole
// fix, so it is stated here rather than left to the reader to re-derive.
//
// **Read ONCE, and taken out of the address bar.** The secret has to travel in
// the link — that is what an invite link is, and it is pasted into chat systems
// by design — but once this page holds it there is no reason for it to stay
// where the browser keeps URLs: the address bar, the session's history, the
// `Referer` of anything this page loads afterwards, and a screen share. So it is
// captured into one constant and the query is rewritten away with replaceState,
// which changes the URL without a navigation and without adding a history entry
// of its own.
const INVITE = (() => {
  try {
    const params = new URLSearchParams(location.search);
    const secret = params.get('invite') || '';
    if (secret) {
      // Everything else stays: `?next=` is read after the login finishes.
      params.delete('invite');
      const rest = params.toString();
      history.replaceState(null, '', location.pathname + (rest ? '?' + rest : ''));
    }
    return secret;
  } catch (e) { return ''; }
})();

function inviteSecret() { return INVITE; }

// **A sign-up, redeemed** (lane P1, Path B): the sign-up sends the newcomer back with its
// session (`?signup=` or `?session=`); holger binds the repository of that sign-up to the
// passkey registered with it, once. Read once and taken out of the address bar, as the invite is.
const SIGNUP = (() => {
  try {
    const params = new URLSearchParams(location.search);
    const s = params.get('signup') || params.get('session') || '';
    if (s) {
      params.delete('signup');
      params.delete('session');
      const rest = params.toString();
      history.replaceState(null, '', location.pathname + (rest ? '?' + rest : ''));
    }
    return s;
  } catch (e) { return ''; }
})();

// **Who the link was made for, and what kind of first login it is** (gunnar-ui `INVITE.who`,
// `INVITE.kind`): `&user=<account>` and `&kind=site` (the first admin of a site the shop laid).
// Read once with the invite or the sign-up and taken out of the address bar with them.
const LINK = (() => {
  try {
    const params = new URLSearchParams(location.search);
    const found = { who: (params.get('user') || '').trim(), kind: params.get('kind') || '' };
    if (INVITE || SIGNUP) {
      params.delete('user');
      params.delete('kind');
      const rest = params.toString();
      history.replaceState(null, '', location.pathname + (rest ? '?' + rest : ''));
    }
    return found;
  } catch (e) { return { who: '', kind: '' }; }
})();

// ── The bootstrap door, and taking it away once it is spent ──────────────────
// The break-glass token registers the FIRST admin passkey and then retires: a
// long-lived secret in an environment file must not stay a parallel admin
// credential for ever. The server reports that as `bootstrap_spent`, and the
// page's job is to stop offering a door that no longer opens.
//
// An invite link in the URL still opens the panel — that visitor was invited,
// and the server, not the page, fixes their role.
//
// Cookie-less on purpose: this is a fact about the console, not about the caller.
(function bootstrapDoor() {
  if (inviteSecret() || SIGNUP) return;
  fetch('/-/auth/passkey/config', { credentials: 'omit' })
    .then(r => (r.ok ? r.json() : null))
    .then(s => {
      if (!s) return;
      if (s.bootstrap_spent === true) {
        const reg = document.getElementById('reg');
        if (reg) reg.hidden = true;
      }
    })
    .catch(() => {});
})();

// ★ **The newcomer's line follows whether this root makes accounts** (HOL-LOGIN, owner
// 2026-10-27 on holger.rs). A root whose front page makes accounts (`/-/signup` answers an
// `account_url`, the front page's own rule) sends a newcomer to the front page, where the
// account is made, in Gunnar's words; a private instance keeps the invite sentence. Still no
// sign-up on this page (LOGIN-1): only the line under "Log in" changes. Cookie-less, a fact
// about the root, not about the caller.
const NEWCOMER_FRONT = 'New here? Make an account from the ';
const NEWCOMER_INVITE = 'New here? Ask whoever runs this Holger for an invite link.';
let ACCOUNTS_HERE = false;
(function newcomerLine() {
  const line = document.getElementById('invite-offer');
  const draw = accounts => {
    ACCOUNTS_HERE = accounts;
    line.textContent = '';
    if (accounts) {
      line.append(NEWCOMER_FRONT);
      const front = document.createElement('a');
      front.setAttribute('href', '/');
      front.textContent = 'front page';
      line.append(front, '.');
    } else line.textContent = NEWCOMER_INVITE;
    line.hidden = false;
  };
  fetch('/-/signup', { credentials: 'omit' })
    .then(r => (r.ok ? r.json() : null))
    .then(links => draw(!!(links && links.account_url)))
    .catch(() => draw(false));
})();

// What a person is told when no passkey answered (the authenticator's NotAllowedError):
// where a passkey comes from, and for a newcomer, the way in (LOGIN-1: never a sign-up here).
function noPasskeyHere() {
  return 'No passkey for this site on this device. Use the phone, computer or password manager where you made your passkey. '
    + (ACCOUNTS_HERE ? 'New here? Make an account from the front page.' : NEWCOMER_INVITE);
}

// ★ **A passkey holger does not hold is dropped from the device** (gunnar-ui, 2026-10-19: a
// second registration with a spent invite left an orphan passkey that replaced the good one).
// `signalUnknownCredential` tells the browser or password manager to forget it.
async function forgetPasskey(rpId, credentialId) {
  try {
    if (window.PublicKeyCredential && typeof PublicKeyCredential.signalUnknownCredential === 'function') {
      await PublicKeyCredential.signalUnknownCredential({ rpId: rpId || location.hostname, credentialId: credentialId });
    }
  } catch (e) { /* the device keeps it; nothing to tell the person */ }
}
// What a person reads when the registration answered no session: the key is registered, and
// the Log in button is there.
const REGISTERED_SIGN_IN = 'Your security key is registered. Sign in with it.';

// The invite or the sign-up is done with (spent, or the device already holds the passkey):
// the panel closes and "Log in" is the way on (gunnar-ui `inviteDone`).
// `inviteDone`, `ORPHAN_HINT` and `INVITE_SPENT` are edda's (nornir-identity-web,
// /login-helpers.js, loaded before this script): ONE copy for both consoles (A34-51).
let LINK_DONE = false;
function onInviteDone() { LINK_DONE = true; }

// holger refuses with {"error": "…"}; its sentence, or the plain text.
async function refusal(res) {
  const text = await res.text();
  try { const v = JSON.parse(text); if (v && typeof v.error === 'string') return v.error; } catch (e) {}
  return text;
}

function b64urlToBuf(s) {
  s = s.replace(/-/g, '+').replace(/_/g, '/');
  while (s.length % 4) s += '=';
  const bin = atob(s), bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  return bytes.buffer;
}
function bufToB64url(buf) {
  const bytes = new Uint8Array(buf);
  let s = '';
  for (const b of bytes) s += String.fromCharCode(b);
  return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

// Where to land after logging in: `safeLocalPath` and `nextDestination(home)` are edda's
// (nornir-identity-web, ONE rule for both consoles, A34-51).
//
// ★ THE DEFAULT IS THE CONSOLE, NOT `/`: `/` is the PUBLIC page, so a login that arrived
// without a `next` would show the person who just touched their key what they saw before.
// `/overview` cannot 404 (the console's server falls back to `index.html`).
const CONSOLE_HOME = '/overview';

// ★ **The start before the press** (RA2-6, the A29-20 shape): the sign-in's start is asked when
// the person reaches for Log in (its first hover, focus or touch; never on a bare anonymous
// view), kept under PREPARE_MS and used once. A press that finds it still on its way waits and,
// with the gesture gone, asks for one more press instead of starting a ceremony nobody
// pressed for.
let loginReady = null;
function loginAsk() {
  // ?uv=required (a step-up login; the console itself steps up in place, A28 M9) asks the key to
  // verify the person, not just be touched; anything else is the ordinary login.
  const uv = new URLSearchParams(location.search).get('uv') === 'required';
  return JSON.stringify(uv ? { uv: 'required' } : {});
}
function prepareLogin() {
  const body = loginAsk();
  if (loginReady && loginReady.body === body && Date.now() - loginReady.at < PREPARE_MS) return loginReady;
  const p = { body, at: Date.now(), answer: null };
  p.done = fetch('/-/auth/passkey/login/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body })
    .then(async res => (res.ok ? { ok: true, start: await res.json() } : { ok: false, status: res.status, words: await refusal(res) }))
    .catch(e => ({ ok: false, error: e }))
    .then(answer => { p.answer = answer; return answer; });
  loginReady = p;
  return p;
}

async function login() {
  const btn = document.getElementById('login');
  // The button reports its own state by turning over: the primary action is
  // light-on-dark, so while it waits for the authenticator it becomes
  // dark-on-light and says what it is doing. The label is captured rather than
  // written twice, so changing the word in the markup changes it here too.
  const label = btn.textContent;
  btn.disabled = true;
  btn.classList.add('working');
  btn.textContent = 'Processing …';
  try {
    show('waiting for your key …');
    const p = prepareLogin();
    let answer = p.answer;
    if (!answer) {
      answer = await p.done;
      if (answer.ok && !gestureLive()) { show(PRESS_AGAIN, 'ok'); return; }
    }
    // A start is used once: the next press asks for a fresh one.
    loginReady = null;
    if (answer.error) throw answer.error;
    if (!answer.ok) throw new Error(answer.words || 'could not start');
    const start = answer.start;
    const pk = start.publicKey;
    pk.challenge = b64urlToBuf(pk.challenge);
    pk.allowCredentials = (pk.allowCredentials || []).map(c => ({ ...c, id: b64urlToBuf(c.id) }));
    const cred = await navigator.credentials.get({ publicKey: pk });
    const body = { ceremony_id: start.ceremony_id, id: cred.id, response: {
      clientDataJSON: bufToB64url(cred.response.clientDataJSON),
      authenticatorData: bufToB64url(cred.response.authenticatorData),
      signature: bufToB64url(cred.response.signature),
    }};
    const fin = await fetch('/-/auth/passkey/login/finish', {
      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
    if (fin.ok) {
      // holger's session is the console server's HttpOnly cookie (A34-38): the answer reaches
      // this page without it, and no script here can read it.
      show('logged in — redirecting …', 'ok');
      location.href = nextDestination(CONSOLE_HOME);
    }
    else show(await refusal(fin) || 'login was denied', 'err');
  } catch (e) { show(e && e.name === 'NotAllowedError' ? noPasskeyHere() : (e.message || String(e)), 'err'); }
  finally {
    btn.disabled = false;
    btn.classList.remove('working');
    btn.textContent = label;
  }
}

// ★ **Never the browser's own words** (gunnar-ui, owner order 2026-10-18): a passkey
// ceremony's DOMException is said in plain words.
const PASSKEY_WORDS = {
  NotAllowedError: 'The passkey request was cancelled or timed out. Try again.',
  AbortError: 'The passkey request was cancelled. Try again.',
  InvalidStateError: 'This key already holds a passkey for this site.',
  SecurityError: 'Passkeys do not work at this address. Open the page at its own https address.',
  NotSupportedError: 'This browser or key cannot make a passkey here. Try another browser or key.',
  ConstraintError: 'This browser or key cannot make a passkey here. Try another browser or key.',
};
function said(e) {
  if (e instanceof TypeError) return 'Holger did not answer. Check your connection and try again.';
  if (typeof DOMException !== 'undefined' && e instanceof DOMException) {
    return Object.hasOwn(PASSKEY_WORDS, e.name) ? PASSKEY_WORDS[e.name] : 'Something went wrong with the passkey. Try again.';
  }
  return (e && e.message) || String(e);
}

// ★ **The first touch is the press's own** (A29-20: "passkey request was cancelled" right
// after a first login link). A browser starts a passkey ceremony only inside the person's
// gesture: Safari refuses one started after the press has gone, and Chromium keeps a press live
// for 5 s. On a first login the registration's start may wait on the box, so a press
// that FIRST asked holger and THEN made the passkey could reach the device with no gesture
// left, and the device said "cancelled". So the start is asked BEFORE the press — when the
// panel opens, and again when a field it names changes — and the press makes the passkey at
// once. A ceremony is used once and kept at most PREPARE_MS (holger keeps one CHALLENGE_TTL,
// 5 min). A press that finds the start still on its way waits for it and, when the gesture has
// gone meanwhile, asks for one more press instead of starting a ceremony without one.
const PREPARE_MS = 4 * 60 * 1000;
const PRESS_AGAIN = 'Ready. Touch your security key again.';
let prepared = null;
let registering = false;
function gestureLive() { return !navigator.userActivation || navigator.userActivation.isActive; }
// What the start is asked with: the fields as they stand now.
function registerAsk() {
  const invite = LINK_DONE ? '' : inviteSecret();
  const token = document.getElementById('token').value.trim();
  const label = document.getElementById('label').value.trim() || 'passkey';
  const user = document.getElementById('user').value.trim();
  const role = document.getElementById('role').value;
  const hdr = (!invite && !SIGNUP && token) ? { 'Authorization': 'Bearer ' + token } : {};
  const ask = {};
  if (user) ask.display = user;
  if (invite) ask.invite = invite;
  else if (SIGNUP) ask.signup = SIGNUP;
  // The role is asked for at START: holger fixes what a ceremony may create
  // when it opens it (an invite's word beats the form, and the form's role is
  // an admin's word only), so naming it at finish would be naming it too late.
  if (!invite && role) ask.role = role;
  if (label) ask.label = label;
  return { invite, token, label, user, role, hdr, ask };
}
// The start for the fields as they stand: the one already asked when it is fresh, else asked now.
function prepare() {
  const a = registerAsk();
  const key = JSON.stringify([a.ask, a.hdr]);
  if (prepared && prepared.key === key && Date.now() - prepared.at < PREPARE_MS) return prepared;
  const p = { key, at: Date.now(), a, answer: null };
  p.done = fetch('/-/auth/passkey/register/start', {
    method: 'POST', headers: { ...a.hdr, 'Content-Type': 'application/json' }, body: JSON.stringify(a.ask) })
    .then(async res => (res.ok ? { ok: true, start: await res.json() } : { ok: false, status: res.status, words: await refusal(res) }))
    .catch(e => ({ ok: false, error: e }))
    .then(answer => { p.answer = answer; return answer; });
  prepared = p;
  return p;
}
// Asked ahead for the panel: the button waits for the answer, a refusal is said at once.
function prepareAhead() {
  if (registering) return;
  const btn = document.getElementById('register');
  const p = prepare();
  if (p.answer) return;
  btn.disabled = true;
  p.done.then(answer => {
    if (prepared === p) btn.disabled = registering;
    if (answer.ok || prepared !== p) return;
    prepared = null;
    if (p.a.invite && (answer.status === 401 || answer.status === 410)) inviteDone(answer.words || INVITE_SPENT, 'err');
    else if (answer.error) show(said(answer.error), 'err');
    // A name still to be chosen (400) is said at the press, as before.
    else if (![400, 401, 403].includes(answer.status)) show(answer.words || 'requires a valid invite or admin token', 'err');
  });
}

async function register() {
  if (registering) return;
  registering = true;
  const btn = document.getElementById('register');
  btn.disabled = true;
  try {
    const p = prepare();
    let answer = p.answer;
    if (!answer) {
      show('starting registration …');
      answer = await p.done;
      if (answer.ok && !gestureLive()) { show(PRESS_AGAIN, 'ok'); return; }
    }
    // A ceremony is used once: the next press asks for a fresh one.
    prepared = null;
    const { invite, label, user, role, hdr } = p.a;
    if (answer.error) throw answer.error;
    // A spent or expired invite is refused here, before the device makes a passkey (holger
    // answers one 401 for spent, expired and unknown).
    if (!answer.ok && invite && (answer.status === 401 || answer.status === 410)) { inviteDone(answer.words || INVITE_SPENT, 'err'); return; }
    if (!answer.ok) throw new Error(answer.words || 'requires a valid invite or admin token');
    const start = answer.start;
    const pk = start.publicKey;
    pk.challenge = b64urlToBuf(pk.challenge);
    pk.user.id = b64urlToBuf(pk.user.id);
    pk.excludeCredentials = (pk.excludeCredentials || []).map(c => ({ ...c, id: b64urlToBuf(c.id) }));
    let cred;
    try { cred = await navigator.credentials.create({ publicKey: pk }); }
    catch (e) {
      // The account's passkeys are excluded: this device already holds one.
      if (e && e.name === 'InvalidStateError' && (invite || SIGNUP)) {
        inviteDone('This device already holds your passkey for this site. Sign in with it.', 'ok');
        return;
      }
      throw e;
    }
    const body = { ceremony_id: start.ceremony_id, label, user, role, response: {
      clientDataJSON: bufToB64url(cred.response.clientDataJSON),
      attestationObject: bufToB64url(cred.response.attestationObject),
    }};
    if (invite) body.invite = invite;
    const fin = await fetch('/-/auth/passkey/register/finish', {
      method: 'POST', headers: { ...hdr, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
    if (fin.ok) {
      // A sign-up's or an invite's registration answers holger's session (lane G12, A10 row
      // 10): the person is signed in at once, in the console server's HttpOnly cookie as a
      // login is (A34-38) — no second ceremony, so no second touch that could find no gesture.
      const done = await fin.json().catch(() => ({}));
      if (done && done.signed_in) {
        show('Your security key is registered. Signing you in …', 'ok');
        location.href = nextDestination(CONSOLE_HOME);
      } else show(REGISTERED_SIGN_IN, 'ok');
    } else {
      // holger refused the passkey the device just made: the device is told to drop it, so it
      // cannot stand in for a good one.
      const text = await refusal(fin);
      await forgetPasskey(pk.rp && pk.rp.id, cred.id);
      if (invite && (fin.status === 401 || fin.status === 410)) inviteDone((text || INVITE_SPENT) + ' ' + ORPHAN_HINT, 'err');
      else show((text || 'registration failed') + ' ' + ORPHAN_HINT, 'err');
    }
  } catch (e) { show(said(e), 'err'); }
  finally {
    registering = false;
    btn.disabled = false;
    // Ready for the next press, where the panel is still open.
    if (!document.getElementById('reg').hidden && (inviteSecret() || SIGNUP) && !LINK_DONE) prepareAhead();
  }
}

// ★ HOL-LOGIN-LOOP (holger.rs 2026-10-30, "login → login again"): a person who is signed in
// and opens /login (a bookmark, Back, the front page's old link) was offered "Log in" again,
// because this page never asked. It asks holger's session door once, with this tab's own cookie
// (the console server's HttpOnly session; no script reads it): a session turns "Log in" into
// "Open the console" and the line names who is signed in. Never with an invite, a sign-up's way
// back or a step-up (`?uv=required`): those are a new key or a fresh touch, signed in or not. It
// never navigates by itself: the press does.
let SIGNED_IN_HERE = false;
(function signedInAlready() {
  if (inviteSecret() || SIGNUP || new URLSearchParams(location.search).get('uv') === 'required') return;
  fetch('/-/auth/passkey/session', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: '{}' })
    .then(r => (r.ok ? r.json() : null))
    .then(me => {
      if (!me || typeof me !== 'object') return;
      const who = String(me.display || me.subject || '').trim();
      SIGNED_IN_HERE = true;
      document.getElementById('login').textContent = 'Open the console';
      show(who ? 'Signed in as ' + who + '.' : 'Signed in.', 'ok');
    })
    .catch(() => {});
})();
document.getElementById('login').onclick = () => (SIGNED_IN_HERE ? (location.href = nextDestination(CONSOLE_HOME)) : login());
// The start asked as the person reaches for Log in (RA2-6): never on a bare view, never signed in.
for (const ev of ['pointerenter', 'focus', 'touchstart']) {
  document.getElementById('login').addEventListener(ev, () => { if (!SIGNED_IN_HERE) prepareLogin(); }, { passive: true });
}
document.getElementById('register').onclick = register;
if (!window.PublicKeyCredential) show('this browser does not support passkeys', 'err');
// ★ **One panel for a first login** (gunnar-ui `invited()`): an invite's link or a
// sign-up's way back IS the credential. The panel opens, the controls the server decides go,
// "Log in" steps aside, and the line says whose account it is — the account named by the link
// (`&user=`), never typed where it is named. Holger lets a link that names nobody choose a name.
function registerPanel(line) {
  document.getElementById('reg').open = true;
  document.getElementById('token-row').hidden = true;
  document.getElementById('role-row').hidden = true;
  document.getElementById('login').hidden = true;
  document.getElementById('reg-summary').textContent = 'Register your security key';
  document.getElementById('register').textContent = 'Touch your security key';
  document.getElementById('register').classList.add('primary');
  show(line, 'ok');
}
const named = LINK.who;
if (named) {
  // The link names the account (gunnar-ui `reg-who`): the line says it, nobody types it.
  document.getElementById('user').value = named;
  if (inviteSecret() || SIGNUP) document.getElementById('user-row').hidden = true;
}
if (SIGNUP && !inviteSecret()) {
  // The sign-up's way back: one passkey, and the account is yours.
  registerPanel(named
    ? 'Your account ' + named + ' is ready. Touch your security key to finish.'
    : 'Your account is ready. Choose your user name and touch your security key to finish.');
} else if (inviteSecret()) {
  // `kind=site`: the first admin of a SITE the shop laid — nobody ordered an account there, so
  // the line only welcomes its admin (Holger has no member invites).
  const who = named || 'your account';
  registerPanel(LINK.kind === 'site'
    ? 'Welcome — register your security key for ' + who + ' on ' + location.hostname + '.'
    : named
    ? 'Your account ' + named + ' is ready. Touch your security key to finish.'
    : 'You were invited to Holger. Choose your user name and touch your security key to finish.');
}
// ★ The start asked before the press (A29-20), for a link's panel whose name is known; a name
// or a label typed later asks again when the field is left. Kept fresh while the page is open.
// An invite may be pinned to its name (holger knows it); a sign-up names it in the link.
if ((inviteSecret() || SIGNUP) && !LINK_DONE && (document.getElementById('user').value.trim() || inviteSecret())) prepareAhead();
// The break-glass first admin and a typed name: asked once the field is left with a name in it.
for (const id of ['user', 'label', 'token', 'role']) {
  document.getElementById(id).addEventListener('change', () => { if (document.getElementById('user').value.trim() || ((inviteSecret() || SIGNUP) && !LINK_DONE)) prepareAhead(); });
}
setInterval(() => { if (!registering && prepared && Date.now() - prepared.at >= PREPARE_MS) prepareAhead(); }, 30 * 1000);