@openparachute/hub 0.5.7 → 0.5.9-rc.6

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.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  12. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  13. package/src/__tests__/expose.test.ts +2 -2
  14. package/src/__tests__/hub-server.test.ts +338 -65
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/jwt-sign.test.ts +205 -0
  18. package/src/__tests__/module-manifest.test.ts +48 -0
  19. package/src/__tests__/oauth-handlers.test.ts +266 -5
  20. package/src/__tests__/operator-token.test.ts +379 -3
  21. package/src/__tests__/origin-check.test.ts +220 -0
  22. package/src/__tests__/status.test.ts +199 -0
  23. package/src/__tests__/well-known.test.ts +69 -0
  24. package/src/admin-clients.ts +139 -0
  25. package/src/admin-handlers.ts +32 -254
  26. package/src/admin-host-admin-token.ts +25 -10
  27. package/src/admin-login-ui.ts +256 -0
  28. package/src/admin-vault-admin-token.ts +1 -1
  29. package/src/api-me.ts +124 -0
  30. package/src/api-mint-token.ts +239 -0
  31. package/src/api-revocation-list.ts +59 -0
  32. package/src/api-revoke-token.ts +153 -0
  33. package/src/api-tokens.ts +224 -0
  34. package/src/commands/auth.ts +408 -51
  35. package/src/commands/expose-2fa-warning.ts +6 -6
  36. package/src/commands/status.ts +74 -10
  37. package/src/csrf.ts +6 -3
  38. package/src/help.ts +10 -4
  39. package/src/hub-db.ts +63 -0
  40. package/src/hub-server.ts +426 -97
  41. package/src/hub.ts +272 -149
  42. package/src/install-source.ts +291 -0
  43. package/src/jwt-sign.ts +265 -5
  44. package/src/module-manifest.ts +48 -10
  45. package/src/oauth-handlers.ts +183 -54
  46. package/src/oauth-ui.ts +23 -2
  47. package/src/operator-token.ts +272 -18
  48. package/src/origin-check.ts +127 -0
  49. package/src/rate-limit.ts +5 -2
  50. package/src/scope-explanations.ts +33 -2
  51. package/src/sessions.ts +1 -1
  52. package/src/well-known.ts +54 -1
  53. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  54. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  55. package/web/ui/dist/index.html +2 -2
  56. package/src/__tests__/admin-config.test.ts +0 -281
  57. package/src/admin-config-ui.ts +0 -534
  58. package/src/admin-config.ts +0 -226
  59. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  60. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
package/src/hub.ts CHANGED
@@ -1,18 +1,34 @@
1
1
  import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { CONFIG_DIR } from "./config.ts";
4
+ import { CSRF_FIELD_NAME } from "./csrf.ts";
4
5
 
5
6
  /**
6
7
  * Hub page served at `/` when the node is exposed.
7
8
  *
8
- * The page is a *module directory*: one tile per module type (vault, scribe,
9
- * notes, agent), not one row per service instance. Aaron's original shape
10
- * iterated `services[]` and rendered a card per entry fine at one vault,
11
- * but at three vaults plus scribe + notes + agent the page reads as a flat
12
- * list of instances rather than the modules themselves (#168). The new shape
13
- * aggregates: "Vault 3 registered Manage →" links to the per-vault SPA at
14
- * `/vault`; "Scribe 1 registered" links to the running scribe instance;
15
- * etc. Zero-count types are hidden entirely.
9
+ * The page is split into two sections, organized by **ownership**:
10
+ *
11
+ * - **Services** surfaces provided by the modules running on this
12
+ * hub. Browse notes (the Notes PWA); transcribe audio (Scribe); run
13
+ * agents (Agent). Each entry points at the service's own UI; the
14
+ * service owns what's behind the link (use, config, admin
15
+ * whatever it chooses to surface). Entries are dynamic, derived
16
+ * from `/.well-known/parachute.json`; only installed services show
17
+ * up. Vault deliberately doesn't have its own Services entry — its
18
+ * content is browsed via Notes, so a separate "Vault" tile would
19
+ * just send the operator to the admin SPA, which is exactly the
20
+ * friction Aaron flagged ("clicked Vault, took me to hub management").
21
+ *
22
+ * - **Admin** — hub-owned admin surfaces for cross-cutting host
23
+ * concerns. Always visible: even with zero vaults installed, an
24
+ * operator may want to provision the first one. Three entries:
25
+ * Vaults (provisioning), Permissions (OAuth consent grants), Tokens
26
+ * (registry mint/list/revoke).
27
+ *
28
+ * The Services-vs-Admin axis is ownership, not function: services-owned
29
+ * UIs vs hub-owned UIs. The first cut framed it as "Use vs Admin" but
30
+ * that broke down once you noticed real services have UIs that mix use,
31
+ * config, and admin together — the cleaner cut is who's hosting the UI.
16
32
  *
17
33
  * The file stays self-contained (inline CSS + JS, no external assets) so
18
34
  * `tailscale serve` can mount it directly from disk with `--set-path=/`.
@@ -21,21 +37,85 @@ import { CONFIG_DIR } from "./config.ts";
21
37
  export const HUB_PATH = join(CONFIG_DIR, "well-known", "hub.html");
22
38
  export const HUB_MOUNT = "/";
23
39
 
24
- export function renderHub(): string {
25
- return HTML;
40
+ export interface RenderHubSession {
41
+ /** displayName from /api/me semantics — username today, profile field later. */
42
+ displayName: string;
43
+ /** Per-session CSRF token; embedded in the inline sign-out form. */
44
+ csrfToken: string;
26
45
  }
