Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

holger-front/assets/front.html

<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>holger — artifacts at rest</title>
<style>
  /* Self-contained on purpose: holger-server has no static-asset tree and no
     template engine — it is a hand-rolled hyper dispatcher — so this page is
     one file, one door, and it renders with nothing else fetched but the
     picture. The tokens are the estate's console tokens, copied rather than
     re-chosen: two fronts in one estate that disagreed about what "dim" means
     would read as two products. Colour means status, nothing else, and the page is
     LIGHT. */
  :root {
    color-scheme: light;
    /* MEASURED at the EDGE THAT TOUCHES THE PAGE, not at the corner — the
       correction the estate's other front records, and it applies here for the same
       reason: `holger.webp` is a dark mark on a near-white field, and the
       column where it meets the words is a gradient, not a colour. #fefefe is
       the mean of that edge (RE-SAMPLED 2026-09-17 after the picture's LFS
       object was recovered: the edge is rgb(254,254,254) and the corners are
       pure white, so the old #f4f4f4 sat ten values below the picture and the
       seam it was chosen to shorten was visible instead), which is the shortest distance the fade below has
       to travel. THE COLOUR DOES NOT CLOSE THE SEAM AND CANNOT — one value
       cannot match a column that moves. The fade on `.art` closes it, at every
       height and every viewport; this number only makes the fade short.
       Re-sample the edge, not the corner, if the picture is replaced. */
    --bg: #fefefe;
    /* The same value as channels, so a gradient can fade to it at zero alpha.
       `transparent` is NOT the same thing: several engines interpolate it as
       transparent BLACK and lay a grey haze over the last stretch of the fade,
       which is the very seam this is here to remove. */
    --bg-rgb: 254 254 254;
    --surface: oklch(1 0 0);
    /* A step BELOW the ground: the controls are filled rather than outlined,
       and a fill lighter than the page stops reading as a control at all. */
    --raised: #e5e5e3;
    --line: oklch(0.885 0.005 75);
    --line-strong: oklch(0.775 0.006 75);
    --ink: oklch(0.225 0.008 70);
    --ink-dim: oklch(0.435 0.008 70);
    --ink-mute: oklch(0.565 0.007 70);
    --pass: oklch(0.52 0.15 155);
    --bad: oklch(0.53 0.20 25);
    --sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
    --mono: ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, monospace;
    /* The entry half is set in a book face and the releases in monospace, and
       the split is not decoration: a release list is machine data — names and
       version numbers — and monospace is what makes the version column line up
       so the eye can run down it. Loaded from nowhere: an external font file
       would be one more thing that can fail to arrive before the page draws,
       on a server whose whole point is working with no internet at all. */
    --display: ui-serif, Georgia, "Iowan Old Style", "Palatino Linotype", Palatino, serif;
  }
  * { box-sizing: border-box; }
  html, body { height: 100%; }
  /* ── The layout ────────────────────────────────────────────────────────
     Two halves of the viewport, nothing drawn between them: THE PICTURE OWNS
     THE LEFT, everything a visitor can read or do owns the right. No card, no
     modal, no panel — a card around three lines of text is a frame around a
     frame.

     The right half is a column: the entry block on top, sized to its own
     content, and the releases underneath taking whatever is left and scrolling
     inside themselves. `flex: 0 0 auto` on the entry block rather than a fixed
     fraction — a hard 20% would clip it on exactly the laptops people read
     this on. */
  body {
    margin: 0; display: flex; min-height: 100dvh;
    background: var(--bg); color: var(--ink);
    font: 14.5px/1.55 var(--sans); -webkit-font-smoothing: antialiased;
  }
  /* **To change the picture, change this one line.** It is a custom property
     for exactly that reason, so the asset is named ONCE and not scattered
     through the rules that use it. The file is produced from the repository's
     own `.nornir/assets/holger-znippy-logo.png` by `holger-ops logo` — pure
     Rust, lossless VP8L — and served by this same door at `/holger.webp`. */
  :root { --backdrop-image: url("/holger.webp"); }
  /* Full bleed, full strength, no dimming and no blur: it has no text over it
     on a desktop — that is the point of giving it a half of its own — so
     nothing here is protecting anything and every filter would only cost the
     picture. The last 90 pixels dissolve into the page's own ground. */
  .art {
    flex: 0 0 50%;
    background-color: var(--bg);
    background-image:
      linear-gradient(to right, rgb(var(--bg-rgb) / 0) calc(100% - 90px), rgb(var(--bg-rgb) / 1)),
      var(--backdrop-image);
    background-size: auto, contain;
    background-position: center, center;
    background-repeat: no-repeat, no-repeat;
  }
  .side {
    flex: 1 1 50%; min-width: 0; height: 100dvh; overflow: hidden;
    display: flex; flex-direction: column; gap: 1.6rem;
    padding: clamp(1.25rem, 3.2vw, 2.75rem);
  }
  /* ── The entry block ───────────────────────────────────────────────────
     No border, no background, no shadow: it sits on the page. */
  .entry { flex: 0 0 auto; }
  .tagline {
    margin: 0 0 1.25rem; font-family: var(--display); font-size: 1.05rem;
    color: var(--ink-dim);
  }
  h1 {
    margin: 0 0 .35rem;
    font-family: var(--display);
    font-size: 2.1rem; font-weight: 500; letter-spacing: -.012em;
  }
  /* `margin-top: auto` is what puts the credit on the bottom edge, and it is
     the only thing that does: the column is a flex column, so the auto margin
     eats every pixel the entry block and the list did not claim. `flex: 0 0
     auto` so it is never the thing that shrinks when the list is long. */
  .by {
    flex: 0 0 auto;
    display: flex; align-items: center; flex-wrap: wrap; gap: .38rem;
    margin: auto 0 0; font-family: var(--display); font-size: .95rem; color: var(--ink-dim);
  }
  .by strong { font-weight: 600; color: var(--ink); }
  /* The release-signing note under the credit: small, dim, the id breaks anywhere
     so a phone never scrolls sideways for 64 hex digits. */
  .signing { flex: 0 0 auto; margin: .5rem 0 0; font-size: .8rem; line-height: 1.45; color: var(--ink-dim); }
  .signing h2 { margin: 0 0 .15rem; font-size: .72rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--ink); }
  .signing p { margin: 0; }
  .signing code { font-family: var(--mono); font-size: .92em; word-break: break-all; }
  .signing a { color: inherit; }
  .by a { color: inherit; border-radius: 3px; }
  /* The controls stay in the interface face. A serif button is a costume: it
     reads as an ornament where the eye is looking for something to press. */
  button {
    width: 100%; padding: .62rem .8rem; border-radius: 6px;
    border: 1px solid transparent; background: var(--raised); color: var(--ink);
    font: inherit; font-size: .92rem; font-weight: 550; cursor: pointer;
    transition: background .15s cubic-bezier(.16,1,.3,1), border-color .15s cubic-bezier(.16,1,.3,1);
  }
  button:hover:not(:disabled) { background: var(--raised); border-color: var(--ink-mute); }
  button:disabled { opacity: .45; cursor: not-allowed; }
  /* The primary action inverts rather than taking a hue, like the console's:
     dark-on-light turned over, a near-black button with the page's own colour
     as its ink. */
  button.primary { background: var(--ink); border-color: var(--ink); color: var(--surface); }
  button.primary:hover:not(:disabled) { background: oklch(0 0 0); border-color: oklch(0 0 0); }
  button.primary.working {
    background: var(--surface); color: var(--ink); border-color: var(--ink);
  }
  button.working:disabled { opacity: 1; cursor: progress; }
  /* The split row (A11 fix 1): "Sign up account" and the door, side by side and equal width,
     as the estate's other first page. The sign-up half is a link set as the row's second kind of button
     (the base `button` rule: filled, not inverted). */
  .entry-buttons { display: flex; gap: .6rem; }
  .entry-buttons > * { flex: 1 1 0; min-width: 0; }
  a.entry-button {
    display: block; box-sizing: border-box; text-align: center; text-decoration: none;
    padding: .62rem .8rem; border-radius: 6px;
    border: 1px solid transparent; background: var(--raised); color: var(--ink);
    font: inherit; font-size: .92rem; font-weight: 550; cursor: pointer;
    transition: border-color .15s cubic-bezier(.16,1,.3,1);
  }
  a.entry-button:hover { border-color: var(--ink-mute); }
  a.entry-button[hidden] { display: none; }
  :focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; border-radius: 5px; }
  .msg { margin-top: .7rem; min-height: 1.2rem; font-size: .82rem; color: var(--ink-dim); }
  /* A refusal is monospace and carries a code, because `holger-front-2` is
     greppable and "something went wrong" is not. Same ink as any other
     failure in this estate. */
  .msg.err { color: var(--bad); font-family: var(--mono); overflow-wrap: anywhere; }
  .msg.ok { color: var(--pass); }
  /* The lines under the button. Plain lines of text, because that is what they
     are. */
  .offer { margin: .9rem 0 0; color: var(--ink-dim); font-size: .86rem; }
  /* ── The enrolment panel ───────────────────────────────────────────────
     COLLAPSED, and collapsed is the whole design: enrolling a key is the rarer
     act and the one most readers will never perform, so it is a line of text
     until somebody asks for it. It is a `<details>` and not a second page for
     the reason the ceremony is not a second page — the estate's other console
     does exactly this, and the owner asked for the same thing here.

     It is also SERVER-GATED: the markup below is inert until `/-/front` hands
     over three enrolment paths, which only a server that actually serves them
     does. The airgapped appliance never does, so it never grows a form it
     cannot answer. */
  details.reg {
    margin-top: 1.1rem; padding-top: .9rem; border-top: 1px solid var(--line);
    font-size: .86rem;
  }
  details.reg[hidden] { display: none; }
  details.reg summary {
    cursor: pointer; color: var(--ink-dim); list-style: none;
  }
  details.reg summary::-webkit-details-marker { display: none; }
  /* The disclosure triangle is drawn rather than inherited, so it is the same
     shape in every engine and turns with the panel. */
  details.reg summary::before {
    content: "\25B8"; display: inline-block; width: 1em;
    transition: transform .15s cubic-bezier(.16,1,.3,1);
  }
  details.reg[open] summary::before { transform: rotate(90deg); }
  details.reg summary:hover { color: var(--ink); }
  .field { margin-top: .7rem; }
  .field[hidden] { display: none; }
  .field label { display: block; margin-bottom: .22rem; color: var(--ink-mute); font-size: .78rem; }
  .field input, .field select {
    width: 100%; padding: .45rem .55rem; border-radius: 5px;
    border: 1px solid var(--line-strong); background: var(--surface); color: var(--ink);
    font: inherit; font-size: .86rem;
  }
  .field input { font-family: var(--mono); }
  details.reg button { margin-top: .9rem; }
  .offer a { color: var(--ink); }
  .offer code { font-family: var(--mono); font-size: .92em; }
  /* ── The releases ──────────────────────────────────────────────────────
     Where a git forge's front page would list its public REPOSITORIES, holger lists
     the latest RELEASE in each of them: what is newest in this server, right
     now. Three columns and no fourth — repository, artifact, version. Size,
     date, checksum and content type all exist and are all one click away in
     the console; on the page a stranger reads first they are noise around the
     one fact that answers "what is in there".

     Not dimmed and not an aside: this is the second half of the offer — the
     entry block is what you do if the server is yours, this is what there is
     to see if it is not. */
  .browse { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: .55rem; }
  /* ── Black, and black on purpose ──────────────────────────────────────
     Not `--ink` (a warm near-black) and not `--ink-mute`: the heading and the
     rows are asked for at #000. This half of the page is what a stranger came
     for, and setting it two steps dimmer than the block above would say, in
     the only language a page has, that the releases are the aside. */
  .browse h2 {
    flex: 0 0 auto; margin: .2rem 0 0; font-size: .72rem; font-weight: 600;
    letter-spacing: .08em; text-transform: uppercase; color: #000;
  }
  #releases { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
  table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: .88rem; }
  /* The header stays while the body scrolls. A three-column table whose
     headings leave the screen is three columns of unlabelled strings. */
  thead th {
    position: sticky; top: 0; z-index: 1; background: var(--bg);
    text-align: left; font-weight: 600; font-size: .72rem; letter-spacing: .06em;
    text-transform: uppercase; color: var(--ink-mute);
    padding: .3rem .5rem; border-bottom: 1px solid var(--line);
  }
  tbody td {
    padding: .32rem .5rem; color: #000;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 0;
  }
  tbody tr { border-radius: 5px; }
  tbody tr:hover td { background: var(--raised); }
  /* The version column is the one the eye runs DOWN, so it is the one that is
     allowed to keep its full width; the two name columns share what is left.
     Right-aligned, because version numbers compare by their tails. */
  td.version, th.version { text-align: right; width: 1%; white-space: nowrap; max-width: none; }
  .browse[hidden] { display: none; }
  /* Said out loud rather than shown as an empty table: a server with nothing
     published is a normal state and an empty grid looks like a failure. */
  .empty { margin: .4rem .5rem; color: var(--ink-mute); font-size: .86rem; font-family: var(--sans); }
  /* ── The roster ────────────────────────────────────────────────────────
     The repositories a stranger may use, above the releases in them. `flex: 0
     0 auto` and a capped height: the roster is a short list that must not push
     the releases off the column, and a server with forty repositories gets a
     scrollbar rather than a page that is all roster.

     Names, not cards: each one is the first segment of a path on this server,
     so it is set in the same monospace as the table under it and reads as
     something you can type. The format is a second, dimmer word beside it —
     what a package manager must be to talk to that repository. */
  .roster { flex: 0 0 auto; max-height: 9.5rem; overflow-y: auto; margin: 0; padding: 0; list-style: none; }
  .roster li {
    display: flex; align-items: baseline; gap: .5rem;
    padding: .28rem .5rem; font-family: var(--mono); font-size: .88rem; color: #000;
  }
  .roster li:hover { background: var(--raised); border-radius: 5px; }
  .roster .fmt { color: var(--ink-mute); font-size: .78rem; }
  /* The package count sits on the right edge so the column of numbers lines
     up under itself, the way the version column does in the table. */
  .roster .count { margin-left: auto; color: var(--ink-mute); font-size: .78rem; }
  #repos { flex: 0 0 auto; }
  /* ── One column, ≤ 720px ───────────────────────────────────────────────
     The left half does not survive a phone: half a viewport of picture beside
     a half-width column is two unusable columns. So the picture stops being a
     column and becomes the ground the single column stands on — fixed behind
     everything, dimmed far enough that mono text at .88rem still reads over
     it, and out of the way of every tap. */
  @media (max-width: 720px) {
    body { display: block; }
    .art {
      position: fixed; inset: 0; z-index: 0; pointer-events: none;
      opacity: .16; filter: saturate(.9);
    }
    /* The column stops being a viewport-tall frame with a scrolling table
       inside it and becomes an ordinary page that scrolls: on a phone the
       entry block alone can be taller than the viewport, and a fixed-height
       column with `overflow: hidden` would simply cut the releases off. */
    .side {
      position: relative; z-index: 1; width: 100%;
      height: auto; min-height: 100dvh; overflow: visible;
      padding: 1.25rem 1.1rem; gap: 1.2rem;
    }
    #releases { overflow: visible; }
    /* **The controls get their edge back here, and only here.** A fill one
       step below the ground reads as a control because the ground is FLAT —
       true of the desktop, where the picture keeps to its own half. On a phone
       the picture IS the ground, dimmed but not uniform, and a 2 % difference
       in lightness disappears into the parts of it already darker than the
       page. So the phone spends the line the desktop does not need. */
    button { border-color: var(--line-strong); }
    thead th { background: var(--bg); }
  }
  @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
