holger-front 0.1.2

holger.rs's public front page: the page itself, the three-column release row, and the whole door as one pure function
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
<!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); }
  .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; }
  :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; }
  .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. -->
    <button id="enter" class="primary" disabled>&nbsp;</button>
    <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 passkey on this console yet? <a id="register-link" href="/login">Register one</a></p>
  </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>

  <!-- 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>
  </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 || ''); }

// ── 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 stays at the console's own page, and the link points at
          // the same place the link door would have gone.
          const reg = document.getElementById('register-offer');
          const link = document.getElementById('register-link');
          if (reg && link && rooted(front.login.start)) {
            link.href = '/login';
            reg.hidden = false;
          }
        }
        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 () {
    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 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, '');
}

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 start = btn.dataset.start, finish = btn.dataset.finish;
  const next = rooted(btn.dataset.next) ? btn.dataset.next : '/';
  if (!rooted(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 …');
    // ★ 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.
    const res = await fetch(start, { credentials: 'same-origin' });
    if (!res.ok) throw new Error(await res.text() || 'could not start');
    const begin = await res.json();
    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, 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 fin.text() || 'login was denied', 'err');
  } catch (e) { show(e.message || String(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;
      }

      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>