@openparachute/hub 0.3.0-rc.1 → 0.5.1

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 (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. package/web/ui/dist/index.html +14 -0
package/src/hub.ts CHANGED
@@ -3,20 +3,19 @@ import { dirname, join } from "node:path";
3
3
  import { CONFIG_DIR } from "./config.ts";
4
4
 
5
5
  /**
6
- * Hub page served at `/` when the node is exposed. Lists every installed
7
- * service via a client-side fetch to `/.well-known/parachute.json` and each
8
- * service's own `/.parachute/info`. Everything that needs personalization
9
- * (name, tagline, icon) comes from the service, not the CLI — adding a new
10
- * frontend requires zero hub-page changes.
6
+ * Hub page served at `/` when the node is exposed.
11
7
  *
12
- * Card kinds (from `info.kind`, optional):
13
- * "frontend" | undefined → whole card is an <a> that navigates to svc.url
14
- * "api" | "tool" → card is non-navigating; click toggles a detail
15
- * panel with OAuth/MCP/open-in-Notes links, so
16
- * API-only services don't dead-end on raw JSON.
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.
17
16
  *
18
- * The file is fully self-contained (inline CSS + JS, no external assets)
19
- * so `tailscale serve` can mount it directly from disk with `--set-path=/`.
17
+ * The file stays self-contained (inline CSS + JS, no external assets) so
18
+ * `tailscale serve` can mount it directly from disk with `--set-path=/`.
20
19
  */
21
20
 
22
21
  export const HUB_PATH = join(CONFIG_DIR, "well-known", "hub.html");
@@ -169,7 +168,7 @@ const HTML = `<!doctype html>
169
168
  margin: 0;
170
169
  line-height: 1.1;
171
170
  }
172
- .card-tagline {
171
+ .card-count {
173
172
  color: var(--fg-muted);
174
173
  font-size: 0.95rem;
175
174
  margin: 0;
@@ -183,127 +182,14 @@ const HTML = `<!doctype html>
183
182
  font-size: 0.8rem;
184
183
  color: var(--fg-dim);
185
184
  }
186
- .version {
187
- font-family: ui-monospace, 'SF Mono', Monaco, monospace;
188
- padding: 0.1rem 0.5rem;
189
- background: var(--bg-soft);
190
- border-radius: 999px;
191
- border: 1px solid var(--border);
192
- }
193
185
  .path {
194
186
  font-family: ui-monospace, 'SF Mono', Monaco, monospace;
195
187
  color: var(--fg-muted);
196
188
  }