</style>
</head>
<body>
  <!-- THE LEFT HALF on a desktop; the ground behind the column on a phone. -->
  <div class="art" aria-hidden="true"></div>

  <div class="side">
  <main class="entry">
    <!-- No mark beside the title: the picture in the other half IS the brand
         mark, at half a viewport, and a 22-pixel badge beside it would be the
         same thing said twice and smaller. -->
    <h1>Holger</h1>
    <!-- What it is, in one line, under the name. Taken from the repository's
         own README and not written fresh for a web page: a product whose front
         page describes it differently from its readme is two products. -->
    <p class="tagline">Immutable artifact repository in pure&nbsp;Rust</p>

    <!-- ★ **THE BUTTON'S LABEL AND ITS EXISTENCE BOTH COME FROM THE SERVER.**
         It ships disabled and unlabelled-by-the-page, and `/-/front` decides.
         The reason is the one failure this page was warned about: holger-server
         has no browser session today — its doors are mTLS, OIDC and bearer, for
         `cargo`, `pip` and `docker` — so a page that shipped a borrowed "Log in"
         button would be offering a door that does not exist, over holger's
         own logo. If the server offers no browser login the page says so
         BY NAME, with a code, and leaves the button dead. -->
    <!-- ★ **TWO buttons side by side, equal width** (A11 fix 1, owner 2026-10-23: "no signup
         button at the front page"): the estate's first-page row, "Sign up account | Log in".
         "Sign up account" is a link drawn as a button, to the console's `/signup`, where a
         visitor chooses an account here or a private Holger; it ships hidden and is revealed
         only when holger's sign-up door (`/-/signup`) says the ONE shop sells one. The other
         half is the server's door, as before. Where nothing is sold the row is one button. -->
    <div class="entry-buttons">
    <a class="entry-button" id="signup-link" href="/signup?plan=account" hidden>Sign up account</a>
    <button id="enter" class="primary" disabled>&nbsp;</button>
    </div>
    <div id="msg" class="msg" role="status" aria-live="polite">reading the server …</div>

    <!-- What a visitor with no account can actually do here, which is the
         thing holger is for: point a package manager at it. The base URL is
         the server's, printed by the server. -->
    <p class="offer" id="offer" hidden>Point a package manager at <code id="base-url"></code></p>

    <!-- ★ **Registration does not move, and this line is why that is not a
         regression.** The first key comes from a bootstrap token and every key
         after it from an invite; both of those are the console's business, with
         its own roles and its own audit, and neither belongs on the page a
         stranger reads. So the ceremony is here and the enrolment is one link
         away — shown only when the ceremony is here, because on a server whose
         door is somewhere else there is nothing at this address to register
         against. -->
    <p class="offer" id="register-offer" hidden>No account here? An administrator mints you a single-use invite link.</p>

    <!-- ★ **ENROLMENT, ON THIS PAGE, COLLAPSED.**
         MEASURED on holger.rs 2026-09-20: the ceremony worked, called the
         door, and the door answered "no passkey is registered yet" — true,
         and a dead end, because nothing was enrolled and the only place to
         enrol was a second page again. A login with no way to mint the first
         key is one page short of useful.

         `hidden` until `/-/front` hands over three enrolment paths. A server
         that does not serve them — the airgapped appliance, any holger-server
         — never shows this, because it never answers `enrol`. -->
    <details class="reg" id="reg" hidden>
      <summary id="reg-summary">Register the first passkey</summary>

      <div class="field" id="token-row">
        <label for="token">Break-glass admin token</label>
        <input id="token" type="password" autocomplete="off" spellcheck="false"
               placeholder="HOLGER_UI_ADMIN_TOKEN">
      </div>
      <div class="field">
        <label for="user">User name</label>
        <input id="user" type="text" autocomplete="username" spellcheck="false"
               placeholder="lowercase, [a-z0-9._-]">
      </div>
      <div class="field">
        <label for="reg-label">Key label</label>
        <input id="reg-label" type="text" spellcheck="false" placeholder="yubikey">
      </div>
      <div class="field" id="role-row">
        <label for="role">Role</label>
        <select id="role">
          <option value="viewer">viewer — read the console</option>
          <option value="operator">operator — artifact work</option>
          <option value="admin">admin — promotion, replication, passkeys</option>
        </select>
      </div>

      <button id="register">Register this passkey</button>
    </details>
  </main>

  <!-- The repositories a stranger may use. Above the releases, because it is
       the shorter and the more general answer: a visitor who cannot find a
       package still needs to know what to point `cargo` or `mvn` at. Hidden
       until the catalogue answers with at least one, so a server with no
       roster shows no empty shell. -->
  <section class="browse" id="repos" hidden>
    <h2 id="repos-heading">Public repositories</h2>
    <ul class="roster" id="repos-list" aria-labelledby="repos-heading"></ul>
  </section>

  <!-- The latest release in each repository. Hidden until the door answers
       with at least one, so a server with nothing published shows no empty
       grid — the sentence under it says the state instead. -->
  <section class="browse" id="browse" hidden>
    <h2 id="releases-heading">Latest releases</h2>
    <div id="releases">
      <table aria-labelledby="releases-heading">
        <thead>
          <tr>
            <th scope="col">Repository</th>
            <th scope="col">Artifact</th>
            <th scope="col" class="version">Version</th>
          </tr>
        </thead>
        <tbody id="releases-body"></tbody>
      </table>
      <p class="empty" id="releases-empty" hidden></p>
    </div>
  </section>

  <!-- ★ What moved here lately.

       A DIFFERENT question from the table above, which is "the latest version
       of every package, in name order". Until an artifact carried a
       publication time there was no timestamp anywhere in the model to sort
       by, so the page showed the latest VERSION of each package and that was
       as close as it could get: a package that has not moved in a year read
       the same as one that moved this morning.

       Hidden unless the door answers with at least one row, and it legitimately
       answers with none — only some backends can put a time in a listing
       without reading the whole store, and a row whose backend could not is
       left out rather than dated 1970. A server that knows no times draws no
       section here at all, which is the truthful page for that server, and is
       the same choice the roster makes for a server with no artifacts. -->
  <section class="browse" id="recent" hidden>
    <h2 id="recent-heading">Recently changed</h2>
    <div id="recent-list">
      <table aria-labelledby="recent-heading">
        <thead>
          <tr>
            <th scope="col">Repository</th>
            <th scope="col">Artifact</th>
            <th scope="col" class="version">Version</th>
            <th scope="col">Published</th>
          </tr>
        </thead>
        <tbody id="recent-body"></tbody>
      </table>
    </div>
  </section>

  <!-- Who stands behind it, at the foot of the column. The names are the
       repository's own LICENSE and the forge it is published from — not a
       company invented for a web page. `margin-top: auto` and not a position,
       so this line sits on the bottom edge whether the table above has rows or
       none, with no second rule for the empty server. -->
  <p class="by">
    brought to you by
    <!-- A WORDMARK, not a square avatar like Ignalina's, so it is sized by
         height only: 18 px to match the Ignalina mark, width from the SVG's own
         1380x260 viewBox (5.31:1 -> 96 px). Setting both to 18 would squash
         five letters into a square. -->
    <a class="maker" href="https://www.vetra.se" target="_blank" rel="noopener noreferrer">
      <img class="mark" src="/vetra.svg" width="96" height="18" alt="" aria-hidden="true">
      <strong>Vetra AB</strong>
    </a>
    &amp;
    <img class="mark" src="/ignalina.png" width="18" height="18" alt="" aria-hidden="true">
    <strong>Ignalina ApS</strong>
  </p>

  <!-- How the releases are signed, at the very foot, under the credit (the credit's
       `margin-top: auto` keeps both on the bottom edge). The slot below is filled by
       the door (`holger_front::page`) from `RELEASE_KEY_ID`, so the key id is written
       once, in the crate, and not here. -->
  <!--release-signing-->
  </div>

<script>
// Everything below writes with `textContent` and property assignment, and
// never assigns markup from data. A repository name, an artifact name and a
// version string are all operator-supplied text that arrives over a socket, and this page's
// inline script is hashed into the CSP — text that becomes markup would be the
// one hole in it.
const msg = document.getElementById('msg');
function show(text, cls) { msg.textContent = text; msg.className = 'msg ' + (cls || ''); }

// Unix milliseconds -> a readable UTC stamp, or the empty string.
//
// UTC and not the reader's zone: this is a server's fact about when a thing was
// published, the same stamp for everyone who loads the page, and a reader
// comparing it with a log line or a colleague's screen should see one value.
// The zone is printed rather than implied, so nobody has to guess.
//
// Anything that is not a positive finite number prints as nothing. A `0`
// arriving here would mean some peer along the way turned an unknown into a
// value, and 1970 on a front page is a bug that looks like data — so the page
// refuses it too, the same rule the server applies.
function stamp(ms) {
  const n = Number(ms);
  if (!isFinite(n) || n <= 0) return '';
  const iso = new Date(n).toISOString();
  return iso.slice(0, 10) + ' ' + iso.slice(11, 16) + ' UTC';
}

// ★ **The invite is read once, and then it is not in the URL any more.**
//
// An invite arrives 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. Nothing below
// re-reads `location.search`, and it could not: after this line there is
// nothing there.
const INVITE = (function () {
  try {
    const params = new URLSearchParams(location.search);
    const secret = params.get('invite') || '';
    if (secret) {
      params.delete('invite');
      const rest = params.toString();
      history.replaceState(null, '', location.pathname + (rest ? '?' + rest : ''));
    }
    return secret;
  } catch (e) { return ''; }
})();

// ── The sign-up offer (lane SU1) ─────────────────────────────────────────────
// Holger's door answers what the ONE shop sells for this site; nothing sold, nothing shown.
(function signupOffer() {
  fetch('/-/signup', { credentials: 'omit' })
    .then(r => r.ok ? r.json() : null)
    .then(links => {
      if (links && (links.account_url || links.private_offered)) document.getElementById('signup-link').hidden = false;
    })
    .catch(() => {});
})();

// ── The entry block ──────────────────────────────────────────────────────────
// ★ **A blank entry block must say why.** Every way `/-/front` can fail to
// answer — the door absent on an older build (404), reads gated behind a
// credential (401/403), the server simply gone — looks identical from a chair:
// a dead button and no words. Each one gets its own sentence and its own code,
// because "holger-front-3" is greppable and a grey rectangle is not.
(function entry() {
  const btn = document.getElementById('enter');
  fetch('/-/front', { credentials: 'omit' })
    .then(r => {
      if (r.status === 401 || r.status === 403) {
        throw new Error('holger-front-3: this server gates reads behind a credential, so its front page cannot name itself. Configure `require_auth_for_reads: false`, or read the console instead.');
      }
      if (r.status === 404) {
        throw new Error('holger-front-4: this build of holger-server has no `/-/front` door. The page is newer than the server behind it.');
      }
      if (!r.ok) throw new Error('holger-front-4: /-/front answered ' + r.status);
      return r.json();
    })
    .then(front => {
      if (front.base_url) {
        document.getElementById('base-url').textContent = front.base_url;
        document.getElementById('offer').hidden = false;
      }
      // The login door, if the server has one. `null` is the honest answer
      // today and it is ANSWERED, not omitted: a page that treated a missing
      // field as "no login" could not tell an old server from a locked one.
      if (front.login && front.login.label) {
        btn.textContent = front.login.label;
        btn.disabled = false;
        btn.dataset.start = front.login.start || '';
        // ★ THE THIRD STATE. `door: 'here'` means the ceremony runs on this
        // page and the button is not a link at all — see `ceremony()` below.
        // Anything else, including a document written before the field
        // existed, is the link this page has always followed.
        if (front.login.door === 'here' && front.login.finish) {
          btn.dataset.door = 'here';
          btn.dataset.finish = front.login.finish;
          btn.dataset.next = front.login.next || '/';
          enrolment(front.login.enrol);
          // ★ HOL-LOGIN-LOOP (holger.rs 2026-10-30, "login → login again"): a person
          // who is signed in and comes back here (typed, the console's brand link, Back)
          // 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), and a session turns the button into the
          // console's, in the server's words (`signed_in`, `signed_in_as`). It never
          // navigates by itself: the press does.
          if (front.login.signed_in) {
            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();
                btn.dataset.door = 'console';
                btn.textContent = front.login.signed_in;
                if (who && front.login.signed_in_as) show(front.login.signed_in_as + who + '.', 'ok');
              })
              .catch(() => {});
          }
        }
        show('');
      } else {
        // The named refusal. The button STAYS dead and STAYS labelled, so
        // nobody presses a door that is not there and nobody wonders what the
        // grey rectangle was for.
        btn.textContent = 'No browser login';
        show('holger-front-2: this server offers no browser login. Its doors are mTLS, OIDC and bearer — for cargo, pip, docker and the console, not for a tab.', 'err');
      }
    })
    .catch(e => {
      btn.textContent = 'Unavailable';
      show(String(e && e.message || e), 'err');
    });

  btn.onclick = function () {
    // Signed in (HOL-LOGIN-LOOP): the console, with no second ceremony.
    if (btn.dataset.door === 'console') { if (rooted(btn.dataset.next)) location.href = btn.dataset.next; return; }
    const start = btn.dataset.start;
    // Only a same-site rooted path is used, whichever door this is. A server
    // that answered with an off-site URL cannot turn this button into an open
    // redirect, and — the half that is new — cannot make this page POST a
    // signed assertion to a stranger either.
    if (!rooted(start)) return;
    if (btn.dataset.door === 'here') { ceremony(btn); return; }
    location.href = start;
  };
  // The sign-in's start, asked as the person reaches for Log in (RA2-6): never on a bare view.
  for (const ev of ['pointerenter', 'focus', 'touchstart']) {
    btn.addEventListener(ev, () => { if (btn.dataset.door === 'here') prepareLogin(btn); }, { passive: true });
  }
})();

