pmtiles-swarm 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -58,12 +58,33 @@
58
58
  `/api/categories`, filtered by the same `feedCategories` rule, so it can show nothing that was
59
59
  not already published — and it is not the console, which stays on the admin port.
60
60
  `publicIndex: false` turns it off, withdrawing the three paths it needs with it.
61
+ - **`GET /latest/` lists the categories, without a credential.** Everything else under
62
+ `/latest/` is public — the TileJSON, the torrent, the magnet, the per-category feed — so the
63
+ index of what it offers belongs beside them rather than behind the console's door. The public
64
+ front page now leads with categories rather than only listing archives, which is the more
65
+ useful handle for a visitor: an archive URL names one build and goes stale on the next, while a
66
+ category names whichever is current. Same builder as `/api/categories`, so the two cannot drift
67
+ apart. The page's footer and its noscript block no longer link `/api/` paths that answer 401 to
68
+ exactly the audience they are shown to.
69
+ - **The feed carries the BEP 46 identity.** `<pmtiles:mutable>` holds the magnet naming an
70
+ archive's publishing key, so a consumer following a category across rebuilds can read the
71
+ public key straight out of the feed instead of fetching a TileJSON to find it — the key rides
72
+ inside the string as `xs=urn:btpk:`. Absent for archives that have no identity, since an empty
73
+ element would be a claim.
61
74
  - **Lint and format tooling.** `npm run lint`, `lint:fix`, `format`, `format:check`, `tidy` and
62
75
  `check`, wired into CI. There was no linter before, though the source carried
63
76
  `eslint-disable` comments for one — so those suppressed nothing and the rules they named were
64
77
  never checked.
65
78
 
66
79
  ### 🐞 Bug fixes
80
+ - **The public front page could not read its own catalogue.** `/api/catalog` is on the list of
81
+ paths that belong on a public listener, but that is a separate gate from the credential check,
82
+ which guards everything under `/api/` except login and session — so on any node with
83
+ authentication configured the page answered "catalog said 401" and listed nothing. It now
84
+ prefers the catalogue, which carries web seeds and the sparse flag, and falls back to
85
+ `/feed.xml`, which needs no credential and says the same things: name, infohash, magnet, size,
86
+ categories, format and zoom range. Deliberately not fixed by making the catalogue
87
+ world-readable, which would undo a decision the operator made on purpose.
67
88
  - **WebTorrent's `pieces()` was defined twice.** The later definition wins, so the first had
68
89
  never run — which is why it still called an undefined `countHeld` and no test noticed.
69
90
  - **A stuck HTTP connection could keep a shutdown waiting.** `closeServer` armed its force-close
@@ -41,6 +41,27 @@ swarm on every attempt, doing work that could never succeed.
41
41
 
