@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.
Files changed (54) hide show
  1. package/.parachute/module.json +1 -1
  2. package/README.md +78 -41
  3. package/core/src/connection-pragmas.test.ts +232 -0
  4. package/core/src/core.test.ts +257 -0
  5. package/core/src/cursor.test.ts +160 -0
  6. package/core/src/cursor.ts +272 -0
  7. package/core/src/mcp.ts +51 -7
  8. package/core/src/notes.ts +164 -2
  9. package/core/src/schema.ts +106 -5
  10. package/core/src/store.ts +11 -1
  11. package/core/src/types.ts +32 -0
  12. package/package.json +7 -3
  13. package/src/auth-status.ts +4 -0
  14. package/src/auth.test.ts +5 -112
  15. package/src/auto-transcribe.test.ts +116 -0
  16. package/src/auto-transcribe.ts +48 -0
  17. package/src/backup.ts +17 -3
  18. package/src/cli.ts +95 -66
  19. package/src/config.test.ts +26 -0
  20. package/src/config.ts +53 -1
  21. package/src/db.ts +15 -2
  22. package/src/export-watch.test.ts +21 -0
  23. package/src/mcp-install-interactive.test.ts +23 -2
  24. package/src/mcp-install-interactive.ts +21 -2
  25. package/src/mcp-install.test.ts +40 -0
  26. package/src/mcp-tools.ts +17 -1
  27. package/src/module-config.ts +70 -14
  28. package/src/module-manifest.test.ts +114 -0
  29. package/src/module-manifest.ts +104 -0
  30. package/src/oauth-discovery.ts +95 -0
  31. package/src/owner-auth.ts +22 -149
  32. package/src/routes.ts +268 -51
  33. package/src/routing.test.ts +102 -99
  34. package/src/routing.ts +33 -47
  35. package/src/scribe-discovery.test.ts +77 -0
  36. package/src/scribe-discovery.ts +91 -0
  37. package/src/scribe-env.test.ts +66 -1
  38. package/src/scribe-env.ts +42 -1
  39. package/src/self-register.test.ts +412 -0
  40. package/src/self-register.ts +247 -0
  41. package/src/server.ts +47 -23
  42. package/src/transcript-note.test.ts +171 -0
  43. package/src/transcript-note.ts +189 -0
  44. package/src/transcription-registry.ts +22 -0
  45. package/src/transcription-worker.test.ts +250 -0
  46. package/src/transcription-worker.ts +186 -27
  47. package/src/vault-name.ts +3 -2
  48. package/src/vault.test.ts +347 -0
  49. package/web/ui/dist/assets/index-BOa-JJtV.css +1 -0
  50. package/web/ui/dist/assets/index-BzA5LgE3.js +60 -0
  51. package/web/ui/dist/index.html +14 -0
  52. package/web/ui/tsconfig.json +21 -0
  53. package/src/oauth.test.ts +0 -2156
  54. package/src/oauth.ts +0 -973
@@ -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/register reaches the OAuth handler", async () => {
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 path = "/vault/journal/oauth/register";
461
- const res = await route(
462
- new Request(`http://localhost:1940${path}`, {
463
- method: "POST",
464
- headers: { "Content-Type": "application/json" },
465
- body: JSON.stringify({ client_name: "test", redirect_uris: ["https://x.example/cb"] }),
466
- }),
467
- path,
468
- );
469
- expect(res.status).not.toBe(500);
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
- // For a resource at `/vault/<name>/mcp`, clients fetch metadata from
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
- // All endpoints in the AS metadata are vault-scoped so a client that
598
- // discovers the AS at that URL can drive the full authorization flow.
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
- describe("per-vault OAuth discovery", () => {
602
- test("/vault/<name>/.well-known/oauth-authorization-server returns vault-scoped AS metadata", async () => {
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("http://localhost:1940/vault/journal");
614
- expect(body.authorization_endpoint).toBe("http://localhost:1940/vault/journal/oauth/authorize");
615
- expect(body.token_endpoint).toBe("http://localhost:1940/vault/journal/oauth/token");
616
- expect(body.registration_endpoint).toBe("http://localhost:1940/vault/journal/oauth/register");
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("/vault/<name>/.well-known/oauth-protected-resource returns vault-scoped PRM", async () => {
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(["http://localhost:1940/vault/journal"]);
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 generated metadata URLs", async () => {
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("https://vault.example.com/vault/journal");
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 → registration_endpoint is live", async () => {
661
- // On 401, follow the challenge to the PRM, then follow
662
- // PRM.authorization_servers[0] to the AS metadata, then hit the
663
- // `registration_endpoint`. Every hop must resolve.
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 metadata lives at `{asBase}/.well-known/oauth-authorization-server`.
681
- const asBasePath = new URL(asBase).pathname; // "/vault/journal"
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 returns vault-scoped endpoints", async () => {
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("http://localhost:1940/vault/journal");
731
- expect(body.authorization_endpoint).toBe("http://localhost:1940/vault/journal/oauth/authorize");
732
- expect(body.token_endpoint).toBe("http://localhost:1940/vault/journal/oauth/token");
733
- expect(body.registration_endpoint).toBe("http://localhost:1940/vault/journal/oauth/register");
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("http://localhost:1940/vault/journal");
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(["http://localhost:1940/vault/journal"]);
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("x-forwarded-* headers propagate through path-insertion URLs", async () => {
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("https://vault.example.com/vault/journal");
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 (path-insertion) DCR lands", async () => {
832
- // Exact handshake Claude Code's MCP OAuth SDK performs. If any hop
833
- // 404s, auth cascade-fails this is the launch-blocker regression
834
- // we're fixing.
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
- // The challenge still points at the path-append PRM (we emit one URL
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
- const asBase = prm.authorization_servers[0]; // http://localhost:1940/vault/journal
866
+ expect(prm.authorization_servers).toEqual([HUB_ORIGIN]);
855
867
 
856
- // Step 2: fetch AS metadata via path-insertion. The issuer path is
857
- // everything after the host here, `/vault/journal`.
858
- const asBasePath = new URL(asBase).pathname;
859
- const asInsertPath = `/.well-known/oauth-authorization-server${asBasePath}`;
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
- // Step 3: registration_endpoint must be live.
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
- // Path-append PRM URL in the challenge header must still resolve —
883
- // we haven't broken the back-compat path.
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/* per-vault OAuth discovery
19
- * /vault/<name>/oauth/{register,authorize,token}
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
- // OAuth flow endpoints (no auth these ARE the auth).
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 store = getVaultStore(vaultName);
331
- if (subpath === "/oauth/register") return handleRegister(req, store.db);
332
- if (subpath === "/oauth/authorize") {
333
- const gc = readGlobalConfig();
334
- const ownerPasswordHash = gc.owner_password_hash ?? null;
335
- const totpSecret = gc.totp_secret ?? null;
336
- const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
337
- if (req.method === "GET") {
338
- return handleAuthorizeGet(
339
- req,
340
- store.db,
341
- vaultConfig.name,
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
+ }