// The rule, once, for every path this page is handed. One leading slash, no
// authority, and no character the URL parser strips before it decides what the
// value even is: `//host` and `/\host` are off-site in every engine, and a
// value carrying a tab, a newline or a NUL is one whose meaning is settled
// after those are removed. `holger_front::same_site_rooted_path` is this rule
// in Rust and a test asserts the two refuse the same values.
function rooted(start) {
  if (typeof start !== 'string' || start === '') return false;
  if (!start.startsWith('/') || start.startsWith('//') || start.startsWith('/\\')) return false;
  if (/[\x00-\x20\x7f]/.test(start)) return false;
  return true;
}

// ── The ceremony, on this page ───────────────────────────────────────────────
// ★ **There is no second page.** The button used to send a reader to `/login`,
// which showed them the same title, the same picture and the same button, and
// the login happened there. So the page asked for the same press twice to reach
// one ceremony. The ceremony is sixty lines of `fetch` and one call to the
// authenticator; it is here, and `/login` is now only where a key is ENROLLED.
//
// Carried over from the estate's other console without changing the wire: both
// consoles run the same ceremony, so a defect found in one is a defect fixed in
// both.
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);
  // `=` is stripped globally rather than anchored at the end: base64 emits it
  // only as trailing padding, so the two are the same operation here — and the
  // anchored form spells a character this page is tested for never carrying,
  // because it is also a currency sign.
  return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

