@openparachute/hub 0.6.5-rc.1 → 0.6.5-rc.3
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__/api-modules-ops.test.ts +36 -0
- package/src/__tests__/oauth-handlers.test.ts +457 -0
- package/src/__tests__/oauth-ui.test.ts +27 -0
- package/src/__tests__/orphan-attribution.test.ts +98 -0
- package/src/__tests__/supervisor.test.ts +363 -0
- package/src/commands/migrate-cutover.ts +11 -49
- package/src/oauth-handlers.ts +69 -25
- package/src/oauth-ui.ts +28 -2
- package/src/orphan-attribution.ts +102 -0
- package/src/supervisor.ts +281 -10
package/package.json
CHANGED
|
@@ -936,6 +936,42 @@ describe("POST /api/modules/:short/start", () => {
|
|
|
936
936
|
expect(spawns[0]?.env?.MY_CUSTOM_VAR).toBe("sentinel123");
|
|
937
937
|
});
|
|
938
938
|
|
|
939
|
+
test("#519 surface orphan: start surfaces the structured port_squatter error (not a bare failure)", async () => {
|
|
940
|
+
// The #519 field signature: after a hub restart, a module (surface on the
|
|
941
|
+
// box; vault here) is orphaned — listening on its port but NOT a supervised
|
|
942
|
+
// child. The restart-surface API path (`parachute restart <svc>` → 404
|
|
943
|
+
// fallthrough → start, and the boot reconcile) calls `supervisor.start()`,
|
|
944
|
+
// whose #581 squatter detection must surface the structured `port_squatter`
|
|
945
|
+
// error in the response body so the operator gets an actionable next step,
|
|
946
|
+
// not an opaque "request failed". This pins that propagation.
|
|
947
|
+
seedVault(1940);
|
|
948
|
+
// A real Supervisor with the squatter seams injected: pid 95870 (the #519
|
|
949
|
+
// orphan) holds :1940 and is NOT one of the supervisor's children.
|
|
950
|
+
const supervisor = new Supervisor({
|
|
951
|
+
spawnFn: () => {
|
|
952
|
+
throw new Error("should not spawn — the port is squatted");
|
|
953
|
+
},
|
|
954
|
+
pidOnPort: (port) => (port === 1940 ? 95870 : undefined),
|
|
955
|
+
ownerOfPid: (pid) => (pid === 95870 ? "bun /x/.parachute/surface/server.ts" : undefined),
|
|
956
|
+
});
|
|
957
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
958
|
+
const res = await handleStart(
|
|
959
|
+
postReq("/api/modules/vault/start", { authorization: `Bearer ${bearer}` }),
|
|
960
|
+
"vault",
|
|
961
|
+
{ db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
|
|
962
|
+
);
|
|
963
|
+
// 200 with the structured error riding in state.startError — the SPA/CLI
|
|
964
|
+
// render the actionable squatter message instead of a 500 "request failed".
|
|
965
|
+
expect(res.status).toBe(200);
|
|
966
|
+
const body = (await res.json()) as {
|
|
967
|
+
short: string;
|
|
968
|
+
state: { status: string; startError?: { error_type: string; error_description: string } };
|
|
969
|
+
};
|
|
970
|
+
expect(body.state.status).toBe("crashed");
|
|
971
|
+
expect(body.state.startError?.error_type).toBe("port_squatter");
|
|
972
|
+
expect(body.state.startError?.error_description).toContain("port 1940 is held by pid 95870");
|
|
973
|
+
});
|
|
974
|
+
|
|
939
975
|
test("400 not_installed when the module isn't in services.json (no silent install)", async () => {
|
|
940
976
|
// No seedVault — services.json has no vault row.
|
|
941
977
|
const { supervisor, spawns } = makeIdleSupervisor();
|
|
@@ -578,6 +578,298 @@ describe("handleAuthorizeGet", () => {
|
|
|
578
578
|
});
|
|
579
579
|
});
|
|
580
580
|
|
|
581
|
+
// hub#570 — open-redirect: protocol errors (unsupported_response_type,
|
|
582
|
+
// invalid_request for non-S256 PKCE) MUST NOT redirect to the supplied
|
|
583
|
+
// redirect_uri until the (client_id, redirect_uri) pair is confirmed
|
|
584
|
+
// registered. RFC 6749 §4.1.2.1: an unvalidated redirect_uri error is shown
|
|
585
|
+
// to the user, never redirected. Pre-fix, an attacker with a valid client_id
|
|
586
|
+
// + crafted redirect_uri + bad response_type got an error redirect to the
|
|
587
|
+
// attacker-controlled URI.
|
|
588
|
+
describe("handleAuthorizeGet — error redirects gated on redirect_uri validation (hub#570)", () => {
|
|
589
|
+
test("invalid response_type + UNREGISTERED redirect_uri → HTML error, no redirect", async () => {
|
|
590
|
+
const { db, cleanup } = await makeDb();
|
|
591
|
+
try {
|
|
592
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
593
|
+
const { challenge } = makePkce();
|
|
594
|
+
const req = new Request(
|
|
595
|
+
authorizeUrl({
|
|
596
|
+
client_id: reg.client.clientId,
|
|
597
|
+
// Crafted attacker-controlled URI, NOT registered for this client.
|
|
598
|
+
redirect_uri: "https://evil.example/steal",
|
|
599
|
+
response_type: "token",
|
|
600
|
+
code_challenge: challenge,
|
|
601
|
+
code_challenge_method: "S256",
|
|
602
|
+
state: "abc",
|
|
603
|
+
}),
|
|
604
|
+
);
|
|
605
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
606
|
+
// Must be the HTML "Redirect mismatch" error page, NOT a 302 to evil.
|
|
607
|
+
expect(res.status).toBe(400);
|
|
608
|
+
expect(res.headers.get("location")).toBeNull();
|
|
609
|
+
const body = await res.text();
|
|
610
|
+
expect(body).toContain("Redirect mismatch");
|
|
611
|
+
} finally {
|
|
612
|
+
cleanup();
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("invalid code_challenge_method + UNREGISTERED redirect_uri → HTML error, no redirect", async () => {
|
|
617
|
+
const { db, cleanup } = await makeDb();
|
|
618
|
+
try {
|
|
619
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
620
|
+
const req = new Request(
|
|
621
|
+
authorizeUrl({
|
|
622
|
+
client_id: reg.client.clientId,
|
|
623
|
+
redirect_uri: "https://evil.example/steal",
|
|
624
|
+
response_type: "code",
|
|
625
|
+
code_challenge: "challenge",
|
|
626
|
+
code_challenge_method: "plain",
|
|
627
|
+
state: "abc",
|
|
628
|
+
}),
|
|
629
|
+
);
|
|
630
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
631
|
+
expect(res.status).toBe(400);
|
|
632
|
+
expect(res.headers.get("location")).toBeNull();
|
|
633
|
+
const body = await res.text();
|
|
634
|
+
expect(body).toContain("Redirect mismatch");
|
|
635
|
+
} finally {
|
|
636
|
+
cleanup();
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test("invalid response_type + VALID registered redirect_uri → error redirect (spec-correct)", async () => {
|
|
641
|
+
const { db, cleanup } = await makeDb();
|
|
642
|
+
try {
|
|
643
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
644
|
+
const { challenge } = makePkce();
|
|
645
|
+
const req = new Request(
|
|
646
|
+
authorizeUrl({
|
|
647
|
+
client_id: reg.client.clientId,
|
|
648
|
+
redirect_uri: "https://app.example/cb",
|
|
649
|
+
response_type: "token",
|
|
650
|
+
code_challenge: challenge,
|
|
651
|
+
code_challenge_method: "S256",
|
|
652
|
+
state: "abc",
|
|
653
|
+
}),
|
|
654
|
+
);
|
|
655
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
656
|
+
// Pair is registered → redirecting the protocol error is RFC-correct.
|
|
657
|
+
expect(res.status).toBe(302);
|
|
658
|
+
const loc = new URL(res.headers.get("location") ?? "");
|
|
659
|
+
expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
|
|
660
|
+
expect(loc.searchParams.get("error")).toBe("unsupported_response_type");
|
|
661
|
+
expect(loc.searchParams.get("state")).toBe("abc");
|
|
662
|
+
} finally {
|
|
663
|
+
cleanup();
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("invalid code_challenge_method + VALID registered redirect_uri → error redirect (spec-correct)", async () => {
|
|
668
|
+
const { db, cleanup } = await makeDb();
|
|
669
|
+
try {
|
|
670
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
671
|
+
const req = new Request(
|
|
672
|
+
authorizeUrl({
|
|
673
|
+
client_id: reg.client.clientId,
|
|
674
|
+
redirect_uri: "https://app.example/cb",
|
|
675
|
+
response_type: "code",
|
|
676
|
+
code_challenge: "challenge",
|
|
677
|
+
code_challenge_method: "plain",
|
|
678
|
+
state: "abc",
|
|
679
|
+
}),
|
|
680
|
+
);
|
|
681
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
682
|
+
expect(res.status).toBe(302);
|
|
683
|
+
const loc = new URL(res.headers.get("location") ?? "");
|
|
684
|
+
expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
|
|
685
|
+
expect(loc.searchParams.get("error")).toBe("invalid_request");
|
|
686
|
+
expect(loc.searchParams.get("state")).toBe("abc");
|
|
687
|
+
} finally {
|
|
688
|
+
cleanup();
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("unknown client_id + invalid response_type → HTML error, no redirect", async () => {
|
|
693
|
+
const { db, cleanup } = await makeDb();
|
|
694
|
+
try {
|
|
695
|
+
const { challenge } = makePkce();
|
|
696
|
+
const req = new Request(
|
|
697
|
+
authorizeUrl({
|
|
698
|
+
client_id: "no-such-client",
|
|
699
|
+
redirect_uri: "https://evil.example/steal",
|
|
700
|
+
response_type: "token",
|
|
701
|
+
code_challenge: challenge,
|
|
702
|
+
code_challenge_method: "S256",
|
|
703
|
+
}),
|
|
704
|
+
);
|
|
705
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
706
|
+
expect(res.status).toBe(400);
|
|
707
|
+
expect(res.headers.get("location")).toBeNull();
|
|
708
|
+
const body = await res.text();
|
|
709
|
+
expect(body).toContain("Unknown application");
|
|
710
|
+
} finally {
|
|
711
|
+
cleanup();
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test("valid full flow still reaches consent (regression guard)", async () => {
|
|
716
|
+
const { db, cleanup } = await makeDb();
|
|
717
|
+
try {
|
|
718
|
+
const user = await createUser(db, "owner", "pw");
|
|
719
|
+
const session = createSession(db, { userId: user.id });
|
|
720
|
+
const reg = registerClient(db, {
|
|
721
|
+
redirectUris: ["https://app.example/cb"],
|
|
722
|
+
clientName: "MyApp",
|
|
723
|
+
});
|
|
724
|
+
const { challenge } = makePkce();
|
|
725
|
+
const req = new Request(
|
|
726
|
+
authorizeUrl({
|
|
727
|
+
client_id: reg.client.clientId,
|
|
728
|
+
redirect_uri: "https://app.example/cb",
|
|
729
|
+
response_type: "code",
|
|
730
|
+
code_challenge: challenge,
|
|
731
|
+
code_challenge_method: "S256",
|
|
732
|
+
scope: "vault:read",
|
|
733
|
+
}),
|
|
734
|
+
{
|
|
735
|
+
headers: {
|
|
736
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
);
|
|
740
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
741
|
+
expect(res.status).toBe(200);
|
|
742
|
+
const html = await res.text();
|
|
743
|
+
expect(html).toContain("Approve");
|
|
744
|
+
expect(html).toContain('name="__action" value="consent"');
|
|
745
|
+
} finally {
|
|
746
|
+
cleanup();
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("pending client + valid session + bad response_type → NOT promoted to approved, request rejected (#570 reviewer fold)", async () => {
|
|
751
|
+
// The pending-client auto-approve (`approveClient`) is a DB state
|
|
752
|
+
// mutation. It must not fire for a request we're about to reject — so
|
|
753
|
+
// redirect_uri validation (and, transitively, the protocol-error checks)
|
|
754
|
+
// gate it. A malformed `response_type` on a registered-but-pending
|
|
755
|
+
// client must leave the client `pending`, not silently promote it.
|
|
756
|
+
const { db, cleanup } = await makeDb();
|
|
757
|
+
try {
|
|
758
|
+
const user = await createUser(db, "owner", "pw");
|
|
759
|
+
const session = createSession(db, { userId: user.id });
|
|
760
|
+
const reg = registerClient(db, {
|
|
761
|
+
redirectUris: ["https://app.example/cb"],
|
|
762
|
+
clientName: "MyApp",
|
|
763
|
+
status: "pending",
|
|
764
|
+
});
|
|
765
|
+
// Sanity: the client starts pending.
|
|
766
|
+
expect(getClient(db, reg.client.clientId)?.status).toBe("pending");
|
|
767
|
+
const { challenge } = makePkce();
|
|
768
|
+
const req = new Request(
|
|
769
|
+
authorizeUrl({
|
|
770
|
+
client_id: reg.client.clientId,
|
|
771
|
+
// Valid REGISTERED redirect_uri — so the rejection is driven by the
|
|
772
|
+
// malformed response_type, not the redirect mismatch. This isolates
|
|
773
|
+
// the "mutation before full validation" class the fold closes.
|
|
774
|
+
redirect_uri: "https://app.example/cb",
|
|
775
|
+
response_type: "token",
|
|
776
|
+
code_challenge: challenge,
|
|
777
|
+
code_challenge_method: "S256",
|
|
778
|
+
state: "pend-1",
|
|
779
|
+
}),
|
|
780
|
+
{
|
|
781
|
+
headers: {
|
|
782
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
783
|
+
},
|
|
784
|
+
},
|
|
785
|
+
);
|
|
786
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
787
|
+
// Registered redirect_uri → the protocol error redirects (spec-correct).
|
|
788
|
+
expect(res.status).toBe(302);
|
|
789
|
+
const loc = new URL(res.headers.get("location") ?? "");
|
|
790
|
+
expect(loc.searchParams.get("error")).toBe("unsupported_response_type");
|
|
791
|
+
// The crux: the client must STILL be pending — no silent promotion.
|
|
792
|
+
expect(getClient(db, reg.client.clientId)?.status).toBe("pending");
|
|
793
|
+
} finally {
|
|
794
|
+
cleanup();
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("pending client + valid session + UNREGISTERED redirect_uri → HTML error, not promoted (#570 reviewer fold)", async () => {
|
|
799
|
+
const { db, cleanup } = await makeDb();
|
|
800
|
+
try {
|
|
801
|
+
const user = await createUser(db, "owner", "pw");
|
|
802
|
+
const session = createSession(db, { userId: user.id });
|
|
803
|
+
const reg = registerClient(db, {
|
|
804
|
+
redirectUris: ["https://app.example/cb"],
|
|
805
|
+
clientName: "MyApp",
|
|
806
|
+
status: "pending",
|
|
807
|
+
});
|
|
808
|
+
const { challenge } = makePkce();
|
|
809
|
+
const req = new Request(
|
|
810
|
+
authorizeUrl({
|
|
811
|
+
client_id: reg.client.clientId,
|
|
812
|
+
redirect_uri: "https://evil.example/steal",
|
|
813
|
+
response_type: "code",
|
|
814
|
+
code_challenge: challenge,
|
|
815
|
+
code_challenge_method: "S256",
|
|
816
|
+
}),
|
|
817
|
+
{
|
|
818
|
+
headers: {
|
|
819
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
820
|
+
},
|
|
821
|
+
},
|
|
822
|
+
);
|
|
823
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
824
|
+
expect(res.status).toBe(400);
|
|
825
|
+
expect(res.headers.get("location")).toBeNull();
|
|
826
|
+
expect(await res.text()).toContain("Redirect mismatch");
|
|
827
|
+
// Still pending — unregistered redirect_uri never promotes the client.
|
|
828
|
+
expect(getClient(db, reg.client.clientId)?.status).toBe("pending");
|
|
829
|
+
} finally {
|
|
830
|
+
cleanup();
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test("pending client + valid session + valid request → auto-approved → consent (happy path preserved)", async () => {
|
|
835
|
+
const { db, cleanup } = await makeDb();
|
|
836
|
+
try {
|
|
837
|
+
const user = await createUser(db, "owner", "pw");
|
|
838
|
+
const session = createSession(db, { userId: user.id });
|
|
839
|
+
const reg = registerClient(db, {
|
|
840
|
+
redirectUris: ["https://app.example/cb"],
|
|
841
|
+
clientName: "MyApp",
|
|
842
|
+
status: "pending",
|
|
843
|
+
});
|
|
844
|
+
const { challenge } = makePkce();
|
|
845
|
+
const req = new Request(
|
|
846
|
+
authorizeUrl({
|
|
847
|
+
client_id: reg.client.clientId,
|
|
848
|
+
redirect_uri: "https://app.example/cb",
|
|
849
|
+
response_type: "code",
|
|
850
|
+
code_challenge: challenge,
|
|
851
|
+
code_challenge_method: "S256",
|
|
852
|
+
scope: "vault:read",
|
|
853
|
+
}),
|
|
854
|
+
{
|
|
855
|
+
headers: {
|
|
856
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
857
|
+
},
|
|
858
|
+
},
|
|
859
|
+
);
|
|
860
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
861
|
+
// Valid client + valid uri + pending → auto-approve → consent render.
|
|
862
|
+
expect(res.status).toBe(200);
|
|
863
|
+
const html = await res.text();
|
|
864
|
+
expect(html).toContain('name="__action" value="consent"');
|
|
865
|
+
// The valid request DID promote the client (auto-approve still works).
|
|
866
|
+
expect(getClient(db, reg.client.clientId)?.status).toBe("approved");
|
|
867
|
+
} finally {
|
|
868
|
+
cleanup();
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
|
|
581
873
|
// Q1 of 2026-04-28-vault-config-and-scopes.md: an unnamed `vault:<verb>` is
|
|
582
874
|
// ambiguous, so the consent screen forces the operator to pick a vault before
|
|
583
875
|
// the JWT is minted. Picked vault rewrites the scope to `vault:<picked>:<verb>`
|
|
@@ -7957,6 +8249,171 @@ describe("zero-vault non-admin privesc gate (hub#429 reviewer)", () => {
|
|
|
7957
8249
|
});
|
|
7958
8250
|
});
|
|
7959
8251
|
|
|
8252
|
+
// hub#431 — consent UX: a non-admin with zero assigned vaults requesting a
|
|
8253
|
+
// NAMED vault scope (`vault:<name>:read`) renders an enabled Approve button
|
|
8254
|
+
// pre-fix, even though the POST is correctly 400'd. (Unnamed `vault:read` was
|
|
8255
|
+
// already covered by the empty-picker disable; the gap was named scopes,
|
|
8256
|
+
// which carry no picker.) The fix disables Approve + shows explanatory copy.
|
|
8257
|
+
describe("handleAuthorizeGet — zero-vault non-admin named vault scope disables Approve (hub#431)", () => {
|
|
8258
|
+
test("non-admin / zero vaults / named vault scope → Approve disabled + copy", async () => {
|
|
8259
|
+
const { db, cleanup } = await makeDb();
|
|
8260
|
+
try {
|
|
8261
|
+
await createUser(db, "admin-aaron", "pw");
|
|
8262
|
+
const bob = await createUser(db, "bob", "pw", { allowMulti: true });
|
|
8263
|
+
const session = createSession(db, { userId: bob.id });
|
|
8264
|
+
const reg = registerClient(db, {
|
|
8265
|
+
redirectUris: ["https://app.example/cb"],
|
|
8266
|
+
status: "approved",
|
|
8267
|
+
});
|
|
8268
|
+
const { challenge } = makePkce();
|
|
8269
|
+
const req = new Request(
|
|
8270
|
+
authorizeUrl({
|
|
8271
|
+
client_id: reg.client.clientId,
|
|
8272
|
+
redirect_uri: "https://app.example/cb",
|
|
8273
|
+
response_type: "code",
|
|
8274
|
+
// NAMED vault scope — no picker, so pre-fix Approve stayed enabled.
|
|
8275
|
+
scope: "vault:default:read",
|
|
8276
|
+
code_challenge: challenge,
|
|
8277
|
+
code_challenge_method: "S256",
|
|
8278
|
+
}),
|
|
8279
|
+
{
|
|
8280
|
+
headers: {
|
|
8281
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
8282
|
+
},
|
|
8283
|
+
},
|
|
8284
|
+
);
|
|
8285
|
+
const res = handleAuthorizeGet(db, req, {
|
|
8286
|
+
issuer: ISSUER,
|
|
8287
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
8288
|
+
});
|
|
8289
|
+
expect(res.status).toBe(200);
|
|
8290
|
+
const html = await res.text();
|
|
8291
|
+
expect(html).toContain("You have no assigned vaults");
|
|
8292
|
+
expect(html).toMatch(/<button[^>]*name="approve"[^>]*value="yes"[^>]*disabled/);
|
|
8293
|
+
} finally {
|
|
8294
|
+
cleanup();
|
|
8295
|
+
}
|
|
8296
|
+
});
|
|
8297
|
+
|
|
8298
|
+
test("admin / named vault scope → Approve enabled", async () => {
|
|
8299
|
+
const { db, cleanup } = await makeDb();
|
|
8300
|
+
try {
|
|
8301
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
8302
|
+
const session = createSession(db, { userId: admin.id });
|
|
8303
|
+
const reg = registerClient(db, {
|
|
8304
|
+
redirectUris: ["https://app.example/cb"],
|
|
8305
|
+
status: "approved",
|
|
8306
|
+
});
|
|
8307
|
+
const { challenge } = makePkce();
|
|
8308
|
+
const req = new Request(
|
|
8309
|
+
authorizeUrl({
|
|
8310
|
+
client_id: reg.client.clientId,
|
|
8311
|
+
redirect_uri: "https://app.example/cb",
|
|
8312
|
+
response_type: "code",
|
|
8313
|
+
scope: "vault:default:read",
|
|
8314
|
+
code_challenge: challenge,
|
|
8315
|
+
code_challenge_method: "S256",
|
|
8316
|
+
}),
|
|
8317
|
+
{
|
|
8318
|
+
headers: {
|
|
8319
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
8320
|
+
},
|
|
8321
|
+
},
|
|
8322
|
+
);
|
|
8323
|
+
const res = handleAuthorizeGet(db, req, {
|
|
8324
|
+
issuer: ISSUER,
|
|
8325
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
8326
|
+
});
|
|
8327
|
+
expect(res.status).toBe(200);
|
|
8328
|
+
const html = await res.text();
|
|
8329
|
+
expect(html).not.toContain("You have no assigned vaults");
|
|
8330
|
+
expect(html).not.toMatch(/<button[^>]*name="approve"[^>]*value="yes"[^>]*disabled/);
|
|
8331
|
+
} finally {
|
|
8332
|
+
cleanup();
|
|
8333
|
+
}
|
|
8334
|
+
});
|
|
8335
|
+
|
|
8336
|
+
test("non-admin WITH an assigned vault / named vault scope → Approve enabled", async () => {
|
|
8337
|
+
const { db, cleanup } = await makeDb();
|
|
8338
|
+
try {
|
|
8339
|
+
await createUser(db, "admin-aaron", "pw");
|
|
8340
|
+
const bob = await createUser(db, "bob", "pw", { allowMulti: true });
|
|
8341
|
+
setUserVaults(db, bob.id, ["default"]);
|
|
8342
|
+
const session = createSession(db, { userId: bob.id });
|
|
8343
|
+
const reg = registerClient(db, {
|
|
8344
|
+
redirectUris: ["https://app.example/cb"],
|
|
8345
|
+
status: "approved",
|
|
8346
|
+
});
|
|
8347
|
+
const { challenge } = makePkce();
|
|
8348
|
+
const req = new Request(
|
|
8349
|
+
authorizeUrl({
|
|
8350
|
+
client_id: reg.client.clientId,
|
|
8351
|
+
redirect_uri: "https://app.example/cb",
|
|
8352
|
+
response_type: "code",
|
|
8353
|
+
scope: "vault:default:read",
|
|
8354
|
+
code_challenge: challenge,
|
|
8355
|
+
code_challenge_method: "S256",
|
|
8356
|
+
}),
|
|
8357
|
+
{
|
|
8358
|
+
headers: {
|
|
8359
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
8360
|
+
},
|
|
8361
|
+
},
|
|
8362
|
+
);
|
|
8363
|
+
const res = handleAuthorizeGet(db, req, {
|
|
8364
|
+
issuer: ISSUER,
|
|
8365
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
8366
|
+
});
|
|
8367
|
+
expect(res.status).toBe(200);
|
|
8368
|
+
const html = await res.text();
|
|
8369
|
+
expect(html).not.toContain("You have no assigned vaults");
|
|
8370
|
+
expect(html).not.toMatch(/<button[^>]*name="approve"[^>]*value="yes"[^>]*disabled/);
|
|
8371
|
+
} finally {
|
|
8372
|
+
cleanup();
|
|
8373
|
+
}
|
|
8374
|
+
});
|
|
8375
|
+
|
|
8376
|
+
test("non-admin / zero vaults / NON-vault scope → Approve enabled", async () => {
|
|
8377
|
+
const { db, cleanup } = await makeDb();
|
|
8378
|
+
try {
|
|
8379
|
+
await createUser(db, "admin-aaron", "pw");
|
|
8380
|
+
const bob = await createUser(db, "bob", "pw", { allowMulti: true });
|
|
8381
|
+
const session = createSession(db, { userId: bob.id });
|
|
8382
|
+
const reg = registerClient(db, {
|
|
8383
|
+
redirectUris: ["https://app.example/cb"],
|
|
8384
|
+
status: "approved",
|
|
8385
|
+
});
|
|
8386
|
+
const { challenge } = makePkce();
|
|
8387
|
+
const req = new Request(
|
|
8388
|
+
authorizeUrl({
|
|
8389
|
+
client_id: reg.client.clientId,
|
|
8390
|
+
redirect_uri: "https://app.example/cb",
|
|
8391
|
+
response_type: "code",
|
|
8392
|
+
// Non-vault scope — the user can still consent without a vault.
|
|
8393
|
+
scope: "scribe:transcribe",
|
|
8394
|
+
code_challenge: challenge,
|
|
8395
|
+
code_challenge_method: "S256",
|
|
8396
|
+
}),
|
|
8397
|
+
{
|
|
8398
|
+
headers: {
|
|
8399
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
8400
|
+
},
|
|
8401
|
+
},
|
|
8402
|
+
);
|
|
8403
|
+
const res = handleAuthorizeGet(db, req, {
|
|
8404
|
+
issuer: ISSUER,
|
|
8405
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
8406
|
+
});
|
|
8407
|
+
expect(res.status).toBe(200);
|
|
8408
|
+
const html = await res.text();
|
|
8409
|
+
expect(html).not.toContain("You have no assigned vaults");
|
|
8410
|
+
expect(html).not.toMatch(/<button[^>]*name="approve"[^>]*value="yes"[^>]*disabled/);
|
|
8411
|
+
} finally {
|
|
8412
|
+
cleanup();
|
|
8413
|
+
}
|
|
8414
|
+
});
|
|
8415
|
+
});
|
|
8416
|
+
|
|
7960
8417
|
// RFC 8707 resource binding (fix #461). A friend connecting an MCP client to
|
|
7961
8418
|
// ONE vault (`<origin>/vault/<name>/mcp`) must see ONLY that vault's scopes on
|
|
7962
8419
|
// consent, and the minted token must carry the narrow, NAMED scope +
|
|
@@ -225,6 +225,33 @@ describe("renderConsent", () => {
|
|
|
225
225
|
expect(html).toContain("no vaults exist");
|
|
226
226
|
expect(html).toContain('value="yes" class="btn btn-primary" disabled');
|
|
227
227
|
});
|
|
228
|
+
|
|
229
|
+
test("disables Approve + shows copy when user can't authorize (hub#431)", () => {
|
|
230
|
+
const html = renderConsent({
|
|
231
|
+
params: { ...PARAMS, scope: "vault:work:read" },
|
|
232
|
+
csrfToken: CSRF,
|
|
233
|
+
clientId: "c",
|
|
234
|
+
clientName: "App",
|
|
235
|
+
scopes: ["vault:work:read"],
|
|
236
|
+
userCanAuthorizeRequest: false,
|
|
237
|
+
});
|
|
238
|
+
expect(html).toContain("You have no assigned vaults");
|
|
239
|
+
expect(html).toContain("ask the hub admin".replace("ask", "Ask"));
|
|
240
|
+
expect(html).toContain('value="yes" class="btn btn-primary" disabled');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("leaves Approve enabled when userCanAuthorizeRequest is true (hub#431)", () => {
|
|
244
|
+
const html = renderConsent({
|
|
245
|
+
params: { ...PARAMS, scope: "vault:work:read" },
|
|
246
|
+
csrfToken: CSRF,
|
|
247
|
+
clientId: "c",
|
|
248
|
+
clientName: "App",
|
|
249
|
+
scopes: ["vault:work:read"],
|
|
250
|
+
userCanAuthorizeRequest: true,
|
|
251
|
+
});
|
|
252
|
+
expect(html).not.toContain("You have no assigned vaults");
|
|
253
|
+
expect(html).not.toContain('value="yes" class="btn btn-primary" disabled');
|
|
254
|
+
});
|
|
228
255
|
});
|
|
229
256
|
|
|
230
257
|
describe("renderError", () => {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { orphanAttributable } from "../orphan-attribution.ts";
|
|
3
|
+
|
|
4
|
+
describe("orphanAttributable — two attribution modes (#601 review)", () => {
|
|
5
|
+
const ownerOfPid = (cmdlines: Record<number, string | undefined>) => (pid: number) =>
|
|
6
|
+
cmdlines[pid];
|
|
7
|
+
|
|
8
|
+
test("recorded-pid match → attributable in BOTH modes (cmdline not even read)", () => {
|
|
9
|
+
// No cmdline available, but the orphan IS the recorded pid → trivially ours.
|
|
10
|
+
const probe = ownerOfPid({});
|
|
11
|
+
const broad = orphanAttributable({
|
|
12
|
+
orphan: 100,
|
|
13
|
+
recordedPid: 100,
|
|
14
|
+
short: "vault",
|
|
15
|
+
startCmdHint: undefined,
|
|
16
|
+
ownerOfPid: probe,
|
|
17
|
+
});
|
|
18
|
+
const perModule = orphanAttributable({
|
|
19
|
+
orphan: 100,
|
|
20
|
+
recordedPid: 100,
|
|
21
|
+
short: "vault",
|
|
22
|
+
startCmdHint: undefined,
|
|
23
|
+
ownerOfPid: probe,
|
|
24
|
+
moduleMarker: "parachute-vault",
|
|
25
|
+
});
|
|
26
|
+
expect(broad.attributable).toBe(true);
|
|
27
|
+
expect(perModule.attributable).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("broad mode (no moduleMarker): any `parachute` cmdline is attributable", () => {
|
|
31
|
+
const res = orphanAttributable({
|
|
32
|
+
orphan: 200,
|
|
33
|
+
recordedPid: undefined,
|
|
34
|
+
short: "vault",
|
|
35
|
+
startCmdHint: undefined,
|
|
36
|
+
ownerOfPid: ownerOfPid({ 200: "parachute-scribe serve" }),
|
|
37
|
+
});
|
|
38
|
+
// Migrate-sweep width: a sibling parachute process still counts.
|
|
39
|
+
expect(res.attributable).toBe(true);
|
|
40
|
+
expect(res.cmdline).toBe("parachute-scribe serve");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("per-module mode: own marker matches → attributable", () => {
|
|
44
|
+
const res = orphanAttributable({
|
|
45
|
+
orphan: 300,
|
|
46
|
+
recordedPid: undefined,
|
|
47
|
+
short: "vault",
|
|
48
|
+
startCmdHint: undefined,
|
|
49
|
+
ownerOfPid: ownerOfPid({ 300: "parachute-vault serve" }),
|
|
50
|
+
moduleMarker: "parachute-vault",
|
|
51
|
+
});
|
|
52
|
+
expect(res.attributable).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("per-module mode: a SIBLING parachute module is NOT attributable (cross-module-kill guard)", () => {
|
|
56
|
+
const res = orphanAttributable({
|
|
57
|
+
orphan: 400,
|
|
58
|
+
recordedPid: undefined,
|
|
59
|
+
short: "vault",
|
|
60
|
+
startCmdHint: undefined,
|
|
61
|
+
// A real parachute process (carries `parachute`) — but it's SCRIBE, not
|
|
62
|
+
// vault. The broad mode would attribute it; per-module must not.
|
|
63
|
+
ownerOfPid: ownerOfPid({ 400: "parachute-scribe serve" }),
|
|
64
|
+
moduleMarker: "parachute-vault",
|
|
65
|
+
});
|
|
66
|
+
expect(res.attributable).toBe(false);
|
|
67
|
+
// The cmdline is still returned so the caller can surface it in the message.
|
|
68
|
+
expect(res.cmdline).toBe("parachute-scribe serve");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("either mode: unreadable cmdline + non-matching pid → NOT attributable", () => {
|
|
72
|
+
for (const moduleMarker of [undefined, "parachute-vault"]) {
|
|
73
|
+
const res = orphanAttributable({
|
|
74
|
+
orphan: 500,
|
|
75
|
+
recordedPid: 999, // different from orphan
|
|
76
|
+
short: "vault",
|
|
77
|
+
startCmdHint: undefined,
|
|
78
|
+
ownerOfPid: ownerOfPid({}), // returns undefined
|
|
79
|
+
moduleMarker,
|
|
80
|
+
});
|
|
81
|
+
expect(res.attributable).toBe(false);
|
|
82
|
+
expect(res.cmdline).toBeUndefined();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("startCmdHint is an additional needle in per-module mode", () => {
|
|
87
|
+
const res = orphanAttributable({
|
|
88
|
+
orphan: 600,
|
|
89
|
+
recordedPid: undefined,
|
|
90
|
+
short: "vault",
|
|
91
|
+
startCmdHint: "my-custom-server.ts",
|
|
92
|
+
// cmdline lacks the module binary but carries the explicit hint.
|
|
93
|
+
ownerOfPid: ownerOfPid({ 600: "node /opt/my-custom-server.ts" }),
|
|
94
|
+
moduleMarker: "parachute-vault",
|
|
95
|
+
});
|
|
96
|
+
expect(res.attributable).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|