diff --git a/explorer/.cargo/audit.toml b/explorer/.cargo/audit.toml index 152ac5655..c031cf3ab 100644 --- a/explorer/.cargo/audit.toml +++ b/explorer/.cargo/audit.toml @@ -1,6 +1,6 @@ [advisories] ignore = [ - "RUSTSEC-2023-0071", # https://radicle.network/nodes/iris.radicle.network/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/issues/bd8af7a5e3fd02c0963d7550a5cc2cab00950775 + "RUSTSEC-2023-0071", # https://radicle.network/nodes/oak.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/issues/bd8af7a5e3fd02c0963d7550a5cc2cab00950775 # atomic-polyfill is unmaintained. It reaches the lockfile through # radicle-artifact -> postcard -> heapless 0.7, which only depends on it for # AVR, RISC-V, thumbv6m and Xtensa bare-metal targets, so it is never diff --git a/explorer/README.md b/explorer/README.md index 9e8b8cb02..fe37130ed 100644 --- a/explorer/README.md +++ b/explorer/README.md @@ -103,7 +103,8 @@ the configuration from a pre-defined location (`/config.json`) on the server. The config file must be served as static content and must be publicly accessible. The structure of the runtime `config.json` must match the shape of -the application's base configuration defined in `config/default.json`. +the application's base configuration defined in `config/default.json`. Arrays +replace the built-in ones, and `preferredSeeds` must list at least one seed. ### Homepage @@ -222,13 +223,13 @@ The UI is distributed under the terms of GPLv3. See [LICENSE][lic] for details. [con]: ../CONTRIBUTING.md [def]: ./config/default.json [iss]: - https://radicle.network/nodes/iris.radicle.network/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5/issues + https://radicle.network/nodes/oak.radicle.garden/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5/issues [lic]: ./LICENSE [nco]: https://github.com/node-config/node-config/wiki/Configuration-Files [nod]: https://nodejs.org [npm]: https://www.npmjs.com [pat]: - https://radicle.network/nodes/iris.radicle.network/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5/patches + https://radicle.network/nodes/oak.radicle.garden/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5/patches [rad]: https://radicle.dev [try]: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas [zul]: https://radicle.zulipchat.com/#narrow/stream/369278-web diff --git a/explorer/config/default.json b/explorer/config/default.json index 3d22faafa..356873bba 100644 --- a/explorer/config/default.json +++ b/explorer/config/default.json @@ -12,12 +12,7 @@ "supportWebsite": "https://radicle.zulipchat.com", "preferredSeeds": [ { - "hostname": "rosa.radicle.network", - "port": 443, - "scheme": "https" - }, - { - "hostname": "iris.radicle.network", + "hostname": "oak.radicle.garden", "port": 443, "scheme": "https" } diff --git a/explorer/src/views/nodes/SeedSelector.ts b/explorer/src/views/nodes/SeedSelector.ts index 5f94c2c31..f0fd28740 100644 --- a/explorer/src/views/nodes/SeedSelector.ts +++ b/explorer/src/views/nodes/SeedSelector.ts @@ -13,12 +13,6 @@ const seedSchema = z.object({ scheme: z.string(), }); -const HARDCODED_FALLBACK: BaseUrl = { - scheme: "https", - hostname: "iris.radicle.network", - port: 443, -}; - // The seed the user explicitly picked, or `undefined` when they never did. // Read `searchSeed` instead of this unless you need to distinguish an explicit // pick from a resolved fallback. @@ -49,6 +43,19 @@ export const bookmarkedSeeds = storedWritable( !window.localStorage, ); +function dropRetiredDefaultSeed() { + const pinned = get(explicitSeed); + if ( + pinned && + !config.preferredSeeds.some(s => isEqual(s, pinned)) && + !get(bookmarkedSeeds).some(s => isEqual(s, pinned)) + ) { + explicitSeed.clear(); + } +} + +dropRetiredDefaultSeed(); + // Session-scoped set of seeds the app tried and failed to reach. Used to // surface a warning icon in the seed picker. export const failedSeeds = writable([]); @@ -67,10 +74,8 @@ export function removeBookmark(seed: BaseUrl) { bookmarkedSeeds.update(previous => previous.filter(x => !isEqual(x, seed))); if (isEqual(get(explicitSeed), seed)) { // Clear the explicit pick so `determineSeed()` falls back through its - // normal resolution path (bucket-pick a preferred seed, or the hardcoded - // fallback). Setting it to the first remaining bookmark or preferred seed - // would orphan the store when neither exists, and would also bypass the - // bucket-based load balancing. + // normal resolution path (bucket-pick a preferred seed). Setting it to the + // first remaining bookmark would bypass the bucket-based load balancing. explicitSeed.set(undefined); } } @@ -94,18 +99,17 @@ function getOrCreateBucketRandom(): number { // bucket number against the current preferred-seeds list. This keeps a // user on the same seed across visits when the list is unchanged, but // probabilistically rebalances them onto new seeds when the list grows. -// 3. Fallback to a hardcoded seed if nothing is configured. +// The config must list at least one preferred seed. export function determineSeed(): BaseUrl { const explicit = get(explicitSeed); if (explicit) return explicit; const pool = config.preferredSeeds; - if (pool.length > 0) { - const bucket = getOrCreateBucketRandom(); - return pool[bucket % pool.length]; + if (pool.length === 0) { + throw new Error("Config must list at least one seed in `preferredSeeds`"); } - - return HARDCODED_FALLBACK; + const bucket = getOrCreateBucketRandom(); + return pool[bucket % pool.length]; } // The seed that searches and the explore listing run against. Derived so diff --git a/explorer/src/views/repos/Release/DownloadButton.svelte b/explorer/src/views/repos/Release/DownloadButton.svelte index 3b7780eaf..47c9287e2 100644 --- a/explorer/src/views/repos/Release/DownloadButton.svelte +++ b/explorer/src/views/repos/Release/DownloadButton.svelte @@ -135,7 +135,7 @@ {#if activeTab === "cli"} diff --git a/explorer/tests/e2e/explore.spec.ts b/explorer/tests/e2e/explore.spec.ts index 988a3bac7..dbcc83e3d 100644 --- a/explorer/tests/e2e/explore.spec.ts +++ b/explorer/tests/e2e/explore.spec.ts @@ -35,6 +35,7 @@ test("explore falls back to a healthy seed when the primary is unreachable", asy // Make an unreachable seed the primary, then fail its requests instantly. await page.addInitScript(seed => { window.localStorage.setItem("selectedSeed", JSON.stringify(seed)); + window.localStorage.setItem("bookmarkedSeeds", JSON.stringify([seed])); }, unreachableSeed); await page.route( url => url.hostname === unreachableSeed.hostname, diff --git a/explorer/tests/unit/seedSelector.test.ts b/explorer/tests/unit/seedSelector.test.ts new file mode 100644 index 000000000..1854f5450 --- /dev/null +++ b/explorer/tests/unit/seedSelector.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { get } from "svelte/store"; + +const { oak, iris, custom } = vi.hoisted(() => ({ + oak: { hostname: "oak.radicle.garden", port: 443, scheme: "https" }, + iris: { hostname: "iris.radicle.network", port: 443, scheme: "https" }, + custom: { hostname: "seed.example.com", port: 443, scheme: "https" }, +})); + +vi.mock("@app/lib/config", () => ({ + default: { preferredSeeds: [oak] }, +})); + +async function loadSeedSelector() { + vi.resetModules(); + return import("@app/views/nodes/SeedSelector"); +} + +describe("dropping a retired default seed on load", () => { + beforeEach(() => { + localStorage.clear(); + }); + + test("clears a picked seed that is no longer a default", async () => { + localStorage.setItem("selectedSeed", JSON.stringify(iris)); + + const { explicitSeed, determineSeed } = await loadSeedSelector(); + + expect(get(explicitSeed)).toBeUndefined(); + expect(localStorage.getItem("selectedSeed")).toBeNull(); + expect(determineSeed()).toEqual(oak); + }); + + test("keeps a picked seed that is still a default", async () => { + localStorage.setItem("selectedSeed", JSON.stringify(oak)); + + const { explicitSeed } = await loadSeedSelector(); + + expect(get(explicitSeed)).toEqual(oak); + }); + + test("keeps a picked seed that is bookmarked", async () => { + localStorage.setItem("selectedSeed", JSON.stringify(custom)); + localStorage.setItem("bookmarkedSeeds", JSON.stringify([custom])); + + const { explicitSeed, determineSeed } = await loadSeedSelector(); + + expect(get(explicitSeed)).toEqual(custom); + expect(determineSeed()).toEqual(custom); + }); + + test("keeps a bookmarked former default", async () => { + localStorage.setItem("selectedSeed", JSON.stringify(iris)); + localStorage.setItem("bookmarkedSeeds", JSON.stringify([iris])); + + const { explicitSeed } = await loadSeedSelector(); + + expect(get(explicitSeed)).toEqual(iris); + }); + + test("leaves users without a picked seed on the default", async () => { + const { explicitSeed, determineSeed } = await loadSeedSelector(); + + expect(get(explicitSeed)).toBeUndefined(); + expect(determineSeed()).toEqual(oak); + }); +});