Gunnar

nordisk/holger-ui

assets/login.js

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();
  }
}

document.getElementById('login').onclick = login;
// The start asked as the person reaches for Log in (RA2-6): never on a bare view.
for (const ev of ['pointerenter', 'focus', 'touchstart']) {
  document.getElementById('login').addEventListener(ev, () => { 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) document.getElementById('user').value = named;
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);