// ── Enrolment ────────────────────────────────────────────────────────────────
// ★ **The panel exists only when the server named all three paths.** `enrol` is
// absent on the appliance and on every holger-server, so the form is never
// drawn where it could not be answered — the markup above ships `hidden` and
// nothing but this function takes that away.
function enrolment(enrol) {
  const panel = document.getElementById('reg');
  if (!panel || !enrol) return;
  if (!rooted(enrol.start) || !rooted(enrol.finish) || !rooted(enrol.status)) return;
  panel.dataset.start = enrol.start;
  panel.dataset.finish = enrol.finish;
  const offer = document.getElementById('register-offer');
  if (offer) offer.hidden = false;
  panel.hidden = false;
  const button = document.getElementById('register');
  if (button) button.onclick = register;

  if (INVITE) {
    // The link IS the credential: open the form, drop the two controls the
    // SERVER decides, and say what this page is about to do.
    panel.open = true;
    const token = document.getElementById('token-row');
    const role = document.getElementById('role-row');
    if (token) token.hidden = true;
    if (role) role.hidden = true;
    const summary = document.getElementById('reg-summary');
    if (summary) summary.textContent = 'Register your passkey';
    show('you were invited — pick a user name and register your passkey', 'ok');
    return;
  }

  // ★ **Once an admin exists, the break-glass door is not offered.** The panel
  // names an admin token because the FIRST key is registered with one; the
  // moment an admin passkey exists that token is spent and the offer is a lie
  // to everyone who reads it. Cookie-less, like the catalogue: this is a fact
  // about the console, not about the caller. An invited visitor never reaches
  // here — they were invited, and the server, not the page, decides their role.
  fetch(enrol.status, { credentials: 'omit' })
    .then(r => (r.ok ? r.json() : null))
    .then(s => {
      if (s && s.bootstrap_spent === true) {
        panel.hidden = true;
        if (offer) offer.hidden = false;
      }
    })
    .catch(() => {});
}

