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