@openparachute/vault 0.4.7-rc.2 → 0.4.8-rc.10
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/.parachute/module.json +1 -1
- package/README.md +78 -41
- package/core/src/connection-pragmas.test.ts +232 -0
- package/core/src/core.test.ts +257 -0
- package/core/src/cursor.test.ts +160 -0
- package/core/src/cursor.ts +272 -0
- package/core/src/mcp.ts +51 -7
- package/core/src/notes.ts +164 -2
- package/core/src/schema.ts +106 -5
- package/core/src/store.ts +11 -1
- package/core/src/types.ts +32 -0
- package/package.json +7 -3
- package/src/auth-status.ts +4 -0
- package/src/auth.test.ts +5 -112
- package/src/auto-transcribe.test.ts +116 -0
- package/src/auto-transcribe.ts +48 -0
- package/src/backup.ts +17 -3
- package/src/cli.ts +95 -66
- package/src/config.test.ts +26 -0
- package/src/config.ts +53 -1
- package/src/db.ts +15 -2
- package/src/export-watch.test.ts +21 -0
- package/src/mcp-install-interactive.test.ts +23 -2
- package/src/mcp-install-interactive.ts +21 -2
- package/src/mcp-install.test.ts +40 -0
- package/src/mcp-tools.ts +17 -1
- package/src/module-config.ts +70 -14
- package/src/module-manifest.test.ts +114 -0
- package/src/module-manifest.ts +104 -0
- package/src/oauth-discovery.ts +95 -0
- package/src/owner-auth.ts +22 -149
- package/src/routes.ts +268 -51
- package/src/routing.test.ts +102 -99
- package/src/routing.ts +33 -47
- package/src/scribe-discovery.test.ts +77 -0
- package/src/scribe-discovery.ts +91 -0
- package/src/scribe-env.test.ts +66 -1
- package/src/scribe-env.ts +42 -1
- package/src/self-register.test.ts +412 -0
- package/src/self-register.ts +247 -0
- package/src/server.ts +47 -23
- package/src/transcript-note.test.ts +171 -0
- package/src/transcript-note.ts +189 -0
- package/src/transcription-registry.ts +22 -0
- package/src/transcription-worker.test.ts +250 -0
- package/src/transcription-worker.ts +186 -27
- package/src/vault-name.ts +3 -2
- package/src/vault.test.ts +347 -0
- package/web/ui/dist/assets/index-BOa-JJtV.css +1 -0
- package/web/ui/dist/assets/index-BzA5LgE3.js +60 -0
- package/web/ui/dist/index.html +14 -0
- package/web/ui/tsconfig.json +21 -0
- package/src/oauth.test.ts +0 -2156
- package/src/oauth.ts +0 -973
package/src/routing.test.ts
CHANGED
|
@@ -455,19 +455,21 @@ describe("per-vault routing under /vault/<name>/", () => {
|
|
|
455
455
|
expect(res.status).toBe(401);
|
|
456
456
|
});
|
|
457
457
|
|
|
458
|
-
test("/vault/<name>/oauth
|
|
458
|
+
test("/vault/<name>/oauth/* returns 410 Gone (standalone issuer retired — workstream E)", async () => {
|
|
459
|
+
// The standalone OAuth issuer on vault was removed in vault#366 once hub
|
|
460
|
+
// became required. The 410 carries a pointer to the protected-resource
|
|
461
|
+
// metadata so a confused client can rediscover the new issuer (the hub).
|
|
459
462
|
createVault("journal");
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
new Request(`http://localhost:1940${path}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
expect([201, 400]).toContain(res.status);
|
|
463
|
+
for (const subpath of ["/oauth/register", "/oauth/authorize", "/oauth/token"]) {
|
|
464
|
+
const path = `/vault/journal${subpath}`;
|
|
465
|
+
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
466
|
+
expect(res.status).toBe(410);
|
|
467
|
+
const body = (await res.json()) as { error: string; protected_resource_metadata: string };
|
|
468
|
+
expect(body.error).toBe("oauth_endpoint_removed");
|
|
469
|
+
expect(body.protected_resource_metadata).toBe(
|
|
470
|
+
"http://localhost:1940/vault/journal/.well-known/oauth-protected-resource",
|
|
471
|
+
);
|
|
472
|
+
}
|
|
471
473
|
});
|
|
472
474
|
|
|
473
475
|
test("unknown vault returns 404 before hitting auth", async () => {
|
|
@@ -475,7 +477,6 @@ describe("per-vault routing under /vault/<name>/", () => {
|
|
|
475
477
|
for (const path of [
|
|
476
478
|
"/vault/nonexistent/mcp",
|
|
477
479
|
"/vault/nonexistent/api/notes",
|
|
478
|
-
"/vault/nonexistent/oauth/register",
|
|
479
480
|
]) {
|
|
480
481
|
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
481
482
|
expect(res.status).toBe(404);
|
|
@@ -591,15 +592,22 @@ describe("MCP 401 WWW-Authenticate challenge (RFC 9728)", () => {
|
|
|
591
592
|
// ---------------------------------------------------------------------------
|
|
592
593
|
// Per-vault OAuth discovery (RFC 8414 / RFC 9728, path-append form).
|
|
593
594
|
//
|
|
594
|
-
//
|
|
595
|
+
// After workstream E (2026-05-25), vault is resource-server-only — hub is
|
|
596
|
+
// the OAuth issuer. The discovery endpoints stay served at
|
|
595
597
|
// /vault/<name>/.well-known/oauth-protected-resource
|
|
596
598
|
// /vault/<name>/.well-known/oauth-authorization-server
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
+
// but the metadata they return forwards every authorization-server endpoint
|
|
600
|
+
// to the hub origin. The `resource` URL still names the vault's MCP path
|
|
601
|
+
// (that's the resource being protected); the AS pointer is the hub.
|
|
602
|
+
//
|
|
603
|
+
// `PARACHUTE_HUB_ORIGIN` defaults to `http://127.0.0.1:1939` when unset
|
|
604
|
+
// (canonical hub loopback). Tests below assert against that default.
|
|
599
605
|
// ---------------------------------------------------------------------------
|
|
600
606
|
|
|
601
|
-
|
|
602
|
-
|
|
607
|
+
const HUB_ORIGIN = "http://127.0.0.1:1939";
|
|
608
|
+
|
|
609
|
+
describe("per-vault OAuth discovery (hub-rooted after workstream E)", () => {
|
|
610
|
+
test("AS metadata names the hub as issuer + endpoints", async () => {
|
|
603
611
|
createVault("journal");
|
|
604
612
|
const path = "/vault/journal/.well-known/oauth-authorization-server";
|
|
605
613
|
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
@@ -609,21 +617,23 @@ describe("per-vault OAuth discovery", () => {
|
|
|
609
617
|
authorization_endpoint: string;
|
|
610
618
|
token_endpoint: string;
|
|
611
619
|
registration_endpoint: string;
|
|
620
|
+
jwks_uri: string;
|
|
612
621
|
};
|
|
613
|
-
expect(body.issuer).toBe(
|
|
614
|
-
expect(body.authorization_endpoint).toBe(
|
|
615
|
-
expect(body.token_endpoint).toBe(
|
|
616
|
-
expect(body.registration_endpoint).toBe(
|
|
622
|
+
expect(body.issuer).toBe(HUB_ORIGIN);
|
|
623
|
+
expect(body.authorization_endpoint).toBe(`${HUB_ORIGIN}/oauth/authorize`);
|
|
624
|
+
expect(body.token_endpoint).toBe(`${HUB_ORIGIN}/oauth/token`);
|
|
625
|
+
expect(body.registration_endpoint).toBe(`${HUB_ORIGIN}/oauth/register`);
|
|
626
|
+
expect(body.jwks_uri).toBe(`${HUB_ORIGIN}/.well-known/jwks.json`);
|
|
617
627
|
});
|
|
618
628
|
|
|
619
|
-
test("
|
|
629
|
+
test("PRM names the vault's MCP endpoint and points at the hub as AS", async () => {
|
|
620
630
|
createVault("journal");
|
|
621
631
|
const path = "/vault/journal/.well-known/oauth-protected-resource";
|
|
622
632
|
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
623
633
|
expect(res.status).toBe(200);
|
|
624
634
|
const body = (await res.json()) as { resource: string; authorization_servers: string[] };
|
|
625
635
|
expect(body.resource).toBe("http://localhost:1940/vault/journal/mcp");
|
|
626
|
-
expect(body.authorization_servers).toEqual([
|
|
636
|
+
expect(body.authorization_servers).toEqual([HUB_ORIGIN]);
|
|
627
637
|
});
|
|
628
638
|
|
|
629
639
|
test("unknown vault returns 404 rather than boilerplate metadata", async () => {
|
|
@@ -637,7 +647,28 @@ describe("per-vault OAuth discovery", () => {
|
|
|
637
647
|
}
|
|
638
648
|
});
|
|
639
649
|
|
|
640
|
-
test("x-forwarded-* headers propagate into the
|
|
650
|
+
test("x-forwarded-* headers propagate into the PRM `resource` URL", async () => {
|
|
651
|
+
// The resource URL still tracks the vault's external origin (it's the
|
|
652
|
+
// protected resource the client just hit); the AS pointer is always the
|
|
653
|
+
// hub, independent of the inbound request's origin.
|
|
654
|
+
createVault("journal");
|
|
655
|
+
const path = "/vault/journal/.well-known/oauth-protected-resource";
|
|
656
|
+
const res = await route(
|
|
657
|
+
new Request(`http://127.0.0.1:1940${path}`, {
|
|
658
|
+
headers: {
|
|
659
|
+
"x-forwarded-host": "vault.example.com",
|
|
660
|
+
"x-forwarded-proto": "https",
|
|
661
|
+
},
|
|
662
|
+
}),
|
|
663
|
+
path,
|
|
664
|
+
);
|
|
665
|
+
expect(res.status).toBe(200);
|
|
666
|
+
const body = (await res.json()) as { resource: string; authorization_servers: string[] };
|
|
667
|
+
expect(body.resource).toBe("https://vault.example.com/vault/journal/mcp");
|
|
668
|
+
expect(body.authorization_servers).toEqual([HUB_ORIGIN]);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("AS metadata ignores x-forwarded-* (hub origin is config, not request-derived)", async () => {
|
|
641
672
|
createVault("journal");
|
|
642
673
|
const path = "/vault/journal/.well-known/oauth-authorization-server";
|
|
643
674
|
const res = await route(
|
|
@@ -651,16 +682,16 @@ describe("per-vault OAuth discovery", () => {
|
|
|
651
682
|
);
|
|
652
683
|
expect(res.status).toBe(200);
|
|
653
684
|
const body = (await res.json()) as { issuer: string; registration_endpoint: string };
|
|
654
|
-
expect(body.issuer).toBe(
|
|
655
|
-
expect(body.registration_endpoint).toBe(
|
|
656
|
-
"https://vault.example.com/vault/journal/oauth/register",
|
|
657
|
-
);
|
|
685
|
+
expect(body.issuer).toBe(HUB_ORIGIN);
|
|
686
|
+
expect(body.registration_endpoint).toBe(`${HUB_ORIGIN}/oauth/register`);
|
|
658
687
|
});
|
|
659
688
|
|
|
660
|
-
test("end-to-end flow: WWW-Authenticate → PRM → AS metadata
|
|
661
|
-
// On 401, follow the challenge to the PRM, then
|
|
662
|
-
// PRM.authorization_servers[0]
|
|
663
|
-
//
|
|
689
|
+
test("end-to-end flow: WWW-Authenticate → PRM → hub AS metadata pointer", async () => {
|
|
690
|
+
// On 401, follow the challenge to the PRM, then read
|
|
691
|
+
// PRM.authorization_servers[0] — it must point at the hub origin. The
|
|
692
|
+
// hub side of the test (fetching the AS metadata at the hub itself)
|
|
693
|
+
// lives in hub's test suite; here we just verify vault stops at the
|
|
694
|
+
// right pointer.
|
|
664
695
|
createVault("journal");
|
|
665
696
|
|
|
666
697
|
// Step 1: unauthenticated MCP → 401 + WWW-Authenticate.
|
|
@@ -675,29 +706,9 @@ describe("per-vault OAuth discovery", () => {
|
|
|
675
706
|
const prmRes = await route(new Request(`http://localhost:1940${prmPath}`), prmPath);
|
|
676
707
|
expect(prmRes.status).toBe(200);
|
|
677
708
|
const prm = (await prmRes.json()) as { authorization_servers: string[] };
|
|
678
|
-
const asBase = prm.authorization_servers[0]; // "http://localhost:1940/vault/journal"
|
|
679
709
|
|
|
680
|
-
// Step 3: AS
|
|
681
|
-
|
|
682
|
-
const asMetaPath = `${asBasePath}/.well-known/oauth-authorization-server`;
|
|
683
|
-
const asRes = await route(new Request(`http://localhost:1940${asMetaPath}`), asMetaPath);
|
|
684
|
-
expect(asRes.status).toBe(200);
|
|
685
|
-
const asMeta = (await asRes.json()) as { registration_endpoint: string };
|
|
686
|
-
|
|
687
|
-
// Step 4: the advertised registration_endpoint must be live.
|
|
688
|
-
const regPath = new URL(asMeta.registration_endpoint).pathname;
|
|
689
|
-
const regRes = await route(
|
|
690
|
-
new Request(`http://localhost:1940${regPath}`, {
|
|
691
|
-
method: "POST",
|
|
692
|
-
headers: { "Content-Type": "application/json" },
|
|
693
|
-
body: JSON.stringify({
|
|
694
|
-
client_name: "Test",
|
|
695
|
-
redirect_uris: ["https://example.com/cb"],
|
|
696
|
-
}),
|
|
697
|
-
}),
|
|
698
|
-
regPath,
|
|
699
|
-
);
|
|
700
|
-
expect(regRes.status).toBe(201);
|
|
710
|
+
// Step 3: the AS pointer is the hub. Clients drive the flow from there.
|
|
711
|
+
expect(prm.authorization_servers).toEqual([HUB_ORIGIN]);
|
|
701
712
|
});
|
|
702
713
|
});
|
|
703
714
|
|
|
@@ -716,7 +727,7 @@ describe("per-vault OAuth discovery", () => {
|
|
|
716
727
|
// ---------------------------------------------------------------------------
|
|
717
728
|
|
|
718
729
|
describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
719
|
-
test("AS metadata at path-insertion short form
|
|
730
|
+
test("AS metadata at path-insertion short form names the hub", async () => {
|
|
720
731
|
createVault("journal");
|
|
721
732
|
const path = "/.well-known/oauth-authorization-server/vault/journal";
|
|
722
733
|
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
@@ -727,10 +738,10 @@ describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
|
727
738
|
token_endpoint: string;
|
|
728
739
|
registration_endpoint: string;
|
|
729
740
|
};
|
|
730
|
-
expect(body.issuer).toBe(
|
|
731
|
-
expect(body.authorization_endpoint).toBe(
|
|
732
|
-
expect(body.token_endpoint).toBe(
|
|
733
|
-
expect(body.registration_endpoint).toBe(
|
|
741
|
+
expect(body.issuer).toBe(HUB_ORIGIN);
|
|
742
|
+
expect(body.authorization_endpoint).toBe(`${HUB_ORIGIN}/oauth/authorize`);
|
|
743
|
+
expect(body.token_endpoint).toBe(`${HUB_ORIGIN}/oauth/token`);
|
|
744
|
+
expect(body.registration_endpoint).toBe(`${HUB_ORIGIN}/oauth/register`);
|
|
734
745
|
});
|
|
735
746
|
|
|
736
747
|
test("AS metadata at path-insertion long form (/mcp suffix) also works", async () => {
|
|
@@ -741,7 +752,7 @@ describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
|
741
752
|
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
742
753
|
expect(res.status).toBe(200);
|
|
743
754
|
const body = (await res.json()) as { issuer: string };
|
|
744
|
-
expect(body.issuer).toBe(
|
|
755
|
+
expect(body.issuer).toBe(HUB_ORIGIN);
|
|
745
756
|
});
|
|
746
757
|
|
|
747
758
|
test("PRM at path-insertion short form returns vault-scoped resource", async () => {
|
|
@@ -751,7 +762,7 @@ describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
|
751
762
|
expect(res.status).toBe(200);
|
|
752
763
|
const body = (await res.json()) as { resource: string; authorization_servers: string[] };
|
|
753
764
|
expect(body.resource).toBe("http://localhost:1940/vault/journal/mcp");
|
|
754
|
-
expect(body.authorization_servers).toEqual([
|
|
765
|
+
expect(body.authorization_servers).toEqual([HUB_ORIGIN]);
|
|
755
766
|
});
|
|
756
767
|
|
|
757
768
|
test("PRM at path-insertion long form (/mcp suffix) also works", async () => {
|
|
@@ -808,7 +819,9 @@ describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
|
808
819
|
}
|
|
809
820
|
});
|
|
810
821
|
|
|
811
|
-
test("
|
|
822
|
+
test("AS metadata at path-insertion is hub-rooted regardless of x-forwarded-*", async () => {
|
|
823
|
+
// Hub origin is configuration, not request-derived. Proxies forwarding
|
|
824
|
+
// x-forwarded-host don't override the issuer.
|
|
812
825
|
createVault("journal");
|
|
813
826
|
const path = "/.well-known/oauth-authorization-server/vault/journal";
|
|
814
827
|
const res = await route(
|
|
@@ -822,16 +835,17 @@ describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
|
822
835
|
);
|
|
823
836
|
expect(res.status).toBe(200);
|
|
824
837
|
const body = (await res.json()) as { issuer: string; registration_endpoint: string };
|
|
825
|
-
expect(body.issuer).toBe(
|
|
826
|
-
expect(body.registration_endpoint).toBe(
|
|
827
|
-
"https://vault.example.com/vault/journal/oauth/register",
|
|
828
|
-
);
|
|
838
|
+
expect(body.issuer).toBe(HUB_ORIGIN);
|
|
839
|
+
expect(body.registration_endpoint).toBe(`${HUB_ORIGIN}/oauth/register`);
|
|
829
840
|
});
|
|
830
841
|
|
|
831
|
-
test("end-to-end: 401 → PRM (path-insertion) → AS
|
|
832
|
-
// Exact handshake Claude Code's MCP OAuth SDK performs.
|
|
833
|
-
//
|
|
834
|
-
//
|
|
842
|
+
test("end-to-end: 401 → PRM (path-insertion) → AS pointer is the hub", async () => {
|
|
843
|
+
// Exact handshake Claude Code's MCP OAuth SDK performs. After
|
|
844
|
+
// workstream E the chain stops at "AS = hub origin"; the live
|
|
845
|
+
// registration_endpoint lives on the hub (covered by hub's tests).
|
|
846
|
+
// Both the path-append PRM (named by the WWW-Authenticate challenge)
|
|
847
|
+
// and the path-insertion PRM (what strict clients probe) must
|
|
848
|
+
// return the same hub pointer.
|
|
835
849
|
createVault("journal");
|
|
836
850
|
|
|
837
851
|
// Step 1: unauth MCP → 401 + WWW-Authenticate with PRM pointer.
|
|
@@ -841,9 +855,7 @@ describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
|
841
855
|
const challenge = mcpRes.headers.get("WWW-Authenticate")!;
|
|
842
856
|
const prmUrl = challenge.match(/resource_metadata="([^"]+)"/)![1];
|
|
843
857
|
|
|
844
|
-
//
|
|
845
|
-
// in the header). Strict clients ignore the hint and probe the
|
|
846
|
-
// path-insertion form regardless — that's the path we care about here.
|
|
858
|
+
// Step 2: path-insertion PRM also resolves and names the hub.
|
|
847
859
|
const prmInsertPath = "/.well-known/oauth-protected-resource/vault/journal";
|
|
848
860
|
const prmRes = await route(
|
|
849
861
|
new Request(`http://localhost:1940${prmInsertPath}`),
|
|
@@ -851,41 +863,30 @@ describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
|
851
863
|
);
|
|
852
864
|
expect(prmRes.status).toBe(200);
|
|
853
865
|
const prm = (await prmRes.json()) as { authorization_servers: string[] };
|
|
854
|
-
|
|
866
|
+
expect(prm.authorization_servers).toEqual([HUB_ORIGIN]);
|
|
855
867
|
|
|
856
|
-
// Step
|
|
857
|
-
//
|
|
858
|
-
|
|
859
|
-
const asInsertPath = `/.well-known/oauth-authorization-server
|
|
868
|
+
// Step 3: AS metadata via path-insertion on the vault path returns
|
|
869
|
+
// hub-rooted endpoints — strict clients that probe AS via the vault
|
|
870
|
+
// path (rather than following the PRM pointer) still land at the hub.
|
|
871
|
+
const asInsertPath = `/.well-known/oauth-authorization-server/vault/journal`;
|
|
860
872
|
const asRes = await route(
|
|
861
873
|
new Request(`http://localhost:1940${asInsertPath}`),
|
|
862
874
|
asInsertPath,
|
|
863
875
|
);
|
|
864
876
|
expect(asRes.status).toBe(200);
|
|
865
|
-
const asMeta = (await asRes.json()) as { registration_endpoint: string };
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
const regPath = new URL(asMeta.registration_endpoint).pathname;
|
|
869
|
-
const regRes = await route(
|
|
870
|
-
new Request(`http://localhost:1940${regPath}`, {
|
|
871
|
-
method: "POST",
|
|
872
|
-
headers: { "Content-Type": "application/json" },
|
|
873
|
-
body: JSON.stringify({
|
|
874
|
-
client_name: "Test",
|
|
875
|
-
redirect_uris: ["https://example.com/cb"],
|
|
876
|
-
}),
|
|
877
|
-
}),
|
|
878
|
-
regPath,
|
|
879
|
-
);
|
|
880
|
-
expect(regRes.status).toBe(201);
|
|
877
|
+
const asMeta = (await asRes.json()) as { issuer: string; registration_endpoint: string };
|
|
878
|
+
expect(asMeta.issuer).toBe(HUB_ORIGIN);
|
|
879
|
+
expect(asMeta.registration_endpoint).toBe(`${HUB_ORIGIN}/oauth/register`);
|
|
881
880
|
|
|
882
|
-
//
|
|
883
|
-
//
|
|
881
|
+
// Step 4: path-append PRM URL in the challenge header still resolves
|
|
882
|
+
// and agrees with the path-insertion answer.
|
|
884
883
|
const prmAppendRes = await route(
|
|
885
884
|
new Request(prmUrl),
|
|
886
885
|
new URL(prmUrl).pathname,
|
|
887
886
|
);
|
|
888
887
|
expect(prmAppendRes.status).toBe(200);
|
|
888
|
+
const prmAppend = (await prmAppendRes.json()) as { authorization_servers: string[] };
|
|
889
|
+
expect(prmAppend.authorization_servers).toEqual([HUB_ORIGIN]);
|
|
889
890
|
});
|
|
890
891
|
});
|
|
891
892
|
|
|
@@ -911,7 +912,6 @@ describe("/.parachute/info + /.parachute/icon.svg", () => {
|
|
|
911
912
|
tagline: string;
|
|
912
913
|
version: string;
|
|
913
914
|
iconUrl: string;
|
|
914
|
-
kind: string;
|
|
915
915
|
};
|
|
916
916
|
expect(body).toEqual({
|
|
917
917
|
name: "parachute-vault",
|
|
@@ -919,8 +919,11 @@ describe("/.parachute/info + /.parachute/icon.svg", () => {
|
|
|
919
919
|
tagline: expect.stringContaining("knowledge graph"),
|
|
920
920
|
version: pkg.version,
|
|
921
921
|
iconUrl: "/vault/journal/.parachute/icon.svg",
|
|
922
|
-
kind: "api",
|
|
923
922
|
});
|
|
923
|
+
// `kind` retired from the info-endpoint response per hub#330 (companion
|
|
924
|
+
// to vault#359's module.json drop). Pin its absence so regressions are
|
|
925
|
+
// surfaced — the shape is a locked contract with the hub.
|
|
926
|
+
expect(body).not.toHaveProperty("kind");
|
|
924
927
|
});
|
|
925
928
|
|
|
926
929
|
test("info iconUrl is vault-scoped and points at a live icon handler", async () => {
|
package/src/routing.ts
CHANGED
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* /vaults/list — public vault-name discovery (can be
|
|
16
16
|
* disabled globally via config)
|
|
17
17
|
* /vaults — authenticated vault metadata list
|
|
18
|
-
* /vault/<name>/.well-known
|
|
19
|
-
*
|
|
18
|
+
* /vault/<name>/.well-known/oauth-* — discovery forwarder; metadata names
|
|
19
|
+
* the hub as the authorization server
|
|
20
|
+
* (vault is resource-server only)
|
|
20
21
|
* /vault/<name>/mcp[/*] — MCP endpoint (Bearer auth)
|
|
21
22
|
* /vault/<name>/view/<idOrPath> — auth-aware HTML view
|
|
22
23
|
* /vault/<name>/public/<noteId> — legacy alias → /view redirect
|
|
@@ -26,6 +27,13 @@
|
|
|
26
27
|
* There is deliberately no compat for the old `/api/*`, `/mcp`, `/oauth/*`,
|
|
27
28
|
* `/view/*`, or `/vaults/<name>/*` prefixes. Clients must re-authenticate
|
|
28
29
|
* after the upgrade and point at the new URLs.
|
|
30
|
+
*
|
|
31
|
+
* **No standalone OAuth issuer.** vault does not mint OAuth tokens or
|
|
32
|
+
* render a consent UI. Hub is the issuer; vault validates hub-signed
|
|
33
|
+
* JWTs (see `auth.ts` + `hub-jwt.ts`). The discovery endpoints above are
|
|
34
|
+
* forwarders that point clients at the hub. The standalone surface that
|
|
35
|
+
* lived in `src/oauth.ts` was retired in vault#366 (workstream E of the
|
|
36
|
+
* UX audit, 2026-05-25).
|
|
29
37
|
*/
|
|
30
38
|
|
|
31
39
|
import pkg from "../package.json" with { type: "json" };
|
|
@@ -60,15 +68,10 @@ import { handleTokens } from "./tokens-routes.ts";
|
|
|
60
68
|
import {
|
|
61
69
|
handleProtectedResource,
|
|
62
70
|
handleAuthorizationServer,
|
|
63
|
-
handleRegister,
|
|
64
|
-
handleAuthorizeGet,
|
|
65
|
-
handleAuthorizePost,
|
|
66
|
-
handleToken,
|
|
67
71
|
getBaseUrl,
|
|
68
|
-
} from "./oauth.ts";
|
|
72
|
+
} from "./oauth-discovery.ts";
|
|
69
73
|
import { handleConfigSchema, handleConfig } from "./module-config.ts";
|
|
70
74
|
import { buildAuthStatus } from "./auth-status.ts";
|
|
71
|
-
import { getAuthorizeRateLimiter } from "./owner-auth.ts";
|
|
72
75
|
import { handleMirrorGet, handleMirrorPut } from "./mirror-routes.ts";
|
|
73
76
|
import { getMirrorManager } from "./mirror-registry.ts";
|
|
74
77
|
|
|
@@ -132,11 +135,11 @@ function handleParachuteInfo(vaultName: string): Response {
|
|
|
132
135
|
tagline: "Agent-native knowledge graph — notes, tags, links, attachments over REST + MCP",
|
|
133
136
|
version: pkg.version,
|
|
134
137
|
iconUrl: `/vault/${vaultName}/.parachute/icon.svg`,
|
|
135
|
-
// Hub renders `kind: "api"` cards as an expandable detail panel (MCP URL,
|
|
136
|
-
// OAuth link, version) rather than navigating to the API's root. Vault
|
|
137
|
-
// has no browser UI, so navigating to it shows raw JSON — not useful.
|
|
138
|
-
kind: "api",
|
|
139
138
|
};
|
|
139
|
+
// `kind` was previously emitted here (and matched module.json) to let the
|
|
140
|
+
// hub branch its card rendering on api vs ui. Retired per hub#330 — the hub
|
|
141
|
+
// now infers presentation from the response shape itself. Companion to
|
|
142
|
+
// vault#359 (manifest drop); closes part of hub#340.
|
|
140
143
|
return Response.json(body, {
|
|
141
144
|
headers: { "Access-Control-Allow-Origin": "*" },
|
|
142
145
|
});
|
|
@@ -167,7 +170,6 @@ function handleParachuteIcon(): Response {
|
|
|
167
170
|
export async function route(
|
|
168
171
|
req: Request,
|
|
169
172
|
path: string,
|
|
170
|
-
clientIp?: string,
|
|
171
173
|
): Promise<Response> {
|
|
172
174
|
// ---------------------------------------------------------------------
|
|
173
175
|
// OAuth discovery — RFC 8414 §3.1 / RFC 9728 §3 path-insertion form.
|
|
@@ -325,41 +327,25 @@ export async function route(
|
|
|
325
327
|
});
|
|
326
328
|
}
|
|
327
329
|
|
|
328
|
-
//
|
|
330
|
+
// The legacy `/oauth/{register,authorize,token}` flow on vault was retired
|
|
331
|
+
// when the standalone OAuth issuer was removed (vault#366, workstream E of
|
|
332
|
+
// the UX audit). Hub is the issuer now; vault is resource-server only. A
|
|
333
|
+
// request landing here is from a client that hasn't been re-pointed at the
|
|
334
|
+
// hub yet — surface a clear 410 Gone with a discovery pointer so the client
|
|
335
|
+
// (or its operator) knows where the authorization server moved.
|
|
329
336
|
if (subpath === "/oauth/register" || subpath === "/oauth/authorize" || subpath === "/oauth/token") {
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
ownerPasswordHash,
|
|
343
|
-
totpEnrolled,
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
if (req.method === "POST") {
|
|
347
|
-
return handleAuthorizePost(req, store.db, {
|
|
348
|
-
vaultName: vaultConfig.name,
|
|
349
|
-
clientIp,
|
|
350
|
-
ownerPasswordHash,
|
|
351
|
-
totpSecret,
|
|
352
|
-
// Per-vault rate-limit instance — prevents brute-force traffic on
|
|
353
|
-
// one vault's consent flow from locking out IPs trying to authorize
|
|
354
|
-
// against an unrelated vault (#93).
|
|
355
|
-
rateLimiter: getAuthorizeRateLimiter(vaultConfig.name),
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
359
|
-
}
|
|
360
|
-
// handleToken pins the OAuth code to the issuing vault (prevents
|
|
361
|
-
// cross-vault code replay) and echoes `vault: <name>` in the response.
|
|
362
|
-
if (subpath === "/oauth/token") return handleToken(req, store.db, vaultName);
|
|
337
|
+
const base = getBaseUrl(req);
|
|
338
|
+
return Response.json(
|
|
339
|
+
{
|
|
340
|
+
error: "oauth_endpoint_removed",
|
|
341
|
+
error_description:
|
|
342
|
+
"Vault no longer hosts an OAuth issuer. The hub is the authorization server. " +
|
|
343
|
+
"Discover its endpoints via the protected-resource metadata.",
|
|
344
|
+
protected_resource_metadata:
|
|
345
|
+
`${base}/vault/${vaultName}/.well-known/oauth-protected-resource`,
|
|
346
|
+
},
|
|
347
|
+
{ status: 410 },
|
|
348
|
+
);
|
|
363
349
|
}
|
|
364
350
|
|
|
365
351
|
// Parachute service-info + icon (no auth, CORS *). The CLI hub page at
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for vault's scribe service-discovery (vault#353).
|
|
3
|
+
*
|
|
4
|
+
* Single decision site for "where does scribe live": env override, then
|
|
5
|
+
* `~/.parachute/services.json`. The cache layer is exercised separately
|
|
6
|
+
* so the resolution rule stays unit-testable without filesystem state.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
10
|
+
import { resolveScribeUrl, clearScribeUrlCache } from "./scribe-discovery.ts";
|
|
11
|
+
|
|
12
|
+
function mkManifest(services: Array<{ name: string; port: number; origin?: string }>): typeof import("./services-manifest.ts").readManifest {
|
|
13
|
+
return () => ({
|
|
14
|
+
services: services.map((s) => ({
|
|
15
|
+
name: s.name,
|
|
16
|
+
port: s.port,
|
|
17
|
+
paths: [`/${s.name}`],
|
|
18
|
+
health: "/health",
|
|
19
|
+
version: "0.0.0-test",
|
|
20
|
+
...(s.origin ? { origin: s.origin } : {}),
|
|
21
|
+
})) as any,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
clearScribeUrlCache();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("resolveScribeUrl", () => {
|
|
30
|
+
test("returns SCRIBE_URL env var (overrides services.json)", () => {
|
|
31
|
+
const env = { SCRIBE_URL: "http://example.test:9999" } as NodeJS.ProcessEnv;
|
|
32
|
+
const manifest = mkManifest([{ name: "parachute-scribe", port: 1943 }]);
|
|
33
|
+
expect(resolveScribeUrl(env, manifest)).toBe("http://example.test:9999");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("strips trailing slash from SCRIBE_URL env var", () => {
|
|
37
|
+
const env = { SCRIBE_URL: "http://example.test:9999/" } as NodeJS.ProcessEnv;
|
|
38
|
+
const manifest = mkManifest([]);
|
|
39
|
+
expect(resolveScribeUrl(env, manifest)).toBe("http://example.test:9999");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("falls back to services.json parachute-scribe entry", () => {
|
|
43
|
+
const env = {} as NodeJS.ProcessEnv;
|
|
44
|
+
const manifest = mkManifest([{ name: "parachute-scribe", port: 1943 }]);
|
|
45
|
+
expect(resolveScribeUrl(env, manifest)).toBe("http://127.0.0.1:1943");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("honors explicit `origin` on the service entry (v0.7 shape)", () => {
|
|
49
|
+
const env = {} as NodeJS.ProcessEnv;
|
|
50
|
+
const manifest = mkManifest([
|
|
51
|
+
{ name: "parachute-scribe", port: 1943, origin: "https://scribe.cloud.example.com" },
|
|
52
|
+
]);
|
|
53
|
+
expect(resolveScribeUrl(env, manifest)).toBe("https://scribe.cloud.example.com");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("returns undefined when no env override AND no scribe entry", () => {
|
|
57
|
+
const env = {} as NodeJS.ProcessEnv;
|
|
58
|
+
const manifest = mkManifest([{ name: "parachute-vault", port: 1940 }]);
|
|
59
|
+
expect(resolveScribeUrl(env, manifest)).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("returns undefined when manifest read throws", () => {
|
|
63
|
+
const env = {} as NodeJS.ProcessEnv;
|
|
64
|
+
const calls: unknown[][] = [];
|
|
65
|
+
const logger = { warn: (...args: unknown[]) => calls.push(args) };
|
|
66
|
+
const manifest = (() => { throw new Error("boom"); }) as unknown as Parameters<typeof resolveScribeUrl>[1];
|
|
67
|
+
expect(resolveScribeUrl(env, manifest, logger)).toBeUndefined();
|
|
68
|
+
expect(calls.length).toBe(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("trims whitespace-only SCRIBE_URL as unset", () => {
|
|
72
|
+
const env = { SCRIBE_URL: " " } as NodeJS.ProcessEnv;
|
|
73
|
+
const manifest = mkManifest([{ name: "parachute-scribe", port: 1943 }]);
|
|
74
|
+
// Whitespace-only env falls through to services.json.
|
|
75
|
+
expect(resolveScribeUrl(env, manifest)).toBe("http://127.0.0.1:1943");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service discovery for the scribe transcription module.
|
|
3
|
+
*
|
|
4
|
+
* Per the 2026-05-21 vault↔scribe design (Part 2, design question 2), vault
|
|
5
|
+
* locates scribe via `~/.parachute/services.json` — the canonical hub-
|
|
6
|
+
* maintained registry. This module is the single read site so the
|
|
7
|
+
* resolution rule lives in one place.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order (first hit wins):
|
|
10
|
+
*
|
|
11
|
+
* 1. `SCRIBE_URL` env var (operator override; useful for tests, Docker
|
|
12
|
+
* compose, and any deploy where scribe runs at a non-loopback host).
|
|
13
|
+
* 2. Entry `name === "parachute-scribe"` in `~/.parachute/services.json`
|
|
14
|
+
* → construct `http://127.0.0.1:<port>`.
|
|
15
|
+
* 3. `undefined` (auto-transcribe stays a no-op).
|
|
16
|
+
*
|
|
17
|
+
* The bearer token resolution stays in `./scribe-env.ts:resolveScribeAuthToken`.
|
|
18
|
+
* Service discovery is just about WHERE scribe lives; AUTH is a separate
|
|
19
|
+
* concern with its own env-var precedence (SCRIBE_AUTH_TOKEN over the legacy
|
|
20
|
+
* SCRIBE_TOKEN). When the v0.7 hub-issued-JWT path lands, the bearer source
|
|
21
|
+
* changes but the URL source stays the same — one file, one concern.
|
|
22
|
+
*
|
|
23
|
+
* v0.6 deploy is single-container (hub-as-supervisor) so loopback is fine.
|
|
24
|
+
* v0.7 cloud-multi-container will grow an `origin` field on the services.json
|
|
25
|
+
* entry; this resolver will honor it without API changes — `port` becomes
|
|
26
|
+
* a fallback when `origin` isn't set, no breaking change for v0.6 callers.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readManifest, ServicesManifestError } from "./services-manifest.ts";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the scribe base URL (no trailing slash) by consulting the env-var
|
|
33
|
+
* override first, then services.json. Returns `undefined` when scribe isn't
|
|
34
|
+
* configured — callers MUST treat that as "auto-transcribe disabled."
|
|
35
|
+
*
|
|
36
|
+
* The `env` + `readManifestImpl` parameters are injection seams for tests;
|
|
37
|
+
* production callers omit them and pick up `process.env` + the real
|
|
38
|
+
* `~/.parachute/services.json`.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveScribeUrl(
|
|
41
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
42
|
+
readManifestImpl: typeof readManifest = readManifest,
|
|
43
|
+
logger: { warn?: (...args: unknown[]) => void } = console,
|
|
44
|
+
): string | undefined {
|
|
45
|
+
const override = env.SCRIBE_URL?.trim();
|
|
46
|
+
if (override) return override.replace(/\/$/, "");
|
|
47
|
+
|
|
48
|
+
let manifest;
|
|
49
|
+
try {
|
|
50
|
+
manifest = readManifestImpl();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err instanceof ServicesManifestError) {
|
|
53
|
+
logger.warn?.(`[scribe-discovery] services.json unreadable: ${err.message}`);
|
|
54
|
+
} else {
|
|
55
|
+
logger.warn?.(`[scribe-discovery] services.json read failed: ${err}`);
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const entry = manifest.services.find((s) => s.name === "parachute-scribe");
|
|
60
|
+
if (!entry) return undefined;
|
|
61
|
+
// v0.6 loopback shape; v0.7 will add an explicit `origin` field on the
|
|
62
|
+
// service entry which wins over loopback when present.
|
|
63
|
+
const origin = (entry as { origin?: string }).origin;
|
|
64
|
+
if (typeof origin === "string" && origin.trim()) {
|
|
65
|
+
return origin.trim().replace(/\/$/, "");
|
|
66
|
+
}
|
|
67
|
+
return `http://127.0.0.1:${entry.port}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Process-lifetime cache. Computed at first call (typically during server
|
|
72
|
+
* boot), reused for every subsequent transcription request. Operators who
|
|
73
|
+
* change the scribe URL via `services.json` (re-install of scribe with a
|
|
74
|
+
* different port) need to restart vault; we deliberately don't watch the
|
|
75
|
+
* file because the v0.6 deploy model has a single restart-on-change story.
|
|
76
|
+
*
|
|
77
|
+
* Tests should pass an explicit `env` + `readManifestImpl` to `resolveScribeUrl`
|
|
78
|
+
* directly to bypass the cache.
|
|
79
|
+
*/
|
|
80
|
+
let cachedScribeUrl: string | undefined | null = null;
|
|
81
|
+
|
|
82
|
+
export function getCachedScribeUrl(): string | undefined {
|
|
83
|
+
if (cachedScribeUrl === null) {
|
|
84
|
+
cachedScribeUrl = resolveScribeUrl();
|
|
85
|
+
}
|
|
86
|
+
return cachedScribeUrl;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function clearScribeUrlCache(): void {
|
|
90
|
+
cachedScribeUrl = null;
|
|
91
|
+
}
|