async function register() {
  const btn = document.getElementById('register');
  const panel = document.getElementById('reg');
  const start = panel.dataset.start, finish = panel.dataset.finish;
  if (!rooted(start) || !rooted(finish)) return;
  btn.disabled = true;
  const token = document.getElementById('token').value.trim();
  const label = document.getElementById('reg-label').value.trim() || 'passkey';
  const user = document.getElementById('user').value.trim();
  const role = document.getElementById('role').value;
  // The admin token is sent ONLY when there is no invite: an invited visitor
  // has the credential in the link and has no token to give.
  const hdr = (!INVITE && token) ? { 'Authorization': 'Bearer ' + token } : {};
  try {
    if (!window.PublicKeyCredential || !navigator.credentials) {
      throw new Error('this browser has no passkey support');
    }
    show('starting registration …');
    const params = new URLSearchParams();
    if (user) params.set('user', user);
    if (INVITE) params.set('invite', INVITE);
    const qs = params.toString();
    const res = await fetch(start + (qs ? '?' + qs : ''), { headers: hdr, credentials: 'same-origin' });
    if (!res.ok) throw new Error(await res.text() || 'requires a valid invite or admin token');
    const begin = await res.json();
    const pk = begin.publicKey;
    pk.challenge = b64urlToBuf(pk.challenge);
    pk.user.id = b64urlToBuf(pk.user.id);
    pk.excludeCredentials = (pk.excludeCredentials || []).map(c => ({ ...c, id: b64urlToBuf(c.id) }));
    const cred = await navigator.credentials.create({ publicKey: pk });
    const body = { ceremony_id: begin.ceremony_id, label: label, user: user, role: role, response: {
      clientDataJSON: bufToB64url(cred.response.clientDataJSON),
      attestationObject: bufToB64url(cred.response.attestationObject),
    }};
    if (INVITE) body.invite = INVITE;
    const fin = await fetch(finish, {
      method: 'POST', credentials: 'same-origin',
      headers: { ...hdr, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
    if (fin.ok) show('passkey registered — you can log in now.', 'ok');
    else show(await fin.text() || 'registration failed', 'err');
  } catch (e) { show(e.message || String(e), 'err'); }
  finally { btn.disabled = false; }
}

// ★ **The start before the press** (RA2-6, the console's own rule, HOLDIRECT 2026-10-28). A
// browser makes a passkey only inside the person's gesture, and Chromium keeps a press live for
// 5 s: a press that FIRST asked holger and THEN asked the device could reach the device with no
// gesture left. So the start is asked as the person reaches for Log in, kept under PREPARE_MS
// (holger keeps a ceremony 5 min) and used once; a press that finds it still on its way waits
// for it and, with the gesture gone, asks for one more press instead of a ceremony nobody
// pressed for. holger's start is a POST with a JSON body.
const PREPARE_MS = 4 * 60 * 1000;
const PRESS_AGAIN = 'Ready. Touch your security key again.';
let loginReady = null;
function gestureLive() { return !navigator.userActivation || navigator.userActivation.isActive; }
// 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 prepareLogin(btn) {
  const start = btn.dataset.start;
  if (!rooted(start)) return null;
  if (loginReady && loginReady.start === start && Date.now() - loginReady.at < PREPARE_MS) return loginReady;
  const p = { start: start, at: Date.now(), answer: null };
  p.done = fetch(start, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: '{}' })
    .then(async res => (res.ok ? { ok: true, begin: await res.json() } : { ok: false, words: await refusal(res) }))
    .catch(e => ({ ok: false, error: e }))
    .then(answer => { p.answer = answer; return answer; });
  loginReady = p;
  return p;
}

// What a person reads when no passkey answered (the device's NotAllowedError, a cancel or a
// device with no passkey here): where a passkey comes from, and for a newcomer the way in. The
// console's own words (holger-ui `noPasskeyHere`).
function noPasskeyHere() {
  const signup = document.getElementById('signup-link');
  return 'No passkey for this site on this device. Use the phone, computer or password manager where you made your passkey. '
    + (signup && !signup.hidden ? 'New here? Press Sign up account.' : 'New here? Ask whoever runs this Holger for an invite link.');
}
// ★ **Never the browser's own words**: a ceremony's DOMException is said in plain words.
const PASSKEY_WORDS = {
  AbortError: 'The passkey request was cancelled. Try again.',
  SecurityError: 'Passkeys do not work at this address. Open the page at its own https address.',
  NotSupportedError: 'This browser or key cannot use a passkey here. Try another browser or key.',
};
function said(e) {
  if (e && e.name === 'NotAllowedError') return noPasskeyHere();
  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);
}