197
- .kind-badge {
198
- font-size: 0.7rem;
199
- letter-spacing: 0.04em;
200
- text-transform: uppercase;
201
- color: var(--fg-dim);
202
- padding: 0.1rem 0.45rem;
203
- border-radius: 999px;
204
- border: 1px solid var(--border);
205
- }
206
- .card.interactive { cursor: pointer; }
207
- .card.interactive:focus-visible {
208
- outline: 2px solid var(--accent);
209
- outline-offset: 2px;
210
- }
211
- .details {
212
- display: none;
213
- flex-direction: column;
214
- gap: 0.5rem;
215
- margin-top: 0.75rem;
216
- padding-top: 0.75rem;
217
- border-top: 1px dashed var(--border);
218
- font-size: 0.9rem;
219
- }
220
- .card.expanded .details { display: flex; }
221
- .details a {
189
+ .manage {
222
190
  color: var(--accent);
223
- text-decoration: none;
224
- border-bottom: 1px solid transparent;
225
- transition: border-color 0.15s ease;
226
- word-break: break-all;
227
- }
228
- .details a:hover { border-bottom-color: var(--accent-light); }
229
- .details .row {
230
- display: flex;
231
- flex-direction: column;
232
- gap: 0.15rem;
233
- }
234
- .details .row .label {
235
- font-size: 0.75rem;
236
- color: var(--fg-dim);
237
- text-transform: uppercase;
238
- letter-spacing: 0.04em;
239
- }
240
- .config {
241
- display: flex;
242
- flex-direction: column;
243
- gap: 0.6rem;
244
- margin-top: 0.5rem;
245
- padding-top: 0.6rem;
246
- border-top: 1px dashed var(--border);
247
- }
248
- .config h3 {
249
- font-family: var(--serif);
250
- font-size: 1.05rem;
251
- font-weight: 400;
252
- margin: 0;
253
- }
254
- .config .hint {
255
- color: var(--fg-dim);
256
- font-size: 0.78rem;
257
- font-style: italic;
258
- }
259
- .config fieldset {
260
- border: 1px solid var(--border);
261
- border-radius: 8px;
262
- padding: 0.6rem 0.8rem;
263
- margin: 0;
264
- display: flex;
265
- flex-direction: column;
266
- gap: 0.5rem;
267
- }
268
- .config legend {
269
- padding: 0 0.35rem;
270
- font-size: 0.75rem;
271
- color: var(--fg-dim);
272
- text-transform: uppercase;
273
- letter-spacing: 0.04em;
274
- }
275
- .config .field {
276
- display: flex;
277
- flex-direction: column;
278
- gap: 0.15rem;
279
- }
280
- .config .field-label {
281
- font-size: 0.82rem;
282
- color: var(--fg-muted);
283
191
  font-weight: 500;
284
192
  }
285
- .config .field-description {
286
- font-size: 0.75rem;
287
- color: var(--fg-dim);
288
- }
289
- .config input,
290
- .config select,
291
- .config textarea {
292
- font-family: var(--sans);
293
- font-size: 0.88rem;
294
- padding: 0.35rem 0.5rem;
295
- background: var(--bg-soft);
296
- color: var(--fg);
297
- border: 1px solid var(--border);
298
- border-radius: 6px;
299
- opacity: 0.85;
300
- cursor: not-allowed;
301
- }
302
- .config input[type="checkbox"] {
303
- width: 1rem;
304
- height: 1rem;
305
- padding: 0;
306
- }
307
193
  .empty, .error {
308
194
  text-align: center;
309
195
  color: var(--fg-muted);
@@ -347,10 +233,10 @@ const HTML = `<!doctype html>
347
233
  <main>
348
234
  <header>
349
235
  <h1>Parachute</h1>
350
- <p class="tagline">Your personal-computing services.</p>
236
+ <p class="tagline">Your personal-computing modules.</p>
351
237
  </header>
352
- <section id="services" class="grid" aria-live="polite">
353
- <div class="empty" id="loading">Loading services\u2026</div>
238
+ <section id="modules" class="grid" aria-live="polite">
239
+ <div class="empty" id="loading">Loading modules\u2026</div>
354
240
  </section>
355
241
  <footer>
356
242
  <a href="/.well-known/parachute.json">discovery</a>
@@ -358,298 +244,115 @@ const HTML = `<!doctype html>
358
244
  </main>
359
245
  <script>
360
246
  (async () => {
361
- const root = document.getElementById('services');
247
+ const root = document.getElementById('modules');
362
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>\`;
363
249
 
364
- function shortName(manifestName) {
365
- return manifestName.replace(/^parachute-/, '');
366
- }
367
-
368
- function fetchWithTimeout(url, ms) {
369
- const ctl = new AbortController();
370
- const t = setTimeout(() => ctl.abort(), ms);
371
- return fetch(url, { signal: ctl.signal, credentials: 'omit' })
372
- .finally(() => clearTimeout(t));
373
- }
374
-
375
- async function loadInfo(infoUrl) {
376
- try {
377
- const r = await fetchWithTimeout(infoUrl, 2000);
378
- if (!r.ok) return null;
379
- return await r.json();
380
- } catch { return null; }
381
- }
382
-
383
- function isInteractiveKind(kind) {
384
- return kind === 'api' || kind === 'tool';
385
- }
386
-
387
- function appendDetailRow(parent, label, node) {
388
- const row = document.createElement('div');
389
- row.className = 'row';
390
- const lab = document.createElement('span');
391
- lab.className = 'label';
392
- lab.textContent = label;
393
- row.appendChild(lab);
394
- row.appendChild(node);
395
- parent.appendChild(row);
396
- }
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
+ };
397
259
 
398
- function linkNode(href, text) {
399
- const a = document.createElement('a');
400
- a.href = href;
401
- a.textContent = text || href;
402
- a.target = '_blank';
403
- a.rel = 'noopener';
404
- return a;
405
- }
406
-
407
- function renderDetails(svc, info) {
408
- const box = document.createElement('div');
409
- box.className = 'details';
410
- // OAuth discovery is served by the hub (this origin) per the Phase 0 seam.
411
- appendDetailRow(
412
- box,
413
- 'OAuth discovery',
414
- linkNode('/.well-known/oauth-authorization-server'),
415
- );
416
- if (info && typeof info.mcpUrl === 'string' && info.mcpUrl) {
417
- appendDetailRow(box, 'MCP endpoint', linkNode(info.mcpUrl));
418
- }
419
- if (info && typeof info.openInNotesUrl === 'string' && info.openInNotesUrl) {
420
- appendDetailRow(box, 'Open in Notes', linkNode(info.openInNotesUrl, 'Open →'));
421
- }
422
- // Direct URL is still useful for power users even if the card doesn't navigate.
423
- appendDetailRow(box, 'Service URL', linkNode(svc.url));
424
- // Empty slot — config fetched + populated lazily on first expand.
425
- const configSlot = document.createElement('div');
426
- configSlot.className = 'config-slot';
427
- box.appendChild(configSlot);
428
- return { box, configSlot };
260
+ function isVaultName(name) {
261
+ return name === 'parachute-vault' || name.startsWith('parachute-vault-');
429
262
  }
430
263
 
431
- async function fetchConfig(svcUrl) {
432
- // Schema endpoint may 404 for modules that haven't shipped config yet;
433
- // in that case we render nothing (no error surfaced).
434
- const schemaResp = await fetchWithTimeout(
435
- svcUrl.replace(/\\/+$/, '') + '/.parachute/config/schema',
436
- 2000,
437
- ).catch(() => null);
438
- if (!schemaResp || !schemaResp.ok) return null;
439
- const schema = await schemaResp.json().catch(() => null);
440
- if (!schema || typeof schema !== 'object') return null;
441
- const valuesResp = await fetchWithTimeout(
442
- svcUrl.replace(/\\/+$/, '') + '/.parachute/config',
443
- 2000,
444
- ).catch(() => null);
445
- const values = valuesResp && valuesResp.ok ? await valuesResp.json().catch(() => ({})) : {};
446
- return { schema, values: values && typeof values === 'object' ? values : {} };
447
- }
448
-
449
- function labelFor(name, schema) {
450
- return (schema && typeof schema.title === 'string' && schema.title) || name;
264
+ function shortName(manifestName) {
265
+ return manifestName.replace(/^parachute-/, '');
451
266
  }
452
267
 
453
- function renderConfigField(name, schema, value) {
454
- const field = document.createElement('div');
455
- field.className = 'field';
456
-
457
- const label = document.createElement('span');
458
- label.className = 'field-label';
459
- label.textContent = labelFor(name, schema);
460
- field.appendChild(label);
461
-
462
- const type = schema && typeof schema.type === 'string' ? schema.type : 'string';
463
- const writeOnly = schema && schema.writeOnly === true;
464
-
465
- let input;
466
- if (type === 'string' && Array.isArray(schema.enum)) {
467
- input = document.createElement('select');
468
- for (const opt of schema.enum) {
469
- const o = document.createElement('option');
470
- o.value = String(opt);
471
- o.textContent = String(opt);
472
- if (String(opt) === String(value ?? '')) o.selected = true;
473
- input.appendChild(o);
474
- }
475
- } else if (type === 'boolean') {
476
- input = document.createElement('input');
477
- input.type = 'checkbox';
478
- input.checked = value === true;
479
- } else if (type === 'integer' || type === 'number') {
480
- input = document.createElement('input');
481
- input.type = 'number';
482
- if (value !== undefined && value !== null) input.value = String(value);
483
- } else {
484
- input = document.createElement('input');
485
- input.type = schema && schema.format === 'uri' ? 'url' : 'text';
486
- if (writeOnly) {
487
- input.placeholder = '\u2022\u2022\u2022\u2022\u2022\u2022';
488
- input.value = '';
489
- } else if (value !== undefined && value !== null) {
490
- input.value = String(value);
491
- }
492
- }
493
- input.disabled = true;
494
- input.setAttribute('aria-readonly', 'true');
495
- field.appendChild(input);
496
-
497
- if (schema && typeof schema.description === 'string' && schema.description) {
498
- const desc = document.createElement('span');
499
- desc.className = 'field-description';
500
- desc.textContent = schema.description;
501
- field.appendChild(desc);
502
- }
503
-
504
- return field;
268
+ function labelFor(type) {
269
+ if (MODULE_LABELS[type]) return MODULE_LABELS[type];
270
+ return type.charAt(0).toUpperCase() + type.slice(1);
505
271
  }
506
272
 
507
- function renderConfigObject(schema, values, legendText) {
508
- const fs = document.createElement('fieldset');
509
- if (legendText) {
510
- const lg = document.createElement('legend');
511
- lg.textContent = legendText;
512
- fs.appendChild(lg);
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
+ });
513
286
  }
514
- const props =
515
- schema && typeof schema.properties === 'object' && schema.properties ? schema.properties : {};
516
- for (const [name, propSchema] of Object.entries(props)) {
517
- const v = values && typeof values === 'object' ? values[name] : undefined;
518
- if (propSchema && propSchema.type === 'object') {
519
- fs.appendChild(renderConfigObject(propSchema, v || {}, labelFor(name, propSchema)));
520
- } else if (propSchema && propSchema.type === 'array') {
521
- // Phase 2: arrays render as a disabled textarea summary; add/remove is Phase 3.
522
- const f = document.createElement('div');
523
- f.className = 'field';
524
- const label = document.createElement('span');
525
- label.className = 'field-label';
526
- label.textContent = labelFor(name, propSchema);
527
- f.appendChild(label);
528
- const ta = document.createElement('textarea');
529
- ta.rows = 2;
530
- ta.value = Array.isArray(v) ? v.join('\\n') : '';
531
- ta.disabled = true;
532
- ta.setAttribute('aria-readonly', 'true');
533
- f.appendChild(ta);
534
- fs.appendChild(f);
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;
535
293
  } else {
536
- fs.appendChild(renderConfigField(name, propSchema, v));
294
+ groups.set(t, {
295
+ type: t,
296
+ label: labelFor(t),
297
+ count: 1,
298
+ manageUrl: svc.path,
299
+ });
537
300
  }
538
301
  }
539
- return fs;
302
+ return groups;
540
303
  }
541
304
 
542
- function renderConfigBody(schema, values) {
543
- const wrap = document.createElement('div');
544
- wrap.className = 'config';
545
- const title = document.createElement('h3');
546
- title.textContent = (schema && schema.title) || 'Configuration';
547
- wrap.appendChild(title);
548
- const hint = document.createElement('span');
549
- hint.className = 'hint';
550
- hint.textContent =
551
- 'Configuration is read-only in this launch — edit via CLI or ~/.parachute/<svc>/.env.';
552
- wrap.appendChild(hint);
553
- wrap.appendChild(renderConfigObject(schema, values, null));
554
- return wrap;
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);
555
316
  }
556
317
 
557
- function renderCard(svc, info) {
558
- const kind = info && typeof info.kind === 'string' ? info.kind : 'frontend';
559
- const interactive = isInteractiveKind(kind);
560
- const root = document.createElement(interactive ? 'div' : 'a');
561
- root.className = 'card' + (interactive ? ' interactive' : '');
562
- if (!interactive) root.href = svc.url;
563
- let configSlotRef = null;
564
- let configLoaded = false;
565
- if (interactive) {
566
- root.setAttribute('role', 'button');
567
- root.setAttribute('tabindex', '0');
568
- root.setAttribute('aria-expanded', 'false');
569
- const toggle = async () => {
570
- const next = !root.classList.contains('expanded');
571
- root.classList.toggle('expanded', next);
572
- root.setAttribute('aria-expanded', next ? 'true' : 'false');
573
- if (next && !configLoaded && configSlotRef) {
574
- configLoaded = true;
575
- const data = await fetchConfig(svc.url);
576
- if (data) configSlotRef.appendChild(renderConfigBody(data.schema, data.values));
577
- }
578
- };
579
- root.addEventListener('click', (e) => {
580
- if (e.target && e.target.closest && e.target.closest('.details')) return;
581
- toggle();
582
- });
583
- root.addEventListener('keydown', (e) => {
584
- if (e.key === 'Enter' || e.key === ' ') {
585
- e.preventDefault();
586
- toggle();
587
- }
588
- });
589
- }
318
+ function renderTile(group) {
319
+ const a = document.createElement('a');
320
+ a.className = 'card';
321
+ a.href = group.manageUrl;
590
322
 
591
323
  const head = document.createElement('div');
592
324
  head.className = 'card-head';
593
325
 
594
326
  const icon = document.createElement('div');
595
327
  icon.className = 'icon';
596
- const iconUrl = info && info.icon ? new URL(info.icon, svc.url).toString() : null;
597
- if (iconUrl) {
598
- const img = document.createElement('img');
599
- img.src = iconUrl;
600
- img.alt = '';
601
- img.onerror = () => { icon.innerHTML = fallbackIcon; };
602
- icon.appendChild(img);
603
- } else {
604
- icon.innerHTML = fallbackIcon;
605
- }
328
+ icon.innerHTML = fallbackIcon;
606
329
 
607
330
  const title = document.createElement('h2');
608
331
  title.className = 'card-title';
609
- title.textContent = (info && info.displayName) || shortName(svc.name);
332
+ title.textContent = group.label;
610
333
 
611
334
  head.appendChild(icon);
612
335
  head.appendChild(title);
613
- root.appendChild(head);
336
+ a.appendChild(head);
614
337
 
615
- const tag = info && info.tagline;
616
- if (tag) {
617
- const p = document.createElement('p');
618
- p.className = 'card-tagline';
619
- p.textContent = tag;
620
- root.appendChild(p);
621
- }
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);
622
342
 
623
343
  const meta = document.createElement('div');
624
344
  meta.className = 'card-meta';
625
345
  const path = document.createElement('span');
626
346
  path.className = 'path';
627
- path.textContent = svc.path;
628
- const right = document.createElement('span');
629
- right.style.display = 'inline-flex';
630
- right.style.gap = '0.35rem';
631
- right.style.alignItems = 'center';
632
- if (interactive) {
633
- const badge = document.createElement('span');
634
- badge.className = 'kind-badge';
635
- badge.textContent = kind;
636
- right.appendChild(badge);
637
- }
638
- const ver = document.createElement('span');
639
- ver.className = 'version';
640
- ver.textContent = 'v' + svc.version;
641
- right.appendChild(ver);
347
+ path.textContent = group.manageUrl;
348
+ const manage = document.createElement('span');
349
+ manage.className = 'manage';
350
+ manage.textContent = 'Manage \u2192';
642
351
  meta.appendChild(path);
643
- meta.appendChild(right);
644
- root.appendChild(meta);
352
+ meta.appendChild(manage);
353
+ a.appendChild(meta);
645
354
 
646
- if (interactive) {
647
- const d = renderDetails(svc, info);
648
- configSlotRef = d.configSlot;
649
- root.appendChild(d.box);
650
- }
651
-
652
- return root;
355
+ return a;
653
356
  }
654
357
 
655
358
  try {
@@ -657,15 +360,19 @@ const HTML = `<!doctype html>
657
360
  if (!wk.ok) throw new Error('well-known fetch failed: ' + wk.status);
658
361
  const doc = await wk.json();
659
362
  const services = Array.isArray(doc.services) ? doc.services : [];
660
- if (services.length === 0) {
661
- root.innerHTML = '<div class="empty">No services installed yet. Try <code>parachute install vault</code>.</div>';
363
+ const vaults = Array.isArray(doc.vaults) ? doc.vaults : [];
364
+
365
+ const groups = aggregate(services, vaults);
366
+ const tiles = tilesInOrder(groups);
367
+
368
+ if (tiles.length === 0) {
369
+ root.innerHTML = '<div class="empty">No modules installed yet. Try <code>parachute install vault</code>.</div>';
662
370
  return;
663
371
  }
664
- const infos = await Promise.all(services.map((s) => loadInfo(s.infoUrl)));
665
372
  root.innerHTML = '';
666
- services.forEach((svc, i) => root.appendChild(renderCard(svc, infos[i])));
373
+ for (const g of tiles) root.appendChild(renderTile(g));
667
374
  } catch (err) {
668
- root.innerHTML = '<div class="error">Could not load services: ' + (err && err.message ? err.message : String(err)) + '</div>';
375
+ root.innerHTML = '<div class="error">Could not load modules: ' + (err && err.message ? err.message : String(err)) + '</div>';
669
376
  }
670
377
  })();
671
378
  </script>
package/src/jwks.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * PEM → JWK conversion for the hub's `/.well-known/jwks.json` endpoint.
3
+ *
4
+ * `node:crypto.createPublicKey(pem).export({format: 'jwk'})` already returns
5
+ * the canonical {kty, n, e} for an RSA public key. We layer the JWKS-level
6
+ * fields (`kid`, `alg`, `use`) on top so consumers can pick the right key
7
+ * without extra metadata.
8
+ */
9
+ import { createPublicKey } from "node:crypto";
10
+
11
+ export interface Jwk {
12
+ kty: "RSA";
13
+ n: string;
14
+ e: string;
15
+ kid: string;
16
+ alg: "RS256";
17
+ use: "sig";
18
+ }
19
+
20
+ export interface Jwks {
21
+ keys: Jwk[];
22
+ }
23
+
24
+ export function pemToJwk(publicKeyPem: string, kid: string): Jwk {
25
+ const exported = createPublicKey(publicKeyPem).export({ format: "jwk" }) as {
26
+ kty?: string;
27
+ n?: string;
28
+ e?: string;
29
+ };
30
+ if (exported.kty !== "RSA" || !exported.n || !exported.e) {
31
+ throw new Error(`pemToJwk: expected RSA public key, got kty=${String(exported.kty)}`);
32
+ }
33
+ return {
34
+ kty: "RSA",
35
+ n: exported.n,
36
+ e: exported.e,
37
+ kid,
38
+ alg: "RS256",
39
+ use: "sig",
40
+ };
41
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Audience derivation for hub-issued JWTs. Used by both:
3
+ * - `/oauth/token` (auth_code redemption + refresh rotation)
4
+ * - `parachute auth mint-token` (CLI shortcut for scope-narrow tokens)
5
+ *
6
+ * Per the vault-config-and-scopes design (Phase 1+2):
7
+ * - A named `vault:<name>:<verb>` → `vault.<name>` (RFC 8707-style resource
8
+ * binding; vault enforces this strict-equality against the URL-derived
9
+ * vault name).
10
+ * - An unnamed `<service>:<verb>` → `<service>` (legacy shape; vault's
11
+ * strict-check rejects unnamed `vault:*` audiences, so the consent
12
+ * picker rewrites those before this is reached).
13
+ * - Fallback: `hub` (no namespaced scope).
14
+ *
15
+ * Named vault scopes win over unnamed ones — an OAuth flow that mixes
16
+ * `vault:work:read` + `scribe:transcribe` audiences is grounded on the vault
17
+ * (the more sensitive resource), and tokens are issued per-flow anyway.
18
+ *
19
+ * Hoisted from `oauth-handlers.ts` so CLI mints and OAuth mints can't diverge
20
+ * on audience semantics — a divergence here means tokens minted via CLI fail
21
+ * audience strict-check at the resource server even though scopes match.
22
+ */
23
+
24
+ export const VAULT_VERBS = new Set(["read", "write", "admin"]);
25
+
26
+ export function inferAudience(scopes: readonly string[]): string {
27
+ for (const s of scopes) {
28
+ const parts = s.split(":");
29
+ const name = parts[1];
30
+ const verb = parts[2];
31
+ if (parts.length === 3 && parts[0] === "vault" && name && verb && VAULT_VERBS.has(verb)) {
32
+ return `vault.${name}`;
33
+ }
34
+ }
35
+ for (const s of scopes) {
36
+ const colon = s.indexOf(":");
37
+ if (colon > 0) return s.slice(0, colon);
38
+ }
39
+ return "hub";
40
+ }