27
46
 
47
+ export interface RenderHubOpts {
48
+ /**
49
+ * When set, renders "Signed in as <displayName>" + an inline sign-out
50
+ * form. When omitted (or null), renders a "Sign in" link. Pass through
51
+ * from `findActiveSession` + `ensureCsrfToken` in the hub-server `/`
52
+ * handler — the static-disk write path (`writeHubFile`) emits the
53
+ * signed-out shape, since that file gets served only when the
54
+ * dynamic path can't (`!getDb`).
55
+ */
56
+ session?: RenderHubSession | null;
57
+ }
58
+
59
+ export function renderHub(opts: RenderHubOpts = {}): string {
60
+ return buildHtml(opts);
61
+ }
62
+
63
+ /**
64
+ * Write the static signed-out HTML to disk. Used by `parachute expose` so
65
+ * `tailscale serve --set-path=/` has a file to back. The dynamic
66
+ * session-aware path runs through `hub-server.ts`'s `/` handler whenever
67
+ * a DB is configured; this disk file is the fallback when it isn't.
68
+ */
28
69
  export function writeHubFile(path: string = HUB_PATH): string {
29
70
  if (!existsSync(dirname(path))) {
30
71
  mkdirSync(dirname(path), { recursive: true });
31
72
  }
73
+ const html = renderHub();
32
74
  const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
33
- writeFileSync(tmp, HTML);
75
+ writeFileSync(tmp, html);
34
76
  renameSync(tmp, path);
35
77
  return path;
36
78
  }
37
79
 
38
- const HTML = `<!doctype html>
80
+ function buildHtml({ session }: RenderHubOpts): string {
81
+ const authBlock = session
82
+ ? renderSignedIn(session.displayName, session.csrfToken)
83
+ : renderSignedOut();
84
+ return HTML_TEMPLATE.replace("<!--AUTH-INDICATOR-->", authBlock);
85
+ }
86
+
87
+ function renderSignedIn(displayName: string, csrfToken: string): string {
88
+ // Inline POST form so sign-out works without JS. Submit button is
89
+ // styled as a text link via `.auth-signout` so the visual weight
90
+ // matches the surrounding "Signed in as <name>" text.
91
+ return `<div class="auth-indicator">
92
+ <span class="muted">Signed in as <strong>${escapeHtml(displayName)}</strong></span>
93
+ <form method="POST" action="/logout" class="auth-signout-form">
94
+ <input type="hidden" name="${CSRF_FIELD_NAME}" value="${escapeAttr(csrfToken)}" />
95
+ <button type="submit" class="auth-signout">Sign out</button>
96
+ </form>
97
+ </div>`;
98
+ }
99
+
100
+ function renderSignedOut(): string {
101
+ return `<div class="auth-indicator">
102
+ <a href="/login?next=/" class="auth-signin">Sign in</a>
103
+ </div>`;
104
+ }
105
+
106
+ function escapeHtml(s: string): string {
107
+ return s
108
+ .replace(/&/g, "&amp;")
109
+ .replace(/</g, "&lt;")
110
+ .replace(/>/g, "&gt;")
111
+ .replace(/"/g, "&quot;");
112
+ }
113
+
114
+ function escapeAttr(s: string): string {
115
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
116
+ }
117
+
118
+ const HTML_TEMPLATE = `<!doctype html>
39
119
  <html lang="en">
