diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 2e8658bb4b..229ecff7ad 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -118,6 +118,10 @@ export const superadmin = writable(undefined) export const devopsRole = writable(undefined) export const lspTokenStore = writable(undefined) export const hubBaseUrlStore = writable(DEFAULT_HUB_BASE_URL) +// Whether the store above is the instance's answer or still the default it was seeded with. +// It reads as the public hub either way, which is fine for a link and wrong for anything +// deciding what may be reported about a hub — those must treat unknown as private. +export const hubBaseUrlKnown = writable(false) export const wsBaseUrlStore = writable(undefined) export const disableHubStore = writable(false) // What a superadmin standing in a workspace they are not a member of needs to see it as a diff --git a/frontend/src/lib/utils/featureUsage.test.ts b/frontend/src/lib/utils/featureUsage.test.ts index e0a4c868e7..70dd2e9de9 100644 --- a/frontend/src/lib/utils/featureUsage.test.ts +++ b/frontend/src/lib/utils/featureUsage.test.ts @@ -2,23 +2,28 @@ import { describe, expect, it, vi } from 'vitest' vi.mock('$lib/gen', () => ({ OpenAPI: { BASE: '/api' } })) -// A store `get()` can read, so a test can say which hub the instance points at. +// Stores `get()` can read, so a test can say which hub the instance points at and whether +// the instance has answered at all. const hubBaseUrl = vi.hoisted(() => { - let value = 'https://hub.windmill.dev' - return { - set: (v: string) => (value = v), - store: { - subscribe: (run: (v: string) => void) => { - run(value) - return () => {} + const readable = (initial: T) => { + let value = initial + return { + set: (v: T) => (value = v), + store: { + subscribe: (run: (v: T) => void) => { + run(value) + return () => {} + } } } } + return { url: readable('https://hub.windmill.dev'), known: readable(true) } }) vi.mock('$lib/stores', () => ({ workspaceStore: { subscribe: () => () => {} }, - hubBaseUrlStore: hubBaseUrl.store + hubBaseUrlStore: hubBaseUrl.url.store, + hubBaseUrlKnown: hubBaseUrl.known.store })) import { @@ -137,11 +142,21 @@ describe('hubProjectUsageKey', () => { 'https://hub.windmill.dev:443', ' https://hub.windmill.dev ' ]) { - hubBaseUrl.set(hub) + hubBaseUrl.url.set(hub) expect(hubProjectUsageKey('stripe-invoices'), hub).toBe('stripe-invoices') } }) + it('answers private until the instance has said which hub it points at', () => { + // The store is seeded with the public hub, so a settings read that failed must not + // read as permission to report the name. + hubBaseUrl.url.set('https://hub.windmill.dev') + hubBaseUrl.known.set(false) + expect(hubProjectUsageKey('acme-payroll')).toBe('private') + hubBaseUrl.known.set(true) + expect(hubProjectUsageKey('acme-payroll')).toBe('acme-payroll') + }) + it("keeps a private hub's project names off the wire", () => { // The slug is the customer's own content on an instance running its own hub, and the // disclosure only claims public project names. @@ -152,7 +167,7 @@ describe('hubProjectUsageKey', () => { 'hub.windmill.dev', 'not a url' ]) { - hubBaseUrl.set(hub) + hubBaseUrl.url.set(hub) expect(hubProjectUsageKey('acme-payroll'), hub).toBe('private') } }) diff --git a/frontend/src/lib/utils/featureUsage.ts b/frontend/src/lib/utils/featureUsage.ts index cd75542681..31b33153cf 100644 --- a/frontend/src/lib/utils/featureUsage.ts +++ b/frontend/src/lib/utils/featureUsage.ts @@ -1,6 +1,6 @@ import { get } from 'svelte/store' import { OpenAPI } from '$lib/gen' -import { hubBaseUrlStore, workspaceStore } from '$lib/stores' +import { hubBaseUrlKnown, hubBaseUrlStore, workspaceStore } from '$lib/stores' import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub' // Anonymous product-usage counters (e.g. AI session activity), batched into the @@ -195,10 +195,13 @@ export function hubScriptUsageKey(script: { * and what the disclosure means by "the name of any public hub project". * * Compared by host, so the port, scheme and trailing slash an operator may have typed do - * not decide it. Anything unparseable answers private. + * not decide it. Anything unparseable, and anything not yet read, answers private. */ export function hubProjectUsageKey(slug: string): string { - return isPublicHub(get(hubBaseUrlStore)) ? slug : PRIVATE_HUB_KEY + // `hubBaseUrlKnown` and not the URL alone: the store is seeded with the public hub, so an + // instance whose setting could not be read would otherwise report its own project names. + if (!get(hubBaseUrlKnown) || !isPublicHub(get(hubBaseUrlStore))) return PRIVATE_HUB_KEY + return slug.slice(0, 100) } function isPublicHub(hub: string): boolean { diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index d5ca0d5167..5aa90b80a2 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -32,6 +32,7 @@ type UserExt, defaultScripts, hubBaseUrlStore, + hubBaseUrlKnown, wsBaseUrlStore, disableHubStore, usedTriggerKinds, @@ -474,10 +475,18 @@ } async function loadHubBaseUrl() { - $hubBaseUrlStore = - ((await SettingService.getGlobal({ key: 'hub_accessible_url' })) as string) || - ((await SettingService.getGlobal({ key: 'hub_base_url' })) as string) || - DEFAULT_HUB_BASE_URL + // A read that throws leaves the store on its seeded default, which names the public hub + // — so the flag, not the value, is what says the instance has answered. An instance that + // simply has no setting still answers: the chain falls through to the default. + try { + $hubBaseUrlStore = + ((await SettingService.getGlobal({ key: 'hub_accessible_url' })) as string) || + ((await SettingService.getGlobal({ key: 'hub_base_url' })) as string) || + DEFAULT_HUB_BASE_URL + $hubBaseUrlKnown = true + } catch (error) { + console.error('Could not read the hub URL:', error) + } } async function loadWsBaseUrl() {