async function ceremony(btn) {
  // 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 the server's word is what comes back.
  const label = btn.textContent;
  const finish = btn.dataset.finish;
  const next = rooted(btn.dataset.next) ? btn.dataset.next : '/';
  if (!rooted(btn.dataset.start) || !rooted(finish)) return;
  btn.disabled = true;
  btn.classList.add('working');
  btn.textContent = 'Processing …';
  try {
    if (!window.PublicKeyCredential || !navigator.credentials) {
      throw new Error('this browser has no passkey support');
    }
    show('waiting for your key …');
    const p = prepareLogin(btn);
    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;
    // ★ The body of a refusal is the message. holger's ceremony answers in
    // plain words — "no passkey is registered yet" — and a page that replaced
    // that with "login failed" would be hiding the one sentence that says what
    // to do next.
    if (!answer.ok) throw new Error(answer.words || 'could not start');
    const begin = answer.begin;
    const pk = begin.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: begin.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(finish, {
      method: 'POST', credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
    // The session cookie is set by the response to THIS request (the console's
    // server holds it, HttpOnly), so the navigation below is the first thing that carries it.
    if (fin.ok) { show('logged in — opening the console …', 'ok'); location.href = next; }
    else show(await refusal(fin) || 'login was denied', 'err');
  } catch (e) { show(said(e), 'err'); }
  finally {
    btn.disabled = false;
    btn.classList.remove('working');
    btn.textContent = label;
  }
}

// ── The three columns ────────────────────────────────────────────────────────
// ★ **The page computes nothing.** It does not rank versions, it does not pick
// a winner, it does not sort. `/-/releases` hands it rows that are already the
// latest, in the order the server chose, and every string here is printed
// exactly as it arrived. A version number decided in JavaScript would be a
// second source of truth for the one fact this table exists to state — and it
// would disagree with `holger retention` the first time somebody published
// `1.10.0` next to `1.9.0`.
(function catalogue() {
  const panel = document.getElementById('browse');
  const body = document.getElementById('releases-body');
  const empty = document.getElementById('releases-empty');
  const repos = document.getElementById('repos');
  const roster = document.getElementById('repos-list');
  if (!panel || !body) return;
  fetch('/-/releases', { credentials: 'omit' })
    .then(r => (r.ok ? r.json() : null))
    .then(doc => {
      if (!doc || typeof doc !== 'object') return;
      // ★ The roster is drawn first and separately, because a server can have
      // repositories and nothing published in them — which is exactly what a
      // fresh install and an airgap store waiting for its first bundle look
      // like. That state used to show one sentence and no names at all.
      const names = doc.repositories;
      if (roster && repos && Array.isArray(names) && names.length > 0) {
        for (const r of names) {
          const li = document.createElement('li');
          const name = document.createElement('span');
          name.textContent = String(r.name === undefined ? '' : r.name);
          li.appendChild(name);
          if (r.format) {
            const fmt = document.createElement('span');
            fmt.className = 'fmt';
            fmt.textContent = String(r.format);
            li.appendChild(fmt);
          }
          const count = document.createElement('span');
          count.className = 'count';
          const n = r.packages;
          count.textContent = (n === 1 ? '1 package' : String(n === undefined ? 0 : n) + ' packages');
          li.appendChild(count);
          roster.appendChild(li);
        }
        repos.hidden = false;
      }

      // ★ "Recently changed", drawn from its own list.
      //
      // The server ranks it — this page computes no ordering, which is the one
      // rule of the front crate — so the rows arrive newest first and are
      // printed in the order they came. The only thing done to a value here is
      // rendering a number of milliseconds as a readable UTC stamp.
      //
      // A row that reached this list HAS a time, because the server left the
      // unknown ones out. The guard below is still here because this page
      // reads a document from a socket and a value it did not expect must
      // print as nothing rather than as "Invalid Date".
      const recent = doc.recent;
      const recentPanel = document.getElementById('recent');
      const recentBody = document.getElementById('recent-body');
      if (recentPanel && recentBody && Array.isArray(recent) && recent.length > 0) {
        for (const row of recent) {
          const tr = document.createElement('tr');
          for (const [key, cls] of [['repository', ''], ['artifact', ''], ['version', 'version']]) {
            const td = document.createElement('td');
            if (cls) td.className = cls;
            td.textContent = String(row[key] === undefined ? '' : row[key]);
            td.title = td.textContent;
            tr.appendChild(td);
          }
          const when = document.createElement('td');
          when.textContent = stamp(row.published_unix_ms);
          when.title = when.textContent;
          tr.appendChild(when);
          recentBody.appendChild(tr);
        }
        recentPanel.hidden = false;
      }

      const rows = doc.releases;
      panel.hidden = false;
      if (!Array.isArray(rows) || rows.length === 0) {
        // A server with nothing published is a NORMAL state — a fresh install,
        // or an airgap store waiting for its first bundle — and an empty grid
        // reads as a failure. So the table goes and a sentence stays.
        document.querySelector('#releases table').hidden = true;
        empty.textContent = 'Nothing published yet.';
        empty.hidden = false;
        return;
      }
      for (const row of rows) {
        const tr = document.createElement('tr');
        for (const [key, cls] of [['repository', ''], ['artifact', ''], ['version', 'version']]) {
          const td = document.createElement('td');
          if (cls) td.className = cls;
          td.textContent = String(row[key] === undefined ? '' : row[key]);
          td.title = td.textContent;
          tr.appendChild(td);
        }
        body.appendChild(tr);
      }
    })
    .catch(() => {
      // Silent, and the section stays hidden. A front page that shows a red
      // error because an optional list did not load is worse than one that
      // shows no list: the entry block above already says whether the SERVER
      // is reachable, and saying it twice in two different words is how a
      // reader learns to distrust both.
    });
})();
</script>
</body>
</html>