40
120
  <head>
41
121
  <meta charset="utf-8" />
@@ -94,6 +174,50 @@ const HTML = `<!doctype html>
94
174
  header {
95
175
  text-align: center;
96
176
  margin-bottom: 3.5rem;
177
+ position: relative;
178
+ }
179
+ /* Auth indicator: small text + sign-in/out affordance, top-right of the
180
+ header. Doesn't crowd the centered title; falls below the title on
181
+ narrow viewports via the media query at the bottom of this stylesheet. */
182
+ .auth-indicator {
183
+ position: absolute;
184
+ top: 0;
185
+ right: 0;
186
+ display: inline-flex;
187
+ align-items: baseline;
188
+ gap: 0.5rem;
189
+ font-size: 0.85rem;
190
+ color: var(--fg-muted);
191
+ }
192
+ .auth-indicator .muted {
193
+ color: var(--fg-muted);
194
+ }
195
+ .auth-indicator strong {
196
+ font-weight: 600;
197
+ color: var(--fg);
198
+ }
199
+ .auth-signout-form {
200
+ margin: 0;
201
+ display: inline;
202
+ }
203
+ .auth-signout, .auth-signin {
204
+ background: none;
205
+ border: none;
206
+ padding: 0;
207
+ color: var(--accent);
208
+ font: inherit;
209
+ cursor: pointer;
210
+ text-decoration: underline;
211
+ text-decoration-thickness: 1px;
212
+ text-underline-offset: 2px;
213
+ }
214
+ .auth-signout:hover, .auth-signin:hover {
215
+ color: var(--accent-hover);
216
+ }
217
+ a.auth-signin {
218
+ /* Anchor needs explicit reset since the a element has its own
219
+ color/decoration. */
220
+ border-bottom: none;
97
221
  }
98
222
  h1 {
99
223
  font-family: var(--serif);
@@ -108,6 +232,22 @@ const HTML = `<!doctype html>
108
232
  font-size: 1.1rem;
109
233
  margin: 0;
110
234
  }
