@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.
- package/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- 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.
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
19
|
-
*
|
|
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-
|
|
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
|
-
.
|
|
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
|
|
236
|
+
<p class="tagline">Your personal-computing modules.</p>
|
|
351
237
|
</header>
|
|
352
|
-
<section id="
|
|
353
|
-
<div class="empty" id="loading">Loading
|
|
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('
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
399
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const
|
|
518
|
-
if (
|
|
519
|
-
|
|
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
|
-
|
|
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
|
|
302
|
+
return groups;
|
|
540
303
|
}
|
|
541
304
|
|
|
542
|
-
function
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
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 =
|
|
332
|
+
title.textContent = group.label;
|
|
610
333
|
|
|
611
334
|
head.appendChild(icon);
|
|
612
335
|
head.appendChild(title);
|
|
613
|
-
|
|
336
|
+
a.appendChild(head);
|
|
614
337
|
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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 =
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
|
|
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(
|
|
644
|
-
|
|
352
|
+
meta.appendChild(manage);
|
|
353
|
+
a.appendChild(meta);
|
|
645
354
|
|
|
646
|
-
|
|
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
|
-
|
|
661
|
-
|
|
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
|
-
|
|
373
|
+
for (const g of tiles) root.appendChild(renderTile(g));
|
|
667
374
|
} catch (err) {
|
|
668
|
-
root.innerHTML = '<div class="error">Could not load
|
|
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
|
+
}
|