@openparachute/hub 0.5.2 → 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.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +159 -320
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +123 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +986 -66
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +1000 -3
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +372 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +63 -260
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +99 -8
- package/src/csrf.ts +6 -3
- package/src/help.ts +13 -7
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +572 -106
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +395 -29
- package/src/oauth-ui.ts +188 -0
- package/src/operator-token.ts +272 -18
- package/src/origin-check.ts +127 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +166 -0
- package/src/scope-explanations.ts +33 -2
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -115,18 +115,21 @@ describe("setup", () => {
|
|
|
115
115
|
const h = makeHarness();
|
|
116
116
|
try {
|
|
117
117
|
// Pre-seed every first-party shortname so survey returns all-installed.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"parachute-
|
|
122
|
-
"parachute-
|
|
123
|
-
|
|
118
|
+
// Distinct canonical ports per service — services-manifest.ts now
|
|
119
|
+
// rejects duplicate ports between distinct services (hub#195).
|
|
120
|
+
const seeds: Array<{ name: string; port: number }> = [
|
|
121
|
+
{ name: "parachute-vault", port: 1940 },
|
|
122
|
+
{ name: "parachute-notes", port: 1942 },
|
|
123
|
+
{ name: "parachute-scribe", port: 1943 },
|
|
124
|
+
{ name: "parachute-channel", port: 1941 },
|
|
125
|
+
];
|
|
126
|
+
for (const s of seeds) {
|
|
124
127
|
upsertService(
|
|
125
128
|
{
|
|
126
|
-
name:
|
|
129
|
+
name: s.name,
|
|
127
130
|
version: "0.0.0",
|
|
128
|
-
port:
|
|
129
|
-
paths: [`/${
|
|
131
|
+
port: s.port,
|
|
132
|
+
paths: [`/${s.name.replace(/^parachute-/, "")}`],
|
|
130
133
|
health: "/health",
|
|
131
134
|
},
|
|
132
135
|
h.manifestPath,
|
|
@@ -317,6 +317,179 @@ describe("status", () => {
|
|
|
317
317
|
}
|
|
318
318
|
});
|
|
319
319
|
|
|
320
|
+
// Canonical-port drift warning (hub#195). When a known service ends up at
|
|
321
|
+
// a non-canonical port (because of an upgrade rewrite, a port-walk fallback,
|
|
322
|
+
// or an operator edit), surface it in `parachute status` so a silent miswire
|
|
323
|
+
// is operator-visible. Warning, not error — operators may have moved the
|
|
324
|
+
// service deliberately to dodge a third-party clash.
|
|
325
|
+
describe("canonical-port drift warning", () => {
|
|
326
|
+
test("warns when scribe is at non-canonical port (1944 instead of 1943)", async () => {
|
|
327
|
+
const { path, cleanup } = makeTempPath();
|
|
328
|
+
try {
|
|
329
|
+
upsertService(
|
|
330
|
+
{
|
|
331
|
+
name: "parachute-scribe",
|
|
332
|
+
port: 1944,
|
|
333
|
+
paths: ["/scribe"],
|
|
334
|
+
health: "/scribe/health",
|
|
335
|
+
version: "0.4.0",
|
|
336
|
+
},
|
|
337
|
+
path,
|
|
338
|
+
);
|
|
339
|
+
const lines: string[] = [];
|
|
340
|
+
await status({
|
|
341
|
+
manifestPath: path,
|
|
342
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
343
|
+
print: (l) => lines.push(l),
|
|
344
|
+
});
|
|
345
|
+
expect(lines.some((l) => l.includes("canonical port is 1943"))).toBe(true);
|
|
346
|
+
} finally {
|
|
347
|
+
cleanup();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("does not warn when service is on its canonical port", async () => {
|
|
352
|
+
const { path, cleanup } = makeTempPath();
|
|
353
|
+
try {
|
|
354
|
+
upsertService(
|
|
355
|
+
{
|
|
356
|
+
name: "parachute-scribe",
|
|
357
|
+
port: 1943,
|
|
358
|
+
paths: ["/scribe"],
|
|
359
|
+
health: "/scribe/health",
|
|
360
|
+
version: "0.4.0",
|
|
361
|
+
},
|
|
362
|
+
path,
|
|
363
|
+
);
|
|
364
|
+
const lines: string[] = [];
|
|
365
|
+
await status({
|
|
366
|
+
manifestPath: path,
|
|
367
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
368
|
+
print: (l) => lines.push(l),
|
|
369
|
+
});
|
|
370
|
+
expect(lines.some((l) => l.includes("canonical port"))).toBe(false);
|
|
371
|
+
} finally {
|
|
372
|
+
cleanup();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("does not warn for third-party services with no canonical port", async () => {
|
|
377
|
+
const { path, cleanup } = makeTempPath();
|
|
378
|
+
try {
|
|
379
|
+
upsertService(
|
|
380
|
+
{
|
|
381
|
+
name: "third-party-thing",
|
|
382
|
+
port: 9000,
|
|
383
|
+
paths: ["/widget"],
|
|
384
|
+
health: "/health",
|
|
385
|
+
version: "1.0.0",
|
|
386
|
+
},
|
|
387
|
+
path,
|
|
388
|
+
);
|
|
389
|
+
const lines: string[] = [];
|
|
390
|
+
await status({
|
|
391
|
+
manifestPath: path,
|
|
392
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
393
|
+
print: (l) => lines.push(l),
|
|
394
|
+
});
|
|
395
|
+
expect(lines.some((l) => l.includes("canonical port"))).toBe(false);
|
|
396
|
+
} finally {
|
|
397
|
+
cleanup();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("warning does not affect exit code (status stays 0 when healthy)", async () => {
|
|
402
|
+
const { path, cleanup } = makeTempPath();
|
|
403
|
+
try {
|
|
404
|
+
upsertService(
|
|
405
|
+
{
|
|
406
|
+
name: "parachute-scribe",
|
|
407
|
+
port: 1944,
|
|
408
|
+
paths: ["/scribe"],
|
|
409
|
+
health: "/scribe/health",
|
|
410
|
+
version: "0.4.0",
|
|
411
|
+
},
|
|
412
|
+
path,
|
|
413
|
+
);
|
|
414
|
+
const code = await status({
|
|
415
|
+
manifestPath: path,
|
|
416
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
417
|
+
print: () => {},
|
|
418
|
+
});
|
|
419
|
+
// Drift is informational. A healthy probed service still returns 0
|
|
420
|
+
// even when the port has drifted off canonical.
|
|
421
|
+
expect(code).toBe(0);
|
|
422
|
+
} finally {
|
|
423
|
+
cleanup();
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("warning still fires when service is stopped (probe skipped)", async () => {
|
|
428
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
429
|
+
try {
|
|
430
|
+
upsertService(
|
|
431
|
+
{
|
|
432
|
+
name: "parachute-scribe",
|
|
433
|
+
port: 1944,
|
|
434
|
+
paths: ["/scribe"],
|
|
435
|
+
health: "/scribe/health",
|
|
436
|
+
version: "0.4.0",
|
|
437
|
+
},
|
|
438
|
+
path,
|
|
439
|
+
);
|
|
440
|
+
writePid("scribe", 4242, configDir);
|
|
441
|
+
const lines: string[] = [];
|
|
442
|
+
await status({
|
|
443
|
+
manifestPath: path,
|
|
444
|
+
configDir,
|
|
445
|
+
alive: () => false,
|
|
446
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
447
|
+
print: (l) => lines.push(l),
|
|
448
|
+
});
|
|
449
|
+
// Drift is computed from services.json, not from the probe — a
|
|
450
|
+
// stopped service with a drifted port should still surface the
|
|
451
|
+
// warning so operators see the miswire even before they start it.
|
|
452
|
+
expect(lines.some((l) => l.includes("canonical port is 1943"))).toBe(true);
|
|
453
|
+
} finally {
|
|
454
|
+
cleanup();
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("multi-vault instance rows do not surface a drift warning (intentional gap)", async () => {
|
|
459
|
+
// Pinning the documented gap: `parachute-vault-default` is not
|
|
460
|
+
// a canonical manifest name in FIRST_PARTY_FALLBACKS, so
|
|
461
|
+
// `canonicalPortForManifest` returns undefined and no drift
|
|
462
|
+
// warning fires — even when the row's port differs from the
|
|
463
|
+
// canonical `parachute-vault` port (1940). Rationale lives on
|
|
464
|
+
// `canonicalPortForManifest` in service-spec.ts; this test pins
|
|
465
|
+
// the behavior so a future change to the lookup shape doesn't
|
|
466
|
+
// accidentally start emitting drift on every multi-vault row
|
|
467
|
+
// without an explicit decision.
|
|
468
|
+
const { path, cleanup } = makeTempPath();
|
|
469
|
+
try {
|
|
470
|
+
upsertService(
|
|
471
|
+
{
|
|
472
|
+
name: "parachute-vault-default",
|
|
473
|
+
port: 1944,
|
|
474
|
+
paths: ["/vault/default"],
|
|
475
|
+
health: "/vault/default/health",
|
|
476
|
+
version: "0.2.4",
|
|
477
|
+
},
|
|
478
|
+
path,
|
|
479
|
+
);
|
|
480
|
+
const lines: string[] = [];
|
|
481
|
+
await status({
|
|
482
|
+
manifestPath: path,
|
|
483
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
484
|
+
print: (l) => lines.push(l),
|
|
485
|
+
});
|
|
486
|
+
expect(lines.some((l) => l.includes("canonical port"))).toBe(false);
|
|
487
|
+
} finally {
|
|
488
|
+
cleanup();
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
320
493
|
test("stopped services still render a URL line so the user knows where to point clients post-start", async () => {
|
|
321
494
|
const { path, configDir, cleanup } = makeTempPath();
|
|
322
495
|
try {
|
|
@@ -344,4 +517,203 @@ describe("status", () => {
|
|
|
344
517
|
cleanup();
|
|
345
518
|
}
|
|
346
519
|
});
|
|
520
|
+
|
|
521
|
+
describe("install-source surface (hub#243)", () => {
|
|
522
|
+
test("renders SOURCE column header + per-row label", async () => {
|
|
523
|
+
const { path, cleanup } = makeTempPath();
|
|
524
|
+
try {
|
|
525
|
+
upsertService(
|
|
526
|
+
{
|
|
527
|
+
name: "parachute-vault",
|
|
528
|
+
port: 1940,
|
|
529
|
+
paths: ["/vault/default"],
|
|
530
|
+
health: "/vault/default/health",
|
|
531
|
+
version: "0.4.4-rc.3",
|
|
532
|
+
installDir: "/Users/me/code/parachute-vault",
|
|
533
|
+
},
|
|
534
|
+
path,
|
|
535
|
+
);
|
|
536
|
+
const lines: string[] = [];
|
|
537
|
+
await status({
|
|
538
|
+
manifestPath: path,
|
|
539
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
540
|
+
print: (l) => lines.push(l),
|
|
541
|
+
installSourceDeps: {
|
|
542
|
+
bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
|
|
543
|
+
resolveBunGlobal: () => null,
|
|
544
|
+
readJson: (p) =>
|
|
545
|
+
p === "/Users/me/code/parachute-vault/package.json"
|
|
546
|
+
? { name: "@openparachute/vault", version: "0.4.4-rc.3" }
|
|
547
|
+
: (() => {
|
|
548
|
+
throw new Error("nope");
|
|
549
|
+
})(),
|
|
550
|
+
readGitHead: () => "8aa167b",
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
expect(lines[0]).toMatch(/SOURCE/);
|
|
554
|
+
expect(lines.some((l) => l.includes("bun-linked → parachute-vault @ 8aa167b"))).toBe(true);
|
|
555
|
+
} finally {
|
|
556
|
+
cleanup();
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("STALE continuation line fires when bun-linked live version != cached version", async () => {
|
|
561
|
+
// Reproduces hub#243's motivating case: services.json says 0.3.11-rc.1
|
|
562
|
+
// but the live source has been rebuilt to 0.3.15-rc.1. Operator should
|
|
563
|
+
// see STALE in one glance from `parachute status` output.
|
|
564
|
+
const { path, cleanup } = makeTempPath();
|
|
565
|
+
try {
|
|
566
|
+
upsertService(
|
|
567
|
+
{
|
|
568
|
+
name: "parachute-notes",
|
|
569
|
+
port: 1942,
|
|
570
|
+
paths: ["/notes"],
|
|
571
|
+
health: "/notes/health",
|
|
572
|
+
version: "0.3.11-rc.1",
|
|
573
|
+
installDir: "/Users/me/code/parachute-notes",
|
|
574
|
+
},
|
|
575
|
+
path,
|
|
576
|
+
);
|
|
577
|
+
const lines: string[] = [];
|
|
578
|
+
await status({
|
|
579
|
+
manifestPath: path,
|
|
580
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
581
|
+
print: (l) => lines.push(l),
|
|
582
|
+
installSourceDeps: {
|
|
583
|
+
bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
|
|
584
|
+
resolveBunGlobal: () => null,
|
|
585
|
+
readJson: (p) =>
|
|
586
|
+
p === "/Users/me/code/parachute-notes/package.json"
|
|
587
|
+
? { name: "@openparachute/notes", version: "0.3.15-rc.1" }
|
|
588
|
+
: (() => {
|
|
589
|
+
throw new Error("nope");
|
|
590
|
+
})(),
|
|
591
|
+
readGitHead: () => "051c404",
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
expect(
|
|
595
|
+
lines.some((l) =>
|
|
596
|
+
l.includes("STALE: services.json cached 0.3.11-rc.1; live package.json 0.3.15-rc.1"),
|
|
597
|
+
),
|
|
598
|
+
).toBe(true);
|
|
599
|
+
} finally {
|
|
600
|
+
cleanup();
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("npm-installed services render as `npm (<version>)` and never STALE", async () => {
|
|
605
|
+
const { path, cleanup } = makeTempPath();
|
|
606
|
+
try {
|
|
607
|
+
upsertService(
|
|
608
|
+
{
|
|
609
|
+
name: "parachute-scribe",
|
|
610
|
+
port: 1943,
|
|
611
|
+
paths: ["/scribe"],
|
|
612
|
+
health: "/scribe/health",
|
|
613
|
+
version: "0.4.2-rc.1",
|
|
614
|
+
installDir: "/home/test/.bun/install/global/node_modules/@openparachute/scribe",
|
|
615
|
+
},
|
|
616
|
+
path,
|
|
617
|
+
);
|
|
618
|
+
const lines: string[] = [];
|
|
619
|
+
await status({
|
|
620
|
+
manifestPath: path,
|
|
621
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
622
|
+
print: (l) => lines.push(l),
|
|
623
|
+
installSourceDeps: {
|
|
624
|
+
bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
|
|
625
|
+
resolveBunGlobal: () => null,
|
|
626
|
+
readJson: (p) =>
|
|
627
|
+
p === "/home/test/.bun/install/global/node_modules/@openparachute/scribe/package.json"
|
|
628
|
+
? { name: "@openparachute/scribe", version: "0.4.2-rc.1" }
|
|
629
|
+
: (() => {
|
|
630
|
+
throw new Error("nope");
|
|
631
|
+
})(),
|
|
632
|
+
readGitHead: () => undefined,
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
expect(lines.some((l) => l.includes("npm (0.4.2-rc.1)"))).toBe(true);
|
|
636
|
+
expect(lines.some((l) => l.includes("STALE:"))).toBe(false);
|
|
637
|
+
} finally {
|
|
638
|
+
cleanup();
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("entries without installDir fall back to bun-global symlink lookup", async () => {
|
|
643
|
+
// Some services.json entries (older first-party rows, or rows written
|
|
644
|
+
// by a service that doesn't echo installDir) leave the field absent.
|
|
645
|
+
// detectInstallSource maps the entry name → first-party package and
|
|
646
|
+
// probes bun globals for the symlink. Pins that fallback path.
|
|
647
|
+
const { path, cleanup } = makeTempPath();
|
|
648
|
+
try {
|
|
649
|
+
upsertService(
|
|
650
|
+
{
|
|
651
|
+
name: "parachute-vault",
|
|
652
|
+
port: 1940,
|
|
653
|
+
paths: ["/vault/default"],
|
|
654
|
+
health: "/vault/default/health",
|
|
655
|
+
version: "0.4.4-rc.3",
|
|
656
|
+
// No installDir.
|
|
657
|
+
},
|
|
658
|
+
path,
|
|
659
|
+
);
|
|
660
|
+
const lines: string[] = [];
|
|
661
|
+
await status({
|
|
662
|
+
manifestPath: path,
|
|
663
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
664
|
+
print: (l) => lines.push(l),
|
|
665
|
+
installSourceDeps: {
|
|
666
|
+
bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
|
|
667
|
+
resolveBunGlobal: (pkg) =>
|
|
668
|
+
pkg === "@openparachute/vault" ? "/Users/me/code/parachute-vault" : null,
|
|
669
|
+
readJson: (p) =>
|
|
670
|
+
p === "/Users/me/code/parachute-vault/package.json"
|
|
671
|
+
? { name: "@openparachute/vault", version: "0.4.4-rc.3" }
|
|
672
|
+
: (() => {
|
|
673
|
+
throw new Error("nope");
|
|
674
|
+
})(),
|
|
675
|
+
readGitHead: () => "8aa167b",
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
expect(lines.some((l) => l.includes("bun-linked → parachute-vault @ 8aa167b"))).toBe(true);
|
|
679
|
+
} finally {
|
|
680
|
+
cleanup();
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("third-party row without installDir + no mapping renders as 'unknown'", async () => {
|
|
685
|
+
const { path, cleanup } = makeTempPath();
|
|
686
|
+
try {
|
|
687
|
+
upsertService(
|
|
688
|
+
{
|
|
689
|
+
name: "agent",
|
|
690
|
+
port: 1946,
|
|
691
|
+
paths: ["/agent"],
|
|
692
|
+
health: "/agent/health",
|
|
693
|
+
version: "0.1.4-rc.1",
|
|
694
|
+
// No installDir; agent isn't in FIRST_PARTY_FALLBACKS by short name,
|
|
695
|
+
// and the fallback bun-global lookup needs a known package name.
|
|
696
|
+
},
|
|
697
|
+
path,
|
|
698
|
+
);
|
|
699
|
+
const lines: string[] = [];
|
|
700
|
+
await status({
|
|
701
|
+
manifestPath: path,
|
|
702
|
+
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
703
|
+
print: (l) => lines.push(l),
|
|
704
|
+
installSourceDeps: {
|
|
705
|
+
bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
|
|
706
|
+
resolveBunGlobal: () => null,
|
|
707
|
+
readJson: () => {
|
|
708
|
+
throw new Error("not reached");
|
|
709
|
+
},
|
|
710
|
+
readGitHead: () => undefined,
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
expect(lines.some((l) => l.includes("unknown"))).toBe(true);
|
|
714
|
+
} finally {
|
|
715
|
+
cleanup();
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
});
|
|
347
719
|
});
|
|
@@ -302,6 +302,75 @@ describe("buildWellKnown", () => {
|
|
|
302
302
|
expect(doc.notes).toEqual([{ url: "https://x.example/notes", version: "0.0.1" }]);
|
|
303
303
|
});
|
|
304
304
|
|
|
305
|
+
// Phase D consumer-side: services entries surface uiUrl + displayName +
|
|
306
|
+
// tagline so the discovery page can render data-driven Service tiles.
|
|
307
|
+
test("uiUrl resolver result rides into doc.services entry as absolute URL", () => {
|
|
308
|
+
const doc = buildWellKnown({
|
|
309
|
+
services: [notes],
|
|
310
|
+
canonicalOrigin: "https://x.example",
|
|
311
|
+
uiUrlFor: () => "/notes",
|
|
312
|
+
});
|
|
313
|
+
const svc = doc.services.find((s) => s.name === "parachute-notes");
|
|
314
|
+
expect(svc?.uiUrl).toBe("https://x.example/notes");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("uiUrl absolute URL passes through verbatim", () => {
|
|
318
|
+
const doc = buildWellKnown({
|
|
319
|
+
services: [notes],
|
|
320
|
+
canonicalOrigin: "https://x.example",
|
|
321
|
+
uiUrlFor: () => "https://notes.example.com/app",
|
|
322
|
+
});
|
|
323
|
+
const svc = doc.services.find((s) => s.name === "parachute-notes");
|
|
324
|
+
expect(svc?.uiUrl).toBe("https://notes.example.com/app");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("uiUrl absent when the resolver returns undefined (vault case)", () => {
|
|
328
|
+
const doc = buildWellKnown({
|
|
329
|
+
services: [vault, notes],
|
|
330
|
+
canonicalOrigin: "https://x.example",
|
|
331
|
+
uiUrlFor: (e) => (e.name === "parachute-notes" ? "/notes" : undefined),
|
|
332
|
+
});
|
|
333
|
+
const vaultSvc = doc.services.find((s) => s.name === "parachute-vault");
|
|
334
|
+
const notesSvc = doc.services.find((s) => s.name === "parachute-notes");
|
|
335
|
+
expect(vaultSvc).not.toHaveProperty("uiUrl");
|
|
336
|
+
expect(notesSvc?.uiUrl).toBe("https://x.example/notes");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("displayName resolver overrides services.json displayName", () => {
|
|
340
|
+
const notesWithName: ServiceEntry = { ...notes, displayName: "FromServicesJson" };
|
|
341
|
+
const doc = buildWellKnown({
|
|
342
|
+
services: [notesWithName],
|
|
343
|
+
canonicalOrigin: "https://x.example",
|
|
344
|
+
displayNameFor: () => "FromModuleJson",
|
|
345
|
+
});
|
|
346
|
+
const svc = doc.services.find((s) => s.name === "parachute-notes");
|
|
347
|
+
expect(svc?.displayName).toBe("FromModuleJson");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("displayName falls back to services.json when resolver returns undefined", () => {
|
|
351
|
+
const notesWithName: ServiceEntry = { ...notes, displayName: "FromServicesJson" };
|
|
352
|
+
const doc = buildWellKnown({
|
|
353
|
+
services: [notesWithName],
|
|
354
|
+
canonicalOrigin: "https://x.example",
|
|
355
|
+
displayNameFor: () => undefined,
|
|
356
|
+
});
|
|
357
|
+
const svc = doc.services.find((s) => s.name === "parachute-notes");
|
|
358
|
+
expect(svc?.displayName).toBe("FromServicesJson");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("tagline rides through from services.json (no resolver needed)", () => {
|
|
362
|
+
const notesWithTagline: ServiceEntry = {
|
|
363
|
+
...notes,
|
|
364
|
+
tagline: "Notes PWA backed by your vault.",
|
|
365
|
+
};
|
|
366
|
+
const doc = buildWellKnown({
|
|
367
|
+
services: [notesWithTagline],
|
|
368
|
+
canonicalOrigin: "https://x.example",
|
|
369
|
+
});
|
|
370
|
+
const svc = doc.services.find((s) => s.name === "parachute-notes");
|
|
371
|
+
expect(svc?.tagline).toBe("Notes PWA backed by your vault.");
|
|
372
|
+
});
|
|
373
|
+
|
|
305
374
|
test("falls back to / for empty paths", () => {
|
|
306
375
|
const entry: ServiceEntry = { ...vault, paths: [] };
|
|
307
376
|
const doc = buildWellKnown({
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin endpoints for OAuth client lookup + approval. Standalone surface so
|
|
3
|
+
* the hub's SPA approve-client page can deep-link to "approve this client_id"
|
|
4
|
+
* without round-tripping through the `/oauth/authorize` flow (whose
|
|
5
|
+
* `POST /oauth/authorize/approve` requires a `return_to` authorize URL).
|
|
6
|
+
*
|
|
7
|
+
* GET /api/oauth/clients/<client_id> client details
|
|
8
|
+
* POST /api/oauth/clients/<client_id>/approve flip status to approved
|
|
9
|
+
*
|
|
10
|
+
* Both gated by `parachute:host:admin` Bearer (same shape as /api/grants,
|
|
11
|
+
* /api/auth/tokens, etc.). The SPA mints one via the session cookie at
|
|
12
|
+
* `/admin/host-admin-token`.
|
|
13
|
+
*
|
|
14
|
+
* Audit: approval emits a `console.log("client approved: ...")` line in the
|
|
15
|
+
* same `key=value` shape used elsewhere (`grant revoked`, `consent skipped`).
|
|
16
|
+
* `parachute auth approve-client` writes to the same `approveClient` db
|
|
17
|
+
* helper but no audit line — adding one to the CLI is a separate cleanup;
|
|
18
|
+
* the API path logs because cross-machine "who approved this" is the
|
|
19
|
+
* audit-grade signal we'd want when the operator approves from a browser
|
|
20
|
+
* rather than a terminal they own.
|
|
21
|
+
*/
|
|
22
|
+
import type { Database } from "bun:sqlite";
|
|
23
|
+
import {
|
|
24
|
+
type AdminAuthContext,
|
|
25
|
+
type AdminAuthError,
|
|
26
|
+
adminAuthErrorResponse,
|
|
27
|
+
requireScope,
|
|
28
|
+
} from "./admin-auth.ts";
|
|
29
|
+
import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
|
|
30
|
+
import { approveClient, getClient } from "./clients.ts";
|
|
31
|
+
|
|
32
|
+
export interface AdminClientsDeps {
|
|
33
|
+
db: Database;
|
|
34
|
+
/** Hub origin — passed through to JWT validation as the expected `iss`. */
|
|
35
|
+
issuer: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AdminClientView {
|
|
39
|
+
client_id: string;
|
|
40
|
+
/** May be null when the client never declared a `client_name` on /oauth/register. */
|
|
41
|
+
client_name: string | null;
|
|
42
|
+
redirect_uris: string[];
|
|
43
|
+
/** Scopes the client requested at registration. The operator approves the client, not these. */
|
|
44
|
+
scopes: string[];
|
|
45
|
+
status: "pending" | "approved";
|
|
46
|
+
registered_at: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function handleGetClient(
|
|
50
|
+
req: Request,
|
|
51
|
+
clientId: string,
|
|
52
|
+
deps: AdminClientsDeps,
|
|
53
|
+
): Promise<Response> {
|
|
54
|
+
if (req.method !== "GET") {
|
|
55
|
+
return jsonError(405, "method_not_allowed", "use GET");
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
61
|
+
}
|
|
62
|
+
const client = getClient(deps.db, clientId);
|
|
63
|
+
if (!client) {
|
|
64
|
+
return jsonError(404, "not_found", `no client registered with id ${clientId}`);
|
|
65
|
+
}
|
|
66
|
+
const view: AdminClientView = {
|
|
67
|
+
client_id: client.clientId,
|
|
68
|
+
client_name: client.clientName,
|
|
69
|
+
redirect_uris: client.redirectUris,
|
|
70
|
+
scopes: client.scopes,
|
|
71
|
+
status: client.status,
|
|
72
|
+
registered_at: client.registeredAt,
|
|
73
|
+
};
|
|
74
|
+
return new Response(JSON.stringify(view), {
|
|
75
|
+
status: 200,
|
|
76
|
+
headers: {
|
|
77
|
+
"content-type": "application/json",
|
|
78
|
+
"cache-control": "no-store",
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function handleApproveClient(
|
|
84
|
+
req: Request,
|
|
85
|
+
clientId: string,
|
|
86
|
+
deps: AdminClientsDeps,
|
|
87
|
+
): Promise<Response> {
|
|
88
|
+
if (req.method !== "POST") {
|
|
89
|
+
return jsonError(405, "method_not_allowed", "use POST");
|
|
90
|
+
}
|
|
91
|
+
let ctx: AdminAuthContext;
|
|
92
|
+
try {
|
|
93
|
+
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
96
|
+
}
|
|
97
|
+
const before = getClient(deps.db, clientId);
|
|
98
|
+
if (!before) {
|
|
99
|
+
return jsonError(404, "not_found", `no client registered with id ${clientId}`);
|
|
100
|
+
}
|
|
101
|
+
// Idempotent — approveClient is a no-op when the row is already approved.
|
|
102
|
+
// The audit line only fires on the actual state change; a no-op approve
|
|
103
|
+
// shouldn't pollute the log with "approved a thing that was already
|
|
104
|
+
// approved" noise from a UI tab re-submit.
|
|
105
|
+
const wasPending = before.status === "pending";
|
|
106
|
+
const ok = approveClient(deps.db, clientId);
|
|
107
|
+
if (!ok) {
|
|
108
|
+
// Race: the row was deleted between getClient and approveClient. Same
|
|
109
|
+
// surface as "no client" — the operator's intent (this client_id is
|
|
110
|
+
// approved or doesn't exist) is satisfied.
|
|
111
|
+
return jsonError(404, "not_found", `no client registered with id ${clientId}`);
|
|
112
|
+
}
|
|
113
|
+
if (wasPending) {
|
|
114
|
+
console.log(
|
|
115
|
+
`client approved: client_id=${clientId} client_name=${before.clientName ?? ""} approver_sub=${ctx.sub}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return new Response(
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
client_id: clientId,
|
|
121
|
+
status: "approved",
|
|
122
|
+
already_approved: !wasPending,
|
|
123
|
+
}),
|
|
124
|
+
{
|
|
125
|
+
status: 200,
|
|
126
|
+
headers: {
|
|
127
|
+
"content-type": "application/json",
|
|
128
|
+
"cache-control": "no-store",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
135
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
136
|
+
status,
|
|
137
|
+
headers: { "content-type": "application/json" },
|
|
138
|
+
});
|
|
139
|
+
}
|