235
+ .section {
236
+ margin-bottom: 3rem;
237
+ }
238
+ .section h2 {
239
+ font-family: var(--serif);
240
+ font-weight: 400;
241
+ font-size: 1.5rem;
242
+ color: var(--fg);
243
+ margin: 0 0 0.4rem;
244
+ letter-spacing: -0.005em;
245
+ }
246
+ .section .section-sub {
247
+ color: var(--fg-muted);
248
+ font-size: 0.92rem;
249
+ margin: 0 0 1.25rem;
250
+ }
111
251
  .grid {
112
252
  display: grid;
113
253
  gap: 1.25rem;
@@ -117,12 +257,12 @@ const HTML = `<!doctype html>
117
257
  background: var(--card-bg);
118
258
  border: 1px solid var(--border);
119
259
  border-radius: 12px;
120
- padding: 1.75rem;
260
+ padding: 1.5rem;
121
261
  text-decoration: none;
122
262
  color: inherit;
123
263
  display: flex;
124
264
  flex-direction: column;
125
- gap: 0.75rem;
265
+ gap: 0.5rem;
126
266
  transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
127
267
  opacity: 0;
128
268
  animation: fadeUp 0.4s ease forwards;
@@ -131,46 +271,22 @@ const HTML = `<!doctype html>
131
271
  .card:nth-child(2) { animation-delay: 0.06s; }
132
272
  .card:nth-child(3) { animation-delay: 0.1s; }
133
273
  .card:nth-child(4) { animation-delay: 0.14s; }
134
- .card:nth-child(5) { animation-delay: 0.18s; }
135
- .card:nth-child(n+6) { animation-delay: 0.22s; }
136
274
  .card:hover {
137
275
  border-color: var(--accent-light);
138
276
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
139
277
  transform: translateY(-2px);
140
278
  }
141
- .card-head {
142
- display: flex;
143
- align-items: center;
144
- gap: 0.75rem;
145
- }
146
- .icon {
147
- width: 2.25rem;
148
- height: 2.25rem;
149
- display: flex;
150
- align-items: center;
151
- justify-content: center;
152
- background: var(--accent-soft);
153
- border-radius: 8px;
154
- color: var(--accent);
155
- font-size: 1.25rem;
156
- flex-shrink: 0;
157
- overflow: hidden;
158
- }
159
- .icon img, .icon svg {
160
- width: 100%;
161
- height: 100%;
162
- object-fit: contain;
163
- }
164
279
  .card-title {
165
280
  font-family: var(--serif);
166
- font-size: 1.5rem;
281
+ font-size: 1.4rem;
167
282
  font-weight: 400;
168
283
  margin: 0;
169
284
  line-height: 1.1;
285
+ color: var(--fg);
170
286
  }
171
- .card-count {
287
+ .card-desc {
172
288
  color: var(--fg-muted);
173
- font-size: 0.95rem;
289
+ font-size: 0.92rem;
174
290
  margin: 0;
175
291
  flex-grow: 1;
176
292
  }
@@ -178,7 +294,7 @@ const HTML = `<!doctype html>
178
294
  display: flex;
179
295
  align-items: center;
180
296
  justify-content: space-between;
181
- margin-top: 0.25rem;
297
+ margin-top: 0.5rem;
182
298
  font-size: 0.8rem;
183
299
  color: var(--fg-dim);
184
300
  }
@@ -186,14 +302,14 @@ const HTML = `<!doctype html>
186
302
  font-family: ui-monospace, 'SF Mono', Monaco, monospace;
187
303
  color: var(--fg-muted);
188
304
  }
189
- .manage {
305
+ .arrow {
190
306
  color: var(--accent);
191
307
  font-weight: 500;
192
308
  }
193
309
  .empty, .error {
194
310
  text-align: center;
195
311
  color: var(--fg-muted);
196
- padding: 3rem 1rem;
312
+ padding: 1.5rem 1rem;
197
313
  border: 1px dashed var(--border);
198
314
  border-radius: 12px;
199
315
  }
@@ -225,154 +341,161 @@ const HTML = `<!doctype html>
225
341
  }
226
342
  @media (max-width: 640px) {
227
343
  main { padding: 2.5rem 1rem 4rem; }
228
- .card { padding: 1.5rem; }
344
+ .card { padding: 1.25rem; }
345
+ /* Auth indicator drops below the title on narrow viewports so the
346
+ header doesn't crowd. */
347
+ header { padding-top: 1.75rem; }
348
+ .auth-indicator { font-size: 0.8rem; }
229
349
  }
230
350
  </style>
231
351
  </head>
232
352
  <body>
233
353
  <main>
234
354
  <header>
355
+ <!--AUTH-INDICATOR-->
235
356
  <h1>Parachute</h1>
236
357
  <p class="tagline">Your personal-computing modules.</p>
237
358
  </header>
238
- <section id="modules" class="grid" aria-live="polite">
239
- <div class="empty" id="loading">Loading modules\u2026</div>
359
+
360
+ <section class="section" id="services-section">
361
+ <h2>Services</h2>
362
+ <p class="section-sub">Surfaces provided by services running on this hub.</p>
363
+ <div class="grid" id="services-grid" aria-live="polite">
364
+ <div class="empty" id="services-loading">Loading…</div>
365
+ </div>
240
366
  </section>
367
+
368
+ <section class="section" id="admin-section">
369
+ <h2>Admin</h2>
370
+ <p class="section-sub">Manage this hub — vaults, permissions, tokens.</p>
371
+ <div class="grid" id="admin-grid"></div>
372
+ </section>
373
+
241
374
  <footer>
242
375
  <a href="/.well-known/parachute.json">discovery</a>
243
376
  </footer>
244
377
  </main>
245
378
  <script>
246
379
  (async () => {
247
- const root = document.getElementById('modules');
248
- const fallbackIcon = \`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/></svg>\`;
380
+ const servicesGrid = document.getElementById('services-grid');
381
+ const adminGrid = document.getElementById('admin-grid');
249
382
 
250
- // Display order for known module types. Unknown short-names append after,
251
- // so a third-party module mounted at /foo still gets a tile.
252
- const MODULE_ORDER = ['vault', 'scribe', 'notes', 'agent'];
253
- const MODULE_LABELS = {
254
- vault: 'Vault',
255
- scribe: 'Scribe',
256
- notes: 'Notes',
257
- agent: 'Agent',
258
- };
383
+ // Services entries are now data-driven from /.well-known/parachute.json.
384
+ // Each services[] row carries (since hub#... Phase D consumer side):
385
+ // - displayName: human label (sourced from module.json:displayName).
386
+ // - uiUrl: where the user-facing UI lives (sourced from module.json:uiUrl).
387
+ // A row WITHOUT uiUrl declines to render a tile — the module is
388
+ // either API-only (vault, scribe-without-UI) or surfaces its UI
389
+ // through a sibling (vault → Notes).
390
+ // The previous SERVICE_LABELS / SERVICE_ORDER / isVaultName hardcoding
391
+ // is retired: vault has no uiUrl, so the "skip vault" rule emerges
392
+ // from data rather than a name check; ordering is alphabetical-by-
393
+ // displayName per the module-json-extensibility pattern doc.
259
394
 
260
- function isVaultName(name) {
261
- return name === 'parachute-vault' || name.startsWith('parachute-vault-');
262
- }
395
+ // Admin entries: always visible. Even a fresh hub with zero vaults wants
396
+ // the operator to find /admin/vaults. Hardcoded — they live in the
397
+ // hub-served SPA, not in services.json.
398
+ const ADMIN_ENTRIES = [
399
+ { title: 'Vaults', desc: 'Create and manage vaults on this hub.', href: '/admin/vaults' },
400
+ { title: 'Permissions', desc: 'OAuth consent grants per app.', href: '/admin/permissions' },
401
+ { title: 'Tokens', desc: 'Mint and revoke access tokens.', href: '/admin/tokens' },
402
+ ];
263
403
 
264
404
  function shortName(manifestName) {
265
405
  return manifestName.replace(/^parachute-/, '');
266
406
  }
267
407
 
268
- function labelFor(type) {
269
- if (MODULE_LABELS[type]) return MODULE_LABELS[type];
270
- return type.charAt(0).toUpperCase() + type.slice(1);
271
- }
272
-
273
- // Aggregate services + vaults into one entry per module type. Vault is
274
- // special-cased because its count comes from doc.vaults[] (one entry per
275
- // vault instance / mount path) and its manage link goes to the hub's
276
- // per-vault SPA at /vault — not to any single vault backend.
277
- function aggregate(services, vaults) {
278
- const groups = new Map();
279
- if (vaults.length > 0) {
280
- groups.set('vault', {
281
- type: 'vault',
282
- label: 'Vault',
283
- count: vaults.length,
284
- manageUrl: '/vault',
285
- });
286
- }
287
- for (const svc of services) {
288
- if (isVaultName(svc.name)) continue;
289
- const t = shortName(svc.name);
290
- const existing = groups.get(t);
291
- if (existing) {
292
- existing.count += 1;
293
- } else {
294
- groups.set(t, {
295
- type: t,
296
- label: labelFor(t),
297
- count: 1,
298
- manageUrl: svc.path,
299
- });
300
- }
301
- }
302
- return groups;
303
- }
304
-
305
- function tilesInOrder(groups) {
306
- const out = [];
307
- for (const t of MODULE_ORDER) {
308
- const g = groups.get(t);
309
- if (g) out.push(g);
310
- }
311
- const known = new Set(MODULE_ORDER);
312
- const extras = [...groups.values()]
313
- .filter((g) => !known.has(g.type))
314
- .sort((a, b) => a.type.localeCompare(b.type));
315
- return out.concat(extras);
316
- }
317
-
318
- function renderTile(group) {
408
+ function renderTile({ title, desc, href }) {
319
409
  const a = document.createElement('a');
320
410
  a.className = 'card';
321
- a.href = group.manageUrl;
322
-
323
- const head = document.createElement('div');
324
- head.className = 'card-head';
411
+ a.href = href;
325
412
 
326
- const icon = document.createElement('div');
327
- icon.className = 'icon';
328
- icon.innerHTML = fallbackIcon;
413
+ const t = document.createElement('h3');
414
+ t.className = 'card-title';
415
+ t.textContent = title;
416
+ a.appendChild(t);
329
417
 
330
- const title = document.createElement('h2');
331
- title.className = 'card-title';
332
- title.textContent = group.label;
333
-
334
- head.appendChild(icon);
335
- head.appendChild(title);
336
- a.appendChild(head);
337
-
338
- const count = document.createElement('p');
339
- count.className = 'card-count';
340
- count.textContent = group.count === 1 ? '1 registered' : group.count + ' registered';
341
- a.appendChild(count);
418
+ if (desc) {
419
+ const d = document.createElement('p');
420
+ d.className = 'card-desc';
421
+ d.textContent = desc;
422
+ a.appendChild(d);
423
+ }
342
424
 
343
425
  const meta = document.createElement('div');
344
426
  meta.className = 'card-meta';
345
427
  const path = document.createElement('span');
346
428
  path.className = 'path';
347
- path.textContent = group.manageUrl;
348
- const manage = document.createElement('span');
349
- manage.className = 'manage';
350
- manage.textContent = 'Manage \u2192';
429
+ path.textContent = href;
430
+ const arrow = document.createElement('span');
431
+ arrow.className = 'arrow';
432
+ arrow.textContent = '';
351
433
  meta.appendChild(path);
352
- meta.appendChild(manage);
434
+ meta.appendChild(arrow);
353
435
  a.appendChild(meta);
354
436
 
355
437
  return a;
356
438
  }
357
439
 
358
- try {
359
- const wk = await fetch('/.well-known/parachute.json', { credentials: 'omit' });
360
- if (!wk.ok) throw new Error('well-known fetch failed: ' + wk.status);
361
- const doc = await wk.json();
362
- const services = Array.isArray(doc.services) ? doc.services : [];
363
- const vaults = Array.isArray(doc.vaults) ? doc.vaults : [];
440
+ function renderAdmin() {
441
+ adminGrid.innerHTML = '';
442
+ for (const entry of ADMIN_ENTRIES) {
443
+ adminGrid.appendChild(renderTile(entry));
444
+ }
445
+ }
446
+
447
+ function renderServices(services) {
448
+ // Render one tile per service that declares a uiUrl. Entries without
449
+ // uiUrl are intentionally omitted — vault is the canonical example
450
+ // (its content is browsed via Notes, which has its own uiUrl row).
451
+ // Multiple entries with the same shortName collapse into one tile;
452
+ // operators with two scribe instances pick the first arbitrarily,
453
+ // and they'd know which they meant.
454
+ const byShort = new Map();
455
+ for (const svc of services) {
456
+ if (!svc || !svc.uiUrl) continue;
457
+ const key = shortName(svc.name);
458
+ if (byShort.has(key)) continue;
459
+ byShort.set(key, {
460
+ title: svc.displayName || key,
461
+ desc: svc.tagline || '',
462
+ href: svc.uiUrl,
463
+ });
464
+ }
364
465
 
365
- const groups = aggregate(services, vaults);
366
- const tiles = tilesInOrder(groups);
466
+ // Alphabetical-by-displayName per the module-json-extensibility pattern.
467
+ // Stable for shared-prefix labels (Notes, Notes-Lite would sort that way).
468
+ const tiles = Array.from(byShort.values()).sort((a, b) =>
469
+ a.title.localeCompare(b.title),
470
+ );
367
471
 
472
+ servicesGrid.innerHTML = '';
368
473
  if (tiles.length === 0) {
369
- root.innerHTML = '<div class="empty">No modules installed yet. Try <code>parachute install vault</code>.</div>';
474
+ const empty = document.createElement('div');
475
+ empty.className = 'empty';
476
+ empty.innerHTML =
477
+ 'No services with a UI declared yet. Modules surface their UIs by ' +
478
+ 'declaring <code>uiUrl</code> in <code>module.json</code> ' +
479
+ '(see the module-json-extensibility pattern).';
480
+ servicesGrid.appendChild(empty);
370
481
  return;
371
482
  }
372
- root.innerHTML = '';
373
- for (const g of tiles) root.appendChild(renderTile(g));
483
+ for (const tile of tiles) servicesGrid.appendChild(renderTile(tile));
484
+ }
485
+
486
+ // Admin section is static — render synchronously so the operator sees it
487
+ // even if the well-known fetch is slow or fails.
488
+ renderAdmin();
489
+
490
+ try {
491
+ const wk = await fetch('/.well-known/parachute.json', { credentials: 'omit' });
492
+ if (!wk.ok) throw new Error('well-known fetch failed: ' + wk.status);
493
+ const doc = await wk.json();
494
+ const services = Array.isArray(doc.services) ? doc.services : [];
495
+ renderServices(services);
374
496
  } catch (err) {
375
- root.innerHTML = '<div class="error">Could not load modules: ' + (err && err.message ? err.message : String(err)) + '</div>';
497
+ servicesGrid.innerHTML = '<div class="error">Could not load services: ' +
498
+ (err && err.message ? err.message : String(err)) + '</div>';
376
499
  }
377
500
  })();
378
501
  </script>