From 2ce5a59a1894169016990c1a7efc7cf681765883 Mon Sep 17 00:00:00 2001 From: Aaron Gabriel Date: Tue, 12 May 2026 09:41:22 -0600 Subject: [PATCH] feat(ui): vault popover + hub-discovery + OAuth vault-hint (0.3.15-rc.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the header `` switcher in the header + with a popover that surfaces the operator's full hub-side vault list + alongside the locally-connected vaults. Implements §2 of + [`design/2026-05-12-notes-ui-audit.md`](./design/2026-05-12-notes-ui-audit.md); + the first item in the §5 ship sequence. + - **Two sections.** "Connected" lists vaults Notes has tokens for + (active vault gets a filled accent dot + "current" tag; the rest + are one click to switch). "Available from your hub" lists vaults + published at `/.well-known/parachute.json` that Notes hasn't + connected to yet, each with an inline "Connect" button. Footer + links to the existing `/vaults` management page. + - **Hub-origin discovery.** Derived from `VaultRecord.issuer` (which + under hub-as-issuer is the hub origin itself, captured at OAuth + time in `OAuthCallback.tsx`) — no schema change to the stored + record, no migration. For a standalone-vault deployment the + well-known fetch returns no peers and the Available section is + omitted (graceful degradation). + - **OAuth `vault=` hint (Path A).** `beginOAuth` now accepts an + `options.params` bag appended to the authorize URL last, guarded + so caller-supplied params can never overwrite standard OAuth/PKCE + params. Notes sends `vault=` so future hubs that adopt the + hint can pre-select on the consent screen; pre-#240 hubs ignore it + and the picker renders as today. + - **Mobile.** Same component, rendered as `variant="inline"` inside + the existing hamburger menu — replaces the mobile ` setActiveVault(e.target.value || null)} - className="rounded-md border border-border bg-card px-2.5 py-1.5 text-sm text-fg" - > - {vaultList.map((v) => ( - - ))} - - + Settings @@ -131,27 +95,12 @@ export function Header() { Activity - - +
+ + Active vault + + +
diff --git a/src/components/ReconnectBanner.tsx b/src/components/ReconnectBanner.tsx index 67933c7..f4e2d90 100644 --- a/src/components/ReconnectBanner.tsx +++ b/src/components/ReconnectBanner.tsx @@ -13,9 +13,7 @@ import { useState } from "react"; export function ReconnectBanner() { const activeVaultId = useVaultStore((s) => s.activeVaultId); const vault = useVaultStore((s) => s.getActiveVault()); - const halt = useAuthHaltStore((s) => - activeVaultId ? (s.byVault[activeVaultId] ?? null) : null, - ); + const halt = useAuthHaltStore((s) => (activeVaultId ? (s.byVault[activeVaultId] ?? null) : null)); const [reconnecting, setReconnecting] = useState(false); const [error, setError] = useState(null); diff --git a/src/components/TranscriptionStatus.test.tsx b/src/components/TranscriptionStatus.test.tsx index ac6d63e..0e917bd 100644 --- a/src/components/TranscriptionStatus.test.tsx +++ b/src/components/TranscriptionStatus.test.tsx @@ -9,25 +9,19 @@ describe("TranscriptionStatus", () => { }); it("shows 'Transcribing…' when the note still carries the pending marker", () => { - render( - , - ); + render(); expect(screen.getByText(/transcribing/i)).toBeInTheDocument(); }); it("shows the unavailable chip when the note carries the unavailable marker", () => { render( - , + , ); expect(screen.getByText(/transcription unavailable/i)).toBeInTheDocument(); }); it("prefers the pending chip when both markers coexist", () => { - render( - , - ); + render(); expect(screen.getByText(/transcribing/i)).toBeInTheDocument(); expect(screen.queryByText(/transcription unavailable/i)).not.toBeInTheDocument(); }); diff --git a/src/components/VaultPopover.test.tsx b/src/components/VaultPopover.test.tsx new file mode 100644 index 0000000..47658ee --- /dev/null +++ b/src/components/VaultPopover.test.tsx @@ -0,0 +1,277 @@ +import { VaultPopover, buildVaultPopoverRows } from "@/components/VaultPopover"; +import type { HubVaultEntry } from "@/lib/vault/hub-discovery"; +import * as oauthModule from "@/lib/vault/oauth"; +import { useVaultStore } from "@/lib/vault/store"; +import type { VaultRecord } from "@/lib/vault/types"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +function makeVault(partial: Partial & Pick): VaultRecord { + return { + name: "", + issuer: "http://localhost:1939", + clientId: "client-test", + scope: "vault:read", + addedAt: "2026-05-12T00:00:00.000Z", + lastUsedAt: "2026-05-12T00:00:00.000Z", + ...partial, + }; +} + +function makeHubVault(name: string, url: string): HubVaultEntry { + return { name, url, version: "0.1.0" }; +} + +describe("buildVaultPopoverRows", () => { + it("returns just the connected vaults when the hub list is empty", () => { + const v = makeVault({ + id: "v", + url: "http://localhost:1939/vault/default", + name: "default", + }); + const rows = buildVaultPopoverRows([v], "v", [], "http://localhost:1939"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ kind: "connected", id: "v", isActive: true, hubKnown: false }); + }); + + it("marks a connected vault as hubKnown when the hub publishes a matching URL", () => { + const v = makeVault({ + id: "v", + url: "http://localhost:1939/vault/default", + name: "default", + }); + const rows = buildVaultPopoverRows( + [v], + "v", + [makeHubVault("default", "http://localhost:1939/vault/default")], + "http://localhost:1939", + ); + expect(rows.filter((r) => r.kind === "connected")).toHaveLength(1); + expect(rows[0]).toMatchObject({ kind: "connected", hubKnown: true }); + expect(rows.filter((r) => r.kind === "available")).toHaveLength(0); + }); + + it("splits hub-only vaults into the Available section", () => { + const v = makeVault({ + id: "v", + url: "http://localhost:1939/vault/default", + name: "default", + }); + const rows = buildVaultPopoverRows( + [v], + "v", + [ + makeHubVault("default", "http://localhost:1939/vault/default"), + makeHubVault("techne", "http://localhost:1939/vault/techne"), + makeHubVault("boulder", "http://localhost:1939/vault/boulder"), + ], + "http://localhost:1939", + ); + const connected = rows.filter((r) => r.kind === "connected"); + const available = rows.filter((r) => r.kind === "available"); + expect(connected).toHaveLength(1); + expect(available).toHaveLength(2); + expect(available.map((r) => r.kind === "available" && r.name)).toEqual(["boulder", "techne"]); + }); + + it("returns no Available rows when hub origin is null (standalone-vault case)", () => { + const v = makeVault({ + id: "v", + url: "https://vault.example.com", + name: "default", + issuer: "https://vault.example.com", + }); + const rows = buildVaultPopoverRows( + [v], + "v", + [makeHubVault("other", "https://vault.example.com/vault/other")], + null, + ); + expect(rows.every((r) => r.kind === "connected")).toBe(true); + }); + + it("sorts Connected rows by display label", () => { + const a = makeVault({ + id: "a", + url: "http://localhost:1939/vault/charlie", + name: "charlie", + }); + const b = makeVault({ + id: "b", + url: "http://localhost:1939/vault/alpha", + name: "alpha", + }); + const rows = buildVaultPopoverRows([a, b], "a", [], "http://localhost:1939"); + expect(rows.map((r) => r.kind === "connected" && r.label)).toEqual(["alpha", "charlie"]); + }); + + it("matches connected vs hub URLs after trailing-slash normalization", () => { + const v = makeVault({ + id: "v", + url: "http://localhost:1939/vault/default", + name: "default", + }); + const rows = buildVaultPopoverRows( + [v], + "v", + [makeHubVault("default", "http://localhost:1939/vault/default/")], + "http://localhost:1939", + ); + expect(rows.filter((r) => r.kind === "available")).toHaveLength(0); + }); +}); + +describe("VaultPopover (component)", () => { + beforeEach(() => { + useVaultStore.setState({ vaults: {}, activeVaultId: null }); + vi.restoreAllMocks(); + // Default: hub returns nothing + global.fetch = vi.fn( + async () => + ({ + ok: true, + status: 200, + json: async () => ({ vaults: [], services: [] }), + }) as Response, + ) as unknown as typeof fetch; + }); + + afterEach(() => { + useVaultStore.setState({ vaults: {}, activeVaultId: null }); + vi.restoreAllMocks(); + }); + + function renderPopover() { + return render( + + + , + ); + } + + it("renders the active vault's label on the trigger", () => { + useVaultStore.setState({ + vaults: { + v: makeVault({ id: "v", url: "http://localhost:1939/vault/default", name: "default" }), + }, + activeVaultId: "v", + }); + renderPopover(); + expect(screen.getByRole("button", { name: /active vault: default/i })).toBeInTheDocument(); + }); + + it("opens and closes on trigger click + closes on outside click", async () => { + useVaultStore.setState({ + vaults: { + v: makeVault({ id: "v", url: "http://localhost:1939/vault/default", name: "default" }), + }, + activeVaultId: "v", + }); + renderPopover(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /active vault/i })); + expect(await screen.findByRole("dialog")).toBeInTheDocument(); + fireEvent.mouseDown(document.body); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); + + it("switches the active vault when a Connected row is clicked, then closes", async () => { + useVaultStore.setState({ + vaults: { + a: makeVault({ id: "a", url: "http://localhost:1939/vault/default", name: "default" }), + b: makeVault({ id: "b", url: "http://localhost:1939/vault/techne", name: "techne" }), + }, + activeVaultId: "a", + }); + renderPopover(); + fireEvent.click(screen.getByRole("button", { name: /active vault/i })); + fireEvent.click(await screen.findByRole("button", { name: "techne" })); + expect(useVaultStore.getState().activeVaultId).toBe("b"); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); + + it("renders Available rows when the hub publishes additional vaults", async () => { + useVaultStore.setState({ + vaults: { + v: makeVault({ id: "v", url: "http://localhost:1939/vault/default", name: "default" }), + }, + activeVaultId: "v", + }); + global.fetch = vi.fn( + async () => + ({ + ok: true, + status: 200, + json: async () => ({ + vaults: [ + { name: "default", url: "http://localhost:1939/vault/default", version: "0.1.0" }, + { name: "techne", url: "http://localhost:1939/vault/techne", version: "0.1.0" }, + ], + services: [], + }), + }) as Response, + ) as unknown as typeof fetch; + renderPopover(); + fireEvent.click(screen.getByRole("button", { name: /active vault/i })); + await waitFor(() => expect(screen.getByText("Available from your hub")).toBeInTheDocument()); + expect(screen.getByText("techne")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Connect$/ })).toBeInTheDocument(); + }); + + it("kicks beginOAuth with the vault hint when Connect is clicked", async () => { + useVaultStore.setState({ + vaults: { + v: makeVault({ id: "v", url: "http://localhost:1939/vault/default", name: "default" }), + }, + activeVaultId: "v", + }); + global.fetch = vi.fn( + async () => + ({ + ok: true, + status: 200, + json: async () => ({ + vaults: [ + { name: "default", url: "http://localhost:1939/vault/default", version: "0.1.0" }, + { name: "techne", url: "http://localhost:1939/vault/techne", version: "0.1.0" }, + ], + services: [], + }), + }) as Response, + ) as unknown as typeof fetch; + const beginSpy = vi.spyOn(oauthModule, "beginOAuth").mockResolvedValue({ + authorizeUrl: "http://localhost:1939/oauth/authorize?test", + pending: { + issuerUrl: "http://localhost:1939", + issuer: "http://localhost:1939", + tokenEndpoint: "http://localhost:1939/oauth/token", + clientId: "x", + codeVerifier: "v", + state: "s", + redirectUri: "r", + scope: "vault:read", + startedAt: "now", + }, + }); + const assignSpy = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, assign: assignSpy }, + }); + + renderPopover(); + fireEvent.click(screen.getByRole("button", { name: /active vault/i })); + await waitFor(() => expect(screen.getByText("techne")).toBeInTheDocument()); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^Connect$/ })); + }); + + await waitFor(() => expect(beginSpy).toHaveBeenCalled()); + expect(beginSpy.mock.calls[0]?.[0]).toBe("http://localhost:1939"); + expect(beginSpy.mock.calls[0]?.[3]).toEqual({ params: { vault: "techne" } }); + await waitFor(() => + expect(assignSpy).toHaveBeenCalledWith("http://localhost:1939/oauth/authorize?test"), + ); + }); +}); diff --git a/src/components/VaultPopover.tsx b/src/components/VaultPopover.tsx new file mode 100644 index 0000000..ab32cad --- /dev/null +++ b/src/components/VaultPopover.tsx @@ -0,0 +1,317 @@ +import { + type HubVaultEntry, + type VaultRecord, + beginOAuth, + fetchHubVaults, + hubOriginForVault, + normalizeVaultUrl, + useVaultStore, +} from "@/lib/vault"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router"; + +// One row per vault — connected (clickable to switch) or available (Connect +// button). Diffing rule: a hub entry whose URL matches a connected vault's URL +// belongs in Connected; the rest go to Available. +interface ConnectedRow { + kind: "connected"; + id: string; + label: string; + isActive: boolean; + hubKnown: boolean; + vault: VaultRecord; +} + +interface AvailableRow { + kind: "available"; + name: string; + url: string; + hubOrigin: string; +} + +export type VaultPopoverRow = ConnectedRow | AvailableRow; + +function vaultDisplayLabel(v: VaultRecord): string { + if (v.name) return v.name; + try { + return new URL(v.url).host; + } catch { + return v.url; + } +} + +function normalizedUrlForMatch(raw: string): string { + try { + return normalizeVaultUrl(raw); + } catch { + return raw.replace(/\/$/, ""); + } +} + +/** + * Compute the rows the popover should render given the locally-connected + * vaults and the hub's published list. Pure function for easy testing. + * + * Matching is URL-based (normalized) — a hub entry with the same vault URL + * as a connected record collapses into the Connected row (marked + * `hubKnown: true`). Connected vaults whose URL isn't in the hub list still + * render under Connected (with `hubKnown: false` so the UI can hint + * "Hub doesn't know about this one" later if we want). + */ +export function buildVaultPopoverRows( + connected: VaultRecord[], + activeId: string | null, + hubVaults: HubVaultEntry[], + hubOriginForAvailable: string | null, +): VaultPopoverRow[] { + const hubUrlSet = new Set(hubVaults.map((v) => normalizedUrlForMatch(v.url))); + const connectedUrlSet = new Set(connected.map((v) => normalizedUrlForMatch(v.url))); + + const connectedRows: ConnectedRow[] = [...connected] + .sort((a, b) => vaultDisplayLabel(a).localeCompare(vaultDisplayLabel(b))) + .map((v) => ({ + kind: "connected" as const, + id: v.id, + label: vaultDisplayLabel(v), + isActive: v.id === activeId, + hubKnown: hubUrlSet.has(normalizedUrlForMatch(v.url)), + vault: v, + })); + + const availableRows: AvailableRow[] = + hubOriginForAvailable === null + ? [] + : hubVaults + .filter((hv) => !connectedUrlSet.has(normalizedUrlForMatch(hv.url))) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((hv) => ({ + kind: "available" as const, + name: hv.name, + url: hv.url, + hubOrigin: hubOriginForAvailable, + })); + + return [...connectedRows, ...availableRows]; +} + +interface VaultPopoverProps { + /** + * Render variant. `header` is the desktop dropdown anchored to the trigger; + * `inline` is the mobile menu where the popover sits in flow (no float, no + * absolute positioning). + */ + variant?: "header" | "inline"; +} + +export function VaultPopover({ variant = "header" }: VaultPopoverProps) { + const navigate = useNavigate(); + const vaults = useVaultStore((s) => s.vaults); + const activeVaultId = useVaultStore((s) => s.activeVaultId); + const setActiveVault = useVaultStore((s) => s.setActiveVault); + const activeVault = activeVaultId ? (vaults[activeVaultId] ?? null) : null; + const [open, setOpen] = useState(false); + const [hubVaults, setHubVaults] = useState(null); + const [connecting, setConnecting] = useState(null); + const [connectError, setConnectError] = useState(null); + const rootRef = useRef(null); + + const hubOrigin = useMemo( + () => (activeVault ? hubOriginForVault(activeVault) : null), + [activeVault], + ); + + // Outside-click and Escape close the popover. Same shape as + // SyncStatusIndicator — mousedown so a click that selects something inside + // doesn't fire before the click handler. + useEffect(() => { + if (!open) return; + const onDocClick = (e: MouseEvent) => { + if (!rootRef.current) return; + if (rootRef.current.contains(e.target as Node)) return; + setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + // Fetch the hub's vault list when the popover opens. Refetched per-open so + // newly-added vaults show up without a page reload; cheap (one same-origin + // GET to a static-ish JSON). + useEffect(() => { + if (!open || !hubOrigin) { + return; + } + const ctrl = new AbortController(); + fetchHubVaults(hubOrigin, fetch.bind(globalThis), ctrl.signal).then((result) => { + if (ctrl.signal.aborted) return; + setHubVaults(result); + }); + return () => ctrl.abort(); + }, [open, hubOrigin]); + + const rows = useMemo( + () => buildVaultPopoverRows(Object.values(vaults), activeVaultId, hubVaults ?? [], hubOrigin), + [vaults, activeVaultId, hubVaults, hubOrigin], + ); + const connectedRows = rows.filter((r): r is ConnectedRow => r.kind === "connected"); + const availableRows = rows.filter((r): r is AvailableRow => r.kind === "available"); + + const triggerLabel = activeVault ? vaultDisplayLabel(activeVault) : "Choose vault"; + + const onSwitch = useCallback( + (id: string) => { + setActiveVault(id); + setOpen(false); + }, + [setActiveVault], + ); + + const onConnect = useCallback(async (row: AvailableRow) => { + setConnecting(row.name); + setConnectError(null); + try { + // Path A (design doc §2): pass `vault=` as a hint. Pre-#240 hubs + // ignore it and the consent screen renders the picker as today; future + // hubs can pre-select on the consent screen with no Notes change. + const { authorizeUrl } = await beginOAuth(row.hubOrigin, undefined, undefined, { + params: { vault: row.name }, + }); + window.location.assign(authorizeUrl); + } catch (err) { + setConnecting(null); + setConnectError((err as Error).message); + } + }, []); + + const onManage = useCallback(() => { + setOpen(false); + navigate("/vaults"); + }, [navigate]); + + const panel = ( + // biome-ignore lint/a11y/useSemanticElements: a native requires imperative show()/showModal() calls; this is a popover, not a modal. +
+ {connectedRows.length > 0 ? ( +
+
+ Connected +
+
    + {connectedRows.map((row) => ( +
  • + +
  • + ))} +
+
+ ) : null} + + {availableRows.length > 0 ? ( +
+
+ Available from your hub +
+
    + {availableRows.map((row) => ( +
  • +
    + + + {row.name} + + +
    +
  • + ))} +
+
+ ) : null} + + {connectError ? ( +
{connectError}
+ ) : null} + +
+ + {hubOrigin && hubVaults === null && open ? ( + Loading hub vaults… + ) : null} +
+
+ ); + + return ( +
+ + + {open ? panel : null} +
+ ); +} diff --git a/src/lib/vault/cross-tab-sync.ts b/src/lib/vault/cross-tab-sync.ts index 780a84c..2186307 100644 --- a/src/lib/vault/cross-tab-sync.ts +++ b/src/lib/vault/cross-tab-sync.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { AUTH_HALT_KEY_PREFIX, useAuthHaltStore } from "./auth-halt-store"; -import { ACTIVE_KEY, loadActiveVaultId, loadVaults, VAULTS_KEY } from "./storage"; +import { ACTIVE_KEY, VAULTS_KEY, loadActiveVaultId, loadVaults } from "./storage"; import { useVaultStore } from "./store"; // Storage events fire across same-origin tabs but never within the tab that diff --git a/src/lib/vault/hub-discovery.test.ts b/src/lib/vault/hub-discovery.test.ts new file mode 100644 index 0000000..25722c4 --- /dev/null +++ b/src/lib/vault/hub-discovery.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchHubVaults, hubOriginForVault } from "./hub-discovery"; +import type { VaultRecord } from "./types"; + +function makeVault(partial: Partial & Pick): VaultRecord { + return { + id: "v", + url: "http://localhost:1939/vault/default", + name: "default", + clientId: "client", + scope: "vault:read", + addedAt: "2026-05-12T00:00:00.000Z", + lastUsedAt: "2026-05-12T00:00:00.000Z", + ...partial, + }; +} + +function mockFetch(response: { ok?: boolean; status?: number; json?: unknown; text?: string }) { + return vi.fn(async () => { + return { + ok: response.ok ?? true, + status: response.status ?? 200, + json: async () => response.json, + text: async () => response.text ?? "", + } as Response; + }); +} + +function mockFetchThrows(err: Error) { + return vi.fn(async () => { + throw err; + }); +} + +describe("hubOriginForVault", () => { + it("returns the origin of the issuer URL", () => { + expect(hubOriginForVault(makeVault({ issuer: "http://localhost:1939" }))).toBe( + "http://localhost:1939", + ); + }); + + it("strips path + query from a path-bearing issuer (standalone vault case)", () => { + expect(hubOriginForVault(makeVault({ issuer: "https://hub.example.com/some/path?q=1" }))).toBe( + "https://hub.example.com", + ); + }); + + it("returns null for an unparseable issuer", () => { + expect(hubOriginForVault(makeVault({ issuer: "not a url" }))).toBe(null); + }); +}); + +describe("fetchHubVaults", () => { + it("returns parsed vaults on a 200", async () => { + const fetchImpl = mockFetch({ + json: { + vaults: [ + { name: "default", url: "http://localhost:1939/vault/default", version: "0.1.0" }, + { name: "techne", url: "http://localhost:1939/vault/techne", version: "0.1.0" }, + ], + services: [], + }, + }); + const result = await fetchHubVaults("http://localhost:1939", fetchImpl); + expect(result).toEqual([ + { name: "default", url: "http://localhost:1939/vault/default", version: "0.1.0" }, + { name: "techne", url: "http://localhost:1939/vault/techne", version: "0.1.0" }, + ]); + expect(fetchImpl.mock.calls[0]?.[0]).toBe("http://localhost:1939/.well-known/parachute.json"); + }); + + it("passes through optional managementUrl", async () => { + const fetchImpl = mockFetch({ + json: { + vaults: [ + { + name: "default", + url: "http://localhost:1939/vault/default", + version: "0.1.0", + managementUrl: "/vault/default/admin", + }, + ], + }, + }); + const result = await fetchHubVaults("http://localhost:1939", fetchImpl); + expect(result?.[0]?.managementUrl).toBe("/vault/default/admin"); + }); + + it("returns an empty array when the hub publishes no vaults", async () => { + const fetchImpl = mockFetch({ json: { vaults: [], services: [] } }); + const result = await fetchHubVaults("http://localhost:1939", fetchImpl); + expect(result).toEqual([]); + }); + + it("returns null on a non-2xx response", async () => { + const fetchImpl = mockFetch({ ok: false, status: 404 }); + const result = await fetchHubVaults("http://localhost:1939", fetchImpl); + expect(result).toBe(null); + }); + + it("returns null when fetch throws (network error)", async () => { + const fetchImpl = mockFetchThrows(new Error("network down")); + const result = await fetchHubVaults("http://localhost:1939", fetchImpl); + expect(result).toBe(null); + }); + + it("returns null on malformed JSON", async () => { + const fetchImpl = vi.fn(async () => { + return { + ok: true, + status: 200, + json: async () => { + throw new Error("not JSON"); + }, + } as unknown as Response; + }); + const result = await fetchHubVaults("http://localhost:1939", fetchImpl); + expect(result).toBe(null); + }); + + it("returns null when the response body isn't an object", async () => { + const fetchImpl = mockFetch({ json: "not an object" }); + const result = await fetchHubVaults("http://localhost:1939", fetchImpl); + expect(result).toBe(null); + }); + + it("returns null when `vaults` is missing or not an array", async () => { + const fetchImpl = mockFetch({ json: { services: [] } }); + const result = await fetchHubVaults("http://localhost:1939", fetchImpl); + expect(result).toBe(null); + }); + + it("filters out malformed vault entries (missing name/url/version)", async () => { + const fetchImpl = mockFetch({ + json: { + vaults: [ + { name: "good", url: "http://localhost:1939/vault/good", version: "0.1.0" }, + { name: "missing-url", version: "0.1.0" }, + { url: "http://localhost:1939/vault/no-name", version: "0.1.0" }, + null, + "string", + ], + }, + }); + const result = await fetchHubVaults("http://localhost:1939", fetchImpl); + expect(result).toEqual([ + { name: "good", url: "http://localhost:1939/vault/good", version: "0.1.0" }, + ]); + }); + + it("strips trailing slash on the hub origin before composing the URL", async () => { + const fetchImpl = mockFetch({ json: { vaults: [] } }); + await fetchHubVaults("http://localhost:1939/", fetchImpl); + expect(fetchImpl.mock.calls[0]?.[0]).toBe("http://localhost:1939/.well-known/parachute.json"); + }); +}); diff --git a/src/lib/vault/hub-discovery.ts b/src/lib/vault/hub-discovery.ts new file mode 100644 index 0000000..7096a96 --- /dev/null +++ b/src/lib/vault/hub-discovery.ts @@ -0,0 +1,75 @@ +import type { VaultRecord } from "./types"; + +/** + * Vault entries the hub publishes at `/.well-known/parachute.json`. Mirrors + * the `WellKnownVaultEntry` shape in `parachute-hub/src/well-known.ts` + * (intentionally re-declared here so Notes doesn't pick up a transitive hub + * dependency). + */ +export interface HubVaultEntry { + name: string; + url: string; + version: string; + managementUrl?: string; +} + +/** + * Derive the OAuth/discovery origin Notes should query for a stored vault. + * Under hub-as-issuer (the standard install) `VaultRecord.issuer` is the + * hub origin itself — captured at OAuth time in OAuthCallback.tsx. Under a + * standalone vault `issuer` equals the vault URL; the well-known fetch will + * fail or return no peer vaults, which is the right answer in that case. + * + * Returning the origin (not the full issuer URL) lets a hub fronted at a + * sub-path still answer `/.well-known/parachute.json` cleanly — + * matches the path the hub's own admin SPA uses. + */ +export function hubOriginForVault(vault: Pick): string | null { + try { + return new URL(vault.issuer).origin; + } catch { + return null; + } +} + +function isHubVaultEntry(value: unknown): value is HubVaultEntry { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return typeof v.name === "string" && typeof v.url === "string" && typeof v.version === "string"; +} + +/** + * Fetch the hub's vault list. Same-origin in standard installs, CORS-open + * cross-origin. Returns the vault array on success, `null` on any failure + * (network, non-2xx, malformed JSON) — callers treat "no list" as + * "popover doesn't render the Available section". + * + * `hubOrigin` is the bare origin (`https://hub.example`); the well-known + * path is appended here so callers don't have to know the URL shape. + */ +export async function fetchHubVaults( + hubOrigin: string, + fetchImpl: typeof fetch = fetch.bind(globalThis), + signal?: AbortSignal, +): Promise { + const url = `${hubOrigin.replace(/\/$/, "")}/.well-known/parachute.json`; + let res: Response; + try { + res = await fetchImpl(url, { headers: { Accept: "application/json" }, signal }); + } catch { + return null; + } + if (!res.ok) return null; + + let parsed: unknown; + try { + parsed = await res.json(); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const vaults = (parsed as Record).vaults; + if (!Array.isArray(vaults)) return null; + + return vaults.filter(isHubVaultEntry); +} diff --git a/src/lib/vault/index.ts b/src/lib/vault/index.ts index 1701476..20c384f 100644 --- a/src/lib/vault/index.ts +++ b/src/lib/vault/index.ts @@ -3,6 +3,7 @@ export * from "./client"; export * from "./cross-tab-sync"; export * from "./discovery"; export * from "./graph"; +export * from "./hub-discovery"; export * from "./neighborhood"; export * from "./note-query"; export * from "./oauth"; diff --git a/src/lib/vault/oauth.test.ts b/src/lib/vault/oauth.test.ts index eb273c6..a46c4bf 100644 --- a/src/lib/vault/oauth.test.ts +++ b/src/lib/vault/oauth.test.ts @@ -99,6 +99,29 @@ describe("beginOAuth", () => { expect(pending.clientId).toBe("client-123"); }); + it("appends caller-supplied `params` to the authorize URL", async () => { + const fetchImpl = mockFetch([{ json: validMetadata }, { json: clientReg }]); + const { authorizeUrl } = await beginOAuth("http://localhost:1940", "vault:read", fetchImpl, { + params: { vault: "techne" }, + }); + const url = new URL(authorizeUrl); + expect(url.searchParams.get("vault")).toBe("techne"); + // Standard OAuth/PKCE params still present and unmodified. + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + }); + + it("never lets `params` overwrite a standard OAuth/PKCE param", async () => { + const fetchImpl = mockFetch([{ json: validMetadata }, { json: clientReg }]); + const { authorizeUrl } = await beginOAuth("http://localhost:1940", "vault:read", fetchImpl, { + params: { response_type: "token", scope: "evil:scope", vault: "techne" }, + }); + const url = new URL(authorizeUrl); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("scope")).toBe("vault:read"); + expect(url.searchParams.get("vault")).toBe("techne"); + }); + it("re-registers when the redirect URI no longer matches the cache", async () => { const first = mockFetch([{ json: validMetadata }, { json: clientReg }]); await beginOAuth("http://localhost:1940", "vault:read", first); diff --git a/src/lib/vault/oauth.ts b/src/lib/vault/oauth.ts index 2122f14..9cb203b 100644 --- a/src/lib/vault/oauth.ts +++ b/src/lib/vault/oauth.ts @@ -31,6 +31,18 @@ export function redirectUriForOrigin(origin: string = window.location.origin): s return `${origin.replace(/\/$/, "")}${basePathPrefix()}${REDIRECT_PATH}`; } +export interface BeginOAuthOptions { + /** + * Extra query params appended to the authorize URL after the standard + * OAuth + PKCE params. Used for hints the hub may consume (e.g. a + * `vault=` pre-selection hint from the Notes vault popover — + * design doc 2026-05-12-notes-ui-audit §2). Unknown params are + * harmless: a hub that doesn't recognize them ignores them and the + * consent screen renders as today. + */ + params?: Record; +} + /** * Begin the OAuth 2.1 + PKCE flow against an issuer URL. * @@ -44,6 +56,7 @@ export async function beginOAuth( issuerInput: string, scope: TokenScope = DEFAULT_SCOPE, fetchImpl: typeof fetch = fetch.bind(globalThis), + options: BeginOAuthOptions = {}, ): Promise<{ authorizeUrl: string; pending: PendingOAuthState }> { const issuerUrl = normalizeVaultUrl(issuerInput); const redirectUri = redirectUriForOrigin(); @@ -88,6 +101,16 @@ export async function beginOAuth( authorizeUrl.searchParams.set("code_challenge_method", "S256"); authorizeUrl.searchParams.set("state", state); authorizeUrl.searchParams.set("scope", scope); + // Appended last so caller-supplied params never overwrite the OAuth/PKCE + // params above. A caller that passes `code_challenge` will see it ignored + // — by design. + if (options.params) { + for (const [key, value] of Object.entries(options.params)) { + if (!authorizeUrl.searchParams.has(key)) { + authorizeUrl.searchParams.set(key, value); + } + } + } return { authorizeUrl: authorizeUrl.toString(), pending }; } diff --git a/src/lib/vault/queries.ts b/src/lib/vault/queries.ts index e39182e..53c5d1a 100644 --- a/src/lib/vault/queries.ts +++ b/src/lib/vault/queries.ts @@ -4,6 +4,7 @@ import { enqueue } from "@/lib/sync/queue"; import { useSync } from "@/providers/SyncProvider"; import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo } from "react"; +import { useAuthHaltStore } from "./auth-halt-store"; import { type CreateNotePayload, type StorageUploadResult, @@ -11,7 +12,6 @@ import { type UploadProgress, VaultClient, } from "./client"; -import { useAuthHaltStore } from "./auth-halt-store"; import { type NoteQueryState, buildNoteQueryParams } from "./note-query"; import { forceRefresh } from "./refresh"; import { loadToken } from "./storage";