42
42
  See [internals.md](internals.md#serving-an-mbtiles-archive).
43
43
 
44
+ ## The category index
45
+
46
+ `GET /latest/` lists every category this node publishes, with the endpoints
47
+ that resolve to each one's newest build — the TileJSON, the `.torrent`, the
48
+ magnet, the per-category feed, and the style URL with the magnet in its
49
+ fragment.
50
+
51
+ Public, and deliberately so. Everything else under `/latest/` is — the
52
+ TileJSON, the torrent, the magnet, the feed — so the index of what `/latest/`
53
+ offers belongs beside them rather than behind the console's door.
54
+ `/api/categories` returns the identical list from the identical builder, and is
55
+ guarded only because everything under `/api/` is.
56
+
57
+ A category is also the handle worth publishing. An archive URL names one build
58
+ and goes stale on the next; a category names whichever is current, so a style
59
+ pointing at one keeps working across rebuilds.
60
+
61
+ Filtered by `feedCategories` and by whatever token was presented, the same as
62
+ the catalogue and the feeds — so it can show nothing this node was not already
63
+ publishing.
64
+
44
65
  ## Missing tiles
45
66
 
46
67
  A tile the archive does not hold answers **404 for raster and 204 for vector**,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/api.js CHANGED
@@ -1744,9 +1744,19 @@ export function createApp({
1744
1744
  // what makes a tile immutable, but leaves nothing for a style to point at
1745
1745
  // that survives a rebuild — and until now the endpoints that give you one
1746
1746
  // were documented rather than discoverable.
1747
- app.get(
1748
- '/api/categories',
1749
- route(async (req, res) => {
1747
+ /**
1748
+ * Every category, with the endpoints that resolve to its newest build.
1749
+ *
1750
+ * Shared by the guarded `/api/categories` and the public `/latest/`, which
1751
+ * want exactly the same answer — one is the console asking and the other is
1752
+ * a stranger, and neither is told anything the other is not. Filtering runs
1753
+ * through `publishesEntry`, so `feedCategories` and whatever token was
1754
+ * presented decide what appears, the same as everywhere else.
1755
+ * @param {import('express').Request} req - The request, for its credential and base URL.
1756
+ * @returns {object[]} - One row per category, sorted by name.
1757
+ */
1758
+ const describeCategories = (req) => {
1759
+ {
1750
1760
  const base = baseUrl(req);
1751
1761
  const visible = catalog
1752
1762
  .list()
@@ -1759,51 +1769,82 @@ export function createApp({
1759
1769
  }
1760
1770
  }
1761
1771
 
1762
- res.json(
1763
- [...counts.keys()].sort().map((category) => {
1764
- const newest = newestIn(category, req);
1765
- // Only PMTiles has tiles to serve, so a category whose newest build
1766
- // is an MBTiles archive gets a feed and a torrent but no tile
1767
- // endpoint — the same rule as an individual archive.
1768
- const servable =
1769
- Boolean(newest?.pmtiles) &&
1770
- (newest.kind ?? 'pmtiles') === 'pmtiles';
1771
-
1772
- return {
1773
- category,
1774
- archives: counts.get(category),
1775
- newest: newest && {
1776
- infoHash: newest.infoHash,
1777
- name: newest.name,
1778
- size: newest.size,
1779
- createdAt: newest.createdAt,
1780
- kind: newest.kind ?? 'pmtiles',
1781
- },
1782
- servable,
1783
- endpoints: {
1784
- tileJson: servable
1785
- ? `${base}/latest/${category}/tiles.json`
1786
- : null,
1787
- // The same URL with a magnet in the fragment, which is what a
1788
- // style should carry. A fragment is never sent in a request, so
1789
- // an ordinary client fetches the TileJSON and ignores it, while
1790
- // a swarm-aware one has the magnet before it makes any call --
1791
- // and can therefore still start when this server cannot answer.
1792
- //
1793
- // The mutable magnet where there is one, because a category is
1794
- // precisely where an infohash goes stale: it names the category
1795
- // and resolves the current build over the DHT. Otherwise the
1796
- // newest build's own magnet, which pins that build but still
1797
- // beats a blank map when the fallback is needed at all.
1798
- styleUrl: servable ? styleUrlFor(category, newest, base) : null,
1799
- torrent: `${base}/latest/${category}/archive.torrent`,
1800
- magnet: `${base}/latest/${category}/magnet`,
1801
- feed: `${base}/feed/${category}.xml`,
1802
- latestFeed: `${base}/latest/${category}.xml`,
1803
- },
1804
- };
1805
- }),
1806
- );
1772
+ return [...counts.keys()].sort().map((category) => {
1773
+ const newest = newestIn(category, req);
1774
+ // Only PMTiles has tiles to serve, so a category whose newest build
1775
+ // is an MBTiles archive gets a feed and a torrent but no tile
1776
+ // endpoint — the same rule as an individual archive.
1777
+ const servable =
1778
+ Boolean(newest?.pmtiles) && (newest.kind ?? 'pmtiles') === 'pmtiles';
1779
+
1780
+ return {
1781
+ category,
1782
+ archives: counts.get(category),
1783
+ newest: newest && {
1784
+ infoHash: newest.infoHash,
1785
+ name: newest.name,
1786
+ size: newest.size,
1787
+ createdAt: newest.createdAt,
1788
+ kind: newest.kind ?? 'pmtiles',
1789
+ },
1790
+ servable,
1791
+ endpoints: {
1792
+ tileJson: servable ? `${base}/latest/${category}/tiles.json` : null,
1793
+ // The same URL with a magnet in the fragment, which is what a
1794
+ // style should carry. A fragment is never sent in a request, so
1795
+ // an ordinary client fetches the TileJSON and ignores it, while
1796
+ // a swarm-aware one has the magnet before it makes any call --
1797
+ // and can therefore still start when this server cannot answer.
1798
+ //
1799
+ // The mutable magnet where there is one, because a category is
1800
+ // precisely where an infohash goes stale: it names the category
1801
+ // and resolves the current build over the DHT. Otherwise the
1802
+ // newest build's own magnet, which pins that build but still
1803
+ // beats a blank map when the fallback is needed at all.
1804
+ styleUrl: servable ? styleUrlFor(category, newest, base) : null,
1805
+ torrent: `${base}/latest/${category}/archive.torrent`,
1806
+ magnet: `${base}/latest/${category}/magnet`,
1807
+ feed: `${base}/feed/${category}.xml`,
1808
+ latestFeed: `${base}/latest/${category}.xml`,
1809
+ },
1810
+ };
1811
+ });
1812
+ }
1813
+ };
1814
+
1815
+ // The console's view. Identical to the public one below, and guarded only
1816
+ // because everything under /api/ is.
1817
+ app.get(
1818
+ '/api/categories',
1819
+ route(async (req, res) => {
1820
+ res.json(describeCategories(req));
1821
+ }),
1822
+ );
1823
+
1824
+ // The same list, without a credential.
1825
+ //
1826
+ // Everything under /latest/ is public already — the TileJSON, the torrent,
1827
+ // the magnet, the per-category feed — so the index of what /latest/ offers
1828
+ // belongs here rather than behind the console's door. A category is also the
1829
+ // handle a person actually wants: an infohash names one build, and this
1830
+ // names whichever is current, so a style pointing at it keeps working across
1831
+ // rebuilds.
1832
+ //
1833
+ // Not a second implementation. It is the same builder, filtered by the same
1834
+ // rule, so the two cannot drift into disagreeing about what is published.
1835
+ app.get(
1836
+ '/latest/',
1837
+ route(async (req, res) => {
1838
+ res.setHeader('access-control-allow-origin', '*');
1839
+ // Short, because the answer changes when a build lands.
1840
+ res.setHeader('cache-control', 'public, max-age=60');
1841
+ res.json({
1842
+ // Named so a consumer can refuse a document it does not understand,
1843
+ // the same as the catalogue does.
1844
+ format: 'pmtiles-swarm-categories/1',
1845
+ generatedAt: new Date().toISOString(),
1846
+ categories: describeCategories(req),
1847
+ });
1807
1848
  }),
1808
1849
  );
1809
1850
 
package/src/feed.js CHANGED
@@ -17,6 +17,8 @@
17
17
  * subscriber restores it on completion.
18
18
  */
19
19
 
20
+ import { mutableMagnet } from './mutable.js';
21
+
20
22
  const PMTILES_NS = 'https://github.com/TechIdiots-LLC/pmtiles-swarm/ns/1.0';
21
23
 
22
24
  /**
@@ -118,6 +120,18 @@ ${(entry.categories ?? (entry.category ? [entry.category] : []))
118
120
  <enclosure url="${xml(torrentUrl)}" length="${xml(entry.size)}" type="application/x-bittorrent"/>
119
121
  <pmtiles:infohash>${xml(entry.infoHash)}</pmtiles:infohash>
120
122
  <pmtiles:magnet>${xml(entry.magnet)}</pmtiles:magnet>
123
+ ${
124
+ entry.mutable?.publicKey
125
+ ? ` <pmtiles:mutable>${xml(
126
+ mutableMagnet(entry.mutable.publicKey, {
127
+ infoHash: entry.infoHash,
128
+ salt: entry.mutable.salt,
129
+ name: entry.mutable.salt ?? entry.name,
130
+ webSeeds: entry.webSeeds,
131
+ }),
132
+ )}</pmtiles:mutable>`
133
+ : ''
134
+ }
121
135
  ${entry.md5 ? ` <pmtiles:md5>${xml(entry.md5)}</pmtiles:md5>` : ''}
122
136
  ${entry.originMtime ? ` <pmtiles:mtime>${xml(entry.originMtime)}</pmtiles:mtime>` : ''}
123
137
  ${mapFields}
@@ -150,6 +164,7 @@ export function formatBytes(bytes) {
150
164
  * @property {string} [torrentUrl] - URL of a .torrent enclosure.
151
165
  * @property {string} [category] - Item category.
152
166
  * @property {string} [mtime] - The archive's mtime on the node that built it.
167
+ * @property {string} [mutableMagnet] - BEP 46 magnet, when the publisher has an identity for it.
153
168
  */
154
169
 
155
170
  /**
@@ -196,6 +211,11 @@ export function parseFeed(body) {
196
211
  items.push({
197
212
  title: title ?? 'untitled',
198
213
  infoHash: tag(block, 'pmtiles:infohash')?.toLowerCase(),
214
+ // The BEP 46 magnet, when the publisher has an identity for this
215
+ // archive. Carried so a consumer can follow the series across rebuilds,
216
+ // and so it can read the public key out of the feed rather than being
217
+ // told it separately -- the key is inside the string as xs=urn:btpk:.
218
+ mutableMagnet: tag(block, 'pmtiles:mutable'),
199
219
  magnet,
200
220
  torrentUrl,
201
221
  // RSS allows several <category> elements, and an archive may be tagged
package/src/watch.js CHANGED
@@ -42,6 +42,7 @@ export class WatchManager {
42
42
  #library;
43
43
  #watchers = [];
44
44
  #importing = new Set();
45
+ #ready = [];
45
46
 
46
47
  /**
47
48
  * Creates the manager.
@@ -142,6 +143,19 @@ export class WatchManager {
142
143
  console.error(`[watch] ${directory}: ${error.message}`);
143
144
  });
144
145
 
146
+ // chokidar scans the directory before it starts reporting changes, and
147
+ // a file that lands during that scan is in neither the listing nor the
148
+ // event stream. Nothing in the daemon depends on knowing when the scan
149
+ // ends — an archive dropped that early is picked up by the next one —
150
+ // but anything driving this deliberately does, and a caller with no way
151
+ // to know is a caller that has to guess with a sleep.
152
+ this.#ready.push(
153
+ new Promise((resolve) => {
154
+ watcher.once('ready', resolve);
155
+ watcher.once('error', resolve);
156
+ }),
157
+ );
158
+
145
159
  this.#watchers.push(watcher);
146
160
  for (const entry of entries) {
147
161
  const tags = normalizeCategories(entry);
@@ -266,5 +280,21 @@ export class WatchManager {
266
280
  async stop() {
267
281
  await Promise.all(this.#watchers.map((watcher) => watcher.close()));
268
282
  this.#watchers = [];
283
+ this.#ready = [];
284
+ }
285
+
286
+ /**
287
+ * Resolves once every watcher has finished its first scan.
288
+ *
289
+ * Until then a file dropped into a watched folder may be reported as an
290
+ * event, may be found by the scan, or may be missed by both — chokidar makes
291
+ * no promise about the window between opening a watch and finishing the
292
+ * listing. Nothing in the daemon needs this, since an archive that early is
293
+ * picked up on the next pass, but anything that creates a file and then
294
+ * expects to see it imported is racing without it.
295
+ * @returns {Promise<void>} - Resolves when watching has actually begun.
296
+ */
297
+ async ready() {
298
+ await Promise.all(this.#ready);
269
299
  }
270
300
  }
@@ -3,10 +3,14 @@
3
3
  The public front door, served at / on the public listener when the console
4
4
  has an admin port of its own.
5
5
 
6
- Reads /api/catalog and /api/categories, both of which are already public and
7
- already filtered by feedCategories — so this page shows exactly what this node
8
- publishes and cannot show anything else. It is deliberately plain: no build
9
- step, no dependencies, and it degrades to a readable list of links if the
6
+ Reads /latest/ for the categories and, for the archives, /api/catalog where a
7
+ credential allows it and /feed.xml where one does not — everything under /api/
8
+ is guarded on a node with any authentication configured, which is most of them,
9
+ and a front page that answers 401 to its own audience is worse than none.
10
+
11
+ All three are filtered by feedCategories, so this page shows exactly what the
12
+ node publishes and cannot show anything else. It is deliberately plain: no
13
+ build step, no dependencies, and it degrades to a readable list of links if the
10
14
  script does not run.
11
15
  -->
12
16
  <html lang="en">
@@ -167,7 +171,7 @@
167
171
  <noscript>
168
172
  <p class="failed">
169
173
  This page lists the archives using JavaScript. Without it, the same
170
- information is at <a href="/api/catalog">/api/catalog</a> as JSON and
174
+ information is at <a href="/latest/">/latest/</a> as JSON and
171
175
  <a href="/feed.xml">/feed.xml</a> as RSS.
172
176
  </p>
173
177
  </noscript>
@@ -176,8 +180,7 @@
176
180
  <div id="archives"><p class="empty">Loading…</p></div>
177
181
 
178
182
  <footer>
179
- <a href="/api/catalog">catalog JSON</a> ·
180
- <a href="/api/categories">categories JSON</a> ·
183
+ <a href="/latest/">categories JSON</a> ·
181
184
  <a href="/feed.xml">RSS feed</a> ·
182
185
  <a href="/health">health</a>
183
186
  </footer>
@@ -280,47 +283,176 @@
280
283
 
281
284
  // Categories are the handle that survives a rebuild, so they go first —
282
285
  // a style should point at one of these rather than at an infohash.
286
+ // Categories first, and given the same weight as an archive, because
287
+ // for most visitors they are the more useful thing: an archive URL names
288
+ // one build and goes stale on the next, while a category names whichever
289
+ // is current and keeps working across rebuilds. A style should point at
290
+ // one of these.
283
291
  const renderCategories = (categories) => {
284
292
  const host = document.getElementById('categories');
285
- const usable = categories.filter((c) => c.endpoints?.styleUrl);
293
+ host.textContent = '';
294
+ const usable = categories.filter((c) => c.endpoints);
286
295
  if (usable.length === 0) return;
287
296
 
288
297
  host.append(el('h2', null, 'Categories'));
289
- const urls = el('div', 'urls');
298
+ host.append(
299
+ el(
300
+ 'p',
301
+ 'facts',
302
+ 'These follow the newest build. Point a style at one of these ' +
303
+ 'rather than at an archive below, and it keeps working when the ' +
304
+ 'archive is rebuilt.',
305
+ ),
306
+ );
307
+
290
308
  for (const entry of usable) {
291
- // `category` and `archives` are what /api/categories actually calls
309
+ // `category` and `archives` are what the endpoint actually calls
292
310
  // these; `name` and `count` would read better and are not there.
293
- const label = Number.isFinite(entry.archives)
294
- ? `${entry.category} (${entry.archives})`
295
- : entry.category;
296
- urls.append(
297
- urlRow(label, new URL(entry.endpoints.styleUrl, location.href).href),
298
- );
311
+ const card = el('div', 'archive');
312
+ const head = el('header');
313
+ head.append(el('span', 'name', entry.category));
314
+ if (Number.isFinite(entry.archives)) {
315
+ head.append(
316
+ el(
317
+ 'span',
318
+ 'tag',
319
+ `${entry.archives} build${entry.archives === 1 ? '' : 's'}`,
320
+ ),
321
+ );
322
+ }
323
+ card.append(head);
324
+
325
+ if (entry.newest?.name) {
326
+ const facts = [`newest: ${entry.newest.name}`];
327
+ const size = bytes(entry.newest.size);
328
+ if (size) facts.push(size);
329
+ card.append(el('p', 'facts', facts.join(' · ')));
330
+ }
331
+
332
+ const links = el('div', 'links');
333
+ const add = (href, text) => {
334
+ const a = el('a', null, text);
335
+ a.href = href;
336
+ links.append(a);
337
+ };
338
+ const ends = entry.endpoints;
339
+ if (ends.tileJson) add(ends.tileJson, 'TileJSON');
340
+ if (ends.torrent) add(ends.torrent, '.torrent');
341
+ if (ends.magnet) add(ends.magnet, 'magnet');
342
+ if (ends.feed) add(ends.feed, 'RSS');
343
+ card.append(links);
344
+
345
+ const urls = el('div', 'urls');
346
+ // The one worth copying: the TileJSON URL with the magnet in its
347
+ // fragment, which a plain client fetches over HTTP and a swarm-aware
348
+ // one joins directly.
349
+ if (ends.styleUrl) {
350
+ urls.append(
351
+ urlRow('style URL', new URL(ends.styleUrl, location.href).href),
352
+ );
353
+ } else if (ends.tileJson) {
354
+ urls.append(
355
+ urlRow('TileJSON', new URL(ends.tileJson, location.href).href),
356
+ );
357
+ }
358
+ if (urls.childElementCount > 0) card.append(urls);
359
+
360
+ host.append(card);
299
361
  }
300
- host.append(urls);
362
+
301
363
  host.append(el('h2', null, 'Archives'));
302
364
  };
365
+ const PMTILES_NS =
366
+ 'https://github.com/TechIdiots-LLC/pmtiles-swarm/ns/1.0';
367
+
368
+ // The feed says the same things the catalog does, in RSS. It is the
369
+ // fallback because it is the one that needs no credential: /api/catalog
370
+ // is guarded whenever this node has any authentication configured, which
371
+ // is most of them, and a front page that 401s is worse than none.
372
+ const fromFeed = (xmlText) => {
373
+ const doc = new DOMParser().parseFromString(xmlText, 'application/xml');
374
+ if (doc.querySelector('parsererror')) {
375
+ throw new Error('the feed is not valid XML');
376
+ }
377
+
378
+ const text = (item, name) =>
379
+ item.getElementsByTagNameNS(PMTILES_NS, name)[0]?.textContent ??
380
+ undefined;
381
+
382
+ return [...doc.getElementsByTagName('item')].map((item) => {
383
+ const zoom = (name) => {
384
+ const value = Number(text(item, name));
385
+ return Number.isFinite(value) ? value : undefined;
386
+ };
387
+ const enclosure = item.getElementsByTagName('enclosure')[0];
388
+ const format = text(item, 'format');
389
+
390
+ return {
391
+ infoHash: text(item, 'infohash'),
392
+ name: item.getElementsByTagName('title')[0]?.textContent,
393
+ size: Number(enclosure?.getAttribute('length')) || undefined,
394
+ categories: [...item.getElementsByTagName('category')].map(
395
+ (node) => node.textContent,
396
+ ),
397
+ magnet: text(item, 'magnet'),
398
+ createdAt: item.getElementsByTagName('pubDate')[0]?.textContent,
399
+ pmtiles: format
400
+ ? {
401
+ format,
402
+ minZoom: zoom('minzoom'),
403
+ maxZoom: zoom('maxzoom'),
404
+ }
405
+ : undefined,
406
+ };
407
+ });
408
+ };
303
409
 
304
410
  const load = async () => {
411
+ let archives = null;
412
+
413
+ // Richer where it is reachable: the catalog carries web seeds and the
414
+ // sparse flag, which the feed does not.
305
415
  try {
306
416
  const response = await fetch('/api/catalog');
307
- if (!response.ok) throw new Error(`catalog said ${response.status}`);
308
- const body = await response.json();
309
- render(body.archives ?? []);
310
- } catch (error) {
311
- const host = document.getElementById('archives');
312
- host.textContent = '';
313
- host.append(
314
- el('p', 'failed', `Could not load the catalog: ${error.message}`),
315
- );
316
- return;
417
+ if (response.ok) {
418
+ archives = (await response.json()).archives ?? [];
419
+ }
420
+ } catch {
421
+ // Fall through to the feed.
317
422
  }
318
423
 
319
- // Best effort, and after the archives: the page is worth showing
320
- // without it.
424
+ if (archives === null) {
425
+ try {
426
+ const response = await fetch('/feed.xml');
427
+ if (!response.ok) throw new Error(`feed said ${response.status}`);
428
+ archives = fromFeed(await response.text());
429
+ } catch (error) {
430
+ const host = document.getElementById('archives');
431
+ host.textContent = '';
432
+ host.append(
433
+ el(
434
+ 'p',
435
+ 'failed',
436
+ `Could not load the archives: ${error.message}`,
437
+ ),
438
+ );
439
+ return;
440
+ }
441
+ }
442
+
443
+ render(archives.filter((archive) => archive.infoHash));
444
+
445
+ // /latest/ rather than /api/categories: the same list, without a
446
+ // credential. This is the part most visitors actually want -- an
447
+ // archive URL names one build, a category names whichever is current
448
+ // -- so it is worth more than a best-effort attempt at a guarded
449
+ // endpoint that answers 401 to exactly the audience this page is for.
321
450
  try {
322
- const response = await fetch('/api/categories');
323
- if (response.ok) renderCategories(await response.json());
451
+ const response = await fetch('/latest/');
452
+ if (response.ok) {
453
+ const body = await response.json();
454
+ renderCategories(body.categories ?? []);
455
+ }
324
456
  } catch {
325
457
  // Nothing to say. The archives above are the substance.
326
458
  }