@openparachute/hub 0.5.7 → 0.5.10-rc.2
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 +70 -323
- 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 +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +526 -67
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +375 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/setup-gate.test.ts +196 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +408 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +32 -254
- 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/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve.ts +157 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +6 -3
- package/src/help.ts +54 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +630 -135
- 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/oauth-handlers.ts +238 -54
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +1 -1
- package/src/supervisor.ts +359 -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
|
@@ -85,12 +85,12 @@ function seedVault(manifestPath: string, installDir: string, version = "0.4.0"):
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
describe("parachute upgrade", () => {
|
|
88
|
-
test("errors cleanly when
|
|
88
|
+
test("errors cleanly when targeting a service that's not installed", async () => {
|
|
89
89
|
const h = makeHarness();
|
|
90
90
|
try {
|
|
91
91
|
const logs: string[] = [];
|
|
92
92
|
const m = makeRunner();
|
|
93
|
-
const code = await upgrade(
|
|
93
|
+
const code = await upgrade("vault", {
|
|
94
94
|
manifestPath: h.manifestPath,
|
|
95
95
|
configDir: h.configDir,
|
|
96
96
|
runner: m.runner,
|
|
@@ -105,7 +105,7 @@ describe("parachute upgrade", () => {
|
|
|
105
105
|
}
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
test("errors cleanly on unknown service", async () => {
|
|
108
|
+
test("errors cleanly on unknown service, lists hub in the known set", async () => {
|
|
109
109
|
const h = makeHarness();
|
|
110
110
|
try {
|
|
111
111
|
seedVault(h.manifestPath, join(h.installRoot, "vault"));
|
|
@@ -120,7 +120,9 @@ describe("parachute upgrade", () => {
|
|
|
120
120
|
log: (l) => logs.push(l),
|
|
121
121
|
});
|
|
122
122
|
expect(code).toBe(1);
|
|
123
|
-
|
|
123
|
+
const joined = logs.join("\n");
|
|
124
|
+
expect(joined).toMatch(/unknown service/);
|
|
125
|
+
expect(joined).toMatch(/\bhub\b/);
|
|
124
126
|
} finally {
|
|
125
127
|
h.cleanup();
|
|
126
128
|
}
|
|
@@ -472,11 +474,249 @@ describe("parachute upgrade", () => {
|
|
|
472
474
|
}
|
|
473
475
|
});
|
|
474
476
|
|
|
477
|
+
test("hub as target: npm-installed path runs bun add -g @openparachute/hub@<tag> + restart", async () => {
|
|
478
|
+
const h = makeHarness();
|
|
479
|
+
try {
|
|
480
|
+
// Hub is not in services.json — it's an internal service. The upgrade
|
|
481
|
+
// command must still accept `hub` as a target, locate its global install,
|
|
482
|
+
// run `bun add -g @openparachute/hub@latest`, and restart.
|
|
483
|
+
const hubInstallDir = join(h.installRoot, "hub");
|
|
484
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.8" });
|
|
485
|
+
|
|
486
|
+
const seenCmd: string[][] = [];
|
|
487
|
+
const runner: UpgradeRunner = {
|
|
488
|
+
async run(cmd) {
|
|
489
|
+
seenCmd.push([...cmd]);
|
|
490
|
+
if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
|
|
491
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.9" });
|
|
492
|
+
}
|
|
493
|
+
return 0;
|
|
494
|
+
},
|
|
495
|
+
async capture(cmd) {
|
|
496
|
+
// Not a git checkout — drives the npm-install branch.
|
|
497
|
+
if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
|
|
498
|
+
return { code: 128, stdout: "fatal: not a git repository\n" };
|
|
499
|
+
}
|
|
500
|
+
return { code: 0, stdout: "" };
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
let restartedShort: string | undefined;
|
|
505
|
+
const logs: string[] = [];
|
|
506
|
+
const code = await upgrade("hub", {
|
|
507
|
+
manifestPath: h.manifestPath,
|
|
508
|
+
configDir: h.configDir,
|
|
509
|
+
runner,
|
|
510
|
+
findGlobalInstall: (pkg) =>
|
|
511
|
+
pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
|
|
512
|
+
restartFn: async (svc) => {
|
|
513
|
+
restartedShort = svc;
|
|
514
|
+
return 0;
|
|
515
|
+
},
|
|
516
|
+
log: (l) => logs.push(l),
|
|
517
|
+
});
|
|
518
|
+
expect(code).toBe(0);
|
|
519
|
+
expect(restartedShort).toBe("hub");
|
|
520
|
+
const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
|
|
521
|
+
expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@latest"]);
|
|
522
|
+
const joined = logs.join("\n");
|
|
523
|
+
expect(joined).toMatch(/hub: npm-installed/);
|
|
524
|
+
expect(joined).toMatch(/0\.5\.8 → 0\.5\.9/);
|
|
525
|
+
} finally {
|
|
526
|
+
h.cleanup();
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("hub as target works even with empty services.json (closes #251)", async () => {
|
|
531
|
+
// The dispatcher must be able to self-upgrade on a brand-new install where
|
|
532
|
+
// services.json doesn't exist yet — that's the worst-case bootstrap path
|
|
533
|
+
// and was the failure mode in #251.
|
|
534
|
+
const h = makeHarness();
|
|
535
|
+
try {
|
|
536
|
+
const hubInstallDir = join(h.installRoot, "hub");
|
|
537
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.8" });
|
|
538
|
+
|
|
539
|
+
const runner: UpgradeRunner = {
|
|
540
|
+
async run(cmd) {
|
|
541
|
+
if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
|
|
542
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.9" });
|
|
543
|
+
}
|
|
544
|
+
return 0;
|
|
545
|
+
},
|
|
546
|
+
async capture(cmd) {
|
|
547
|
+
if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
|
|
548
|
+
return { code: 128, stdout: "" };
|
|
549
|
+
}
|
|
550
|
+
return { code: 0, stdout: "" };
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
let restartedShort: string | undefined;
|
|
555
|
+
const code = await upgrade("hub", {
|
|
556
|
+
manifestPath: h.manifestPath, // file doesn't exist
|
|
557
|
+
configDir: h.configDir,
|
|
558
|
+
runner,
|
|
559
|
+
findGlobalInstall: (pkg) =>
|
|
560
|
+
pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
|
|
561
|
+
restartFn: async (svc) => {
|
|
562
|
+
restartedShort = svc;
|
|
563
|
+
return 0;
|
|
564
|
+
},
|
|
565
|
+
log: () => {},
|
|
566
|
+
});
|
|
567
|
+
expect(code).toBe(0);
|
|
568
|
+
expect(restartedShort).toBe("hub");
|
|
569
|
+
} finally {
|
|
570
|
+
h.cleanup();
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("hub as target: bun-linked checkout follows the linked path", async () => {
|
|
575
|
+
const h = makeHarness();
|
|
576
|
+
try {
|
|
577
|
+
const checkoutDir = join(h.installRoot, "parachute-hub-checkout");
|
|
578
|
+
writePackageJson(checkoutDir, { name: "@openparachute/hub", version: "0.5.9-rc.7" });
|
|
579
|
+
|
|
580
|
+
let headCalls = 0;
|
|
581
|
+
const runner: UpgradeRunner = {
|
|
582
|
+
async run(cmd) {
|
|
583
|
+
if (cmd[0] === "git" && cmd[1] === "pull") return 0;
|
|
584
|
+
return 0;
|
|
585
|
+
},
|
|
586
|
+
async capture(cmd) {
|
|
587
|
+
if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
|
|
588
|
+
return { code: 0, stdout: "true" };
|
|
589
|
+
}
|
|
590
|
+
if (cmd[1] === "status") return { code: 0, stdout: "" };
|
|
591
|
+
if (cmd[1] === "rev-parse" && cmd[2] === "HEAD") {
|
|
592
|
+
headCalls++;
|
|
593
|
+
return { code: 0, stdout: headCalls === 1 ? "old" : "new" };
|
|
594
|
+
}
|
|
595
|
+
if (cmd[1] === "diff") return { code: 0, stdout: "src/foo.ts" };
|
|
596
|
+
return { code: 0, stdout: "" };
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
let restartedShort: string | undefined;
|
|
601
|
+
const logs: string[] = [];
|
|
602
|
+
const code = await upgrade("hub", {
|
|
603
|
+
manifestPath: h.manifestPath,
|
|
604
|
+
configDir: h.configDir,
|
|
605
|
+
runner,
|
|
606
|
+
findGlobalInstall: (pkg) =>
|
|
607
|
+
pkg === "@openparachute/hub" ? join(checkoutDir, "package.json") : null,
|
|
608
|
+
restartFn: async (svc) => {
|
|
609
|
+
restartedShort = svc;
|
|
610
|
+
return 0;
|
|
611
|
+
},
|
|
612
|
+
log: (l) => logs.push(l),
|
|
613
|
+
});
|
|
614
|
+
expect(code).toBe(0);
|
|
615
|
+
expect(restartedShort).toBe("hub");
|
|
616
|
+
expect(logs.join("\n")).toMatch(/hub: bun-linked checkout/);
|
|
617
|
+
} finally {
|
|
618
|
+
h.cleanup();
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("hub as target: --tag is forwarded to bun add -g for the hub package", async () => {
|
|
623
|
+
const h = makeHarness();
|
|
624
|
+
try {
|
|
625
|
+
const hubInstallDir = join(h.installRoot, "hub");
|
|
626
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.8" });
|
|
627
|
+
|
|
628
|
+
const seenCmd: string[][] = [];
|
|
629
|
+
const runner: UpgradeRunner = {
|
|
630
|
+
async run(cmd) {
|
|
631
|
+
seenCmd.push([...cmd]);
|
|
632
|
+
return 0;
|
|
633
|
+
},
|
|
634
|
+
async capture(cmd) {
|
|
635
|
+
if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
|
|
636
|
+
return { code: 128, stdout: "" };
|
|
637
|
+
}
|
|
638
|
+
return { code: 0, stdout: "" };
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
await upgrade("hub", {
|
|
643
|
+
manifestPath: h.manifestPath,
|
|
644
|
+
configDir: h.configDir,
|
|
645
|
+
runner,
|
|
646
|
+
findGlobalInstall: (pkg) =>
|
|
647
|
+
pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
|
|
648
|
+
restartFn: async () => 0,
|
|
649
|
+
tag: "rc",
|
|
650
|
+
log: () => {},
|
|
651
|
+
});
|
|
652
|
+
const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
|
|
653
|
+
expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@rc"]);
|
|
654
|
+
} finally {
|
|
655
|
+
h.cleanup();
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test("sweep includes hub: hub upgraded alongside services.json entries", async () => {
|
|
660
|
+
const h = makeHarness();
|
|
661
|
+
try {
|
|
662
|
+
const hubInstallDir = join(h.installRoot, "hub");
|
|
663
|
+
const vaultDir = join(h.installRoot, "vault");
|
|
664
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.8" });
|
|
665
|
+
writePackageJson(vaultDir, { name: "@openparachute/vault", version: "0.4.0" });
|
|
666
|
+
seedVault(h.manifestPath, vaultDir);
|
|
667
|
+
|
|
668
|
+
const runner: UpgradeRunner = {
|
|
669
|
+
async run(cmd) {
|
|
670
|
+
if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
|
|
671
|
+
const pkg = cmd[3] ?? "";
|
|
672
|
+
if (pkg.startsWith("@openparachute/hub")) {
|
|
673
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.9" });
|
|
674
|
+
}
|
|
675
|
+
if (pkg.startsWith("@openparachute/vault")) {
|
|
676
|
+
writePackageJson(vaultDir, { name: "@openparachute/vault", version: "0.5.0" });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return 0;
|
|
680
|
+
},
|
|
681
|
+
async capture(cmd) {
|
|
682
|
+
if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
|
|
683
|
+
return { code: 128, stdout: "" };
|
|
684
|
+
}
|
|
685
|
+
return { code: 0, stdout: "" };
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
const restartCalls: string[] = [];
|
|
690
|
+
const code = await upgrade(undefined, {
|
|
691
|
+
manifestPath: h.manifestPath,
|
|
692
|
+
configDir: h.configDir,
|
|
693
|
+
runner,
|
|
694
|
+
findGlobalInstall: (pkg) => {
|
|
695
|
+
if (pkg === "@openparachute/hub") return join(hubInstallDir, "package.json");
|
|
696
|
+
if (pkg === "@openparachute/vault") return join(vaultDir, "package.json");
|
|
697
|
+
return null;
|
|
698
|
+
},
|
|
699
|
+
restartFn: async (svc) => {
|
|
700
|
+
restartCalls.push(svc);
|
|
701
|
+
return 0;
|
|
702
|
+
},
|
|
703
|
+
log: () => {},
|
|
704
|
+
});
|
|
705
|
+
expect(code).toBe(0);
|
|
706
|
+
// Hub goes first so its dispatcher upgrade isn't preempted.
|
|
707
|
+
expect(restartCalls).toEqual(["hub", "vault"]);
|
|
708
|
+
} finally {
|
|
709
|
+
h.cleanup();
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
|
|
475
713
|
test("sweep (no svc): partial failure — later targets still run; first failure code wins", async () => {
|
|
476
714
|
const h = makeHarness();
|
|
477
715
|
try {
|
|
716
|
+
const hubInstallDir = join(h.installRoot, "hub");
|
|
478
717
|
const vaultDir = join(h.installRoot, "vault");
|
|
479
718
|
const notesDir = join(h.installRoot, "notes");
|
|
719
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.8" });
|
|
480
720
|
writePackageJson(vaultDir, { name: "@openparachute/vault", version: "0.4.0" });
|
|
481
721
|
writePackageJson(notesDir, { name: "@openparachute/notes", version: "0.0.1" });
|
|
482
722
|
seedVault(h.manifestPath, vaultDir);
|
|
@@ -492,6 +732,7 @@ describe("parachute upgrade", () => {
|
|
|
492
732
|
h.manifestPath,
|
|
493
733
|
);
|
|
494
734
|
|
|
735
|
+
// hub is npm-installed and succeeds (no version bump → skip restart).
|
|
495
736
|
// vault is npm-installed (no git); bun add -g fails with 7
|
|
496
737
|
// notes is npm-installed and succeeds with version bump
|
|
497
738
|
const runner: UpgradeRunner = {
|
|
@@ -521,6 +762,7 @@ describe("parachute upgrade", () => {
|
|
|
521
762
|
configDir: h.configDir,
|
|
522
763
|
runner,
|
|
523
764
|
findGlobalInstall: (pkg) => {
|
|
765
|
+
if (pkg === "@openparachute/hub") return join(hubInstallDir, "package.json");
|
|
524
766
|
if (pkg === "@openparachute/vault") return join(vaultDir, "package.json");
|
|
525
767
|
if (pkg === "@openparachute/notes") return join(notesDir, "package.json");
|
|
526
768
|
return null;
|
|
@@ -532,6 +774,7 @@ describe("parachute upgrade", () => {
|
|
|
532
774
|
log: (l) => logs.push(l),
|
|
533
775
|
});
|
|
534
776
|
expect(code).toBe(7);
|
|
777
|
+
// Hub skipped restart (version unchanged), notes restarted after version bump.
|
|
535
778
|
expect(restartCalls).toEqual(["notes"]);
|
|
536
779
|
expect(logs.join("\n")).toMatch(/vault: bun add -g failed \(exit 7\)/);
|
|
537
780
|
} finally {
|
|
@@ -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
|
+
}
|