fix(frontend): keep private hub project names out of telemetry

An instance pointed at its own hub imports its own projects, and the slug
naming one is the customer's content — `template_import` was recording it
verbatim, which the disclosure ("the name of any public hub project") does
not cover and `hub_script` already avoids by collapsing a private script to
`private`.

`hubProjectUsageKey` gives projects the same treatment, deciding by the
configured hub's host so a port, a scheme's case or a trailing slash cannot
turn a private hub into a public one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG
This commit is contained in:
Guilhem Lemouel
2026-09-08 12:41:34 +02:00
co-authored by Claude Opus 5
parent 4ca1970ab1
commit 36352c482a
3 changed files with 83 additions and 5 deletions
@@ -15,7 +15,7 @@
import { useSetupStep } from '$lib/importWizard/setupStep.svelte'
import type { ImportPlan } from '$lib/importWizard/plan'
import { workspaceStore } from '$lib/stores'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import { hubProjectUsageKey, logFeatureUsage } from '$lib/utils/featureUsage'
import { sendUserToast } from '$lib/toast'
/**
@@ -225,7 +225,7 @@
function finish(setupOutcome: SetupOutcome, outstanding = 1) {
// On the way out rather than on the pick: what is worth counting is an import that
// landed, not a dialog that was opened and abandoned.
if (slug) logFeatureUsage('home', 'template_import', { key: slug })
if (slug) logFeatureUsage('home', 'template_import', { key: hubProjectUsageKey(slug) })
logFeatureUsage('home', 'template_setup', { key: setupKey(setupOutcome, outstanding) })
finishing = true
// Through the same deferred reload every closing uses. `done` survives a retry, so
+51 -1
View File
@@ -1,10 +1,29 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('$lib/gen', () => ({ OpenAPI: { BASE: '/api' } }))
vi.mock('$lib/stores', () => ({ workspaceStore: { subscribe: () => () => {} } }))
// A store `get()` can read, so a test can say which hub the instance points at.
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 () => {}
}
}
}
})
vi.mock('$lib/stores', () => ({
workspaceStore: { subscribe: () => () => {} },
hubBaseUrlStore: hubBaseUrl.store
}))
import {
createFeatureUsageBuffer,
hubProjectUsageKey,
hubScriptUsageKey,
type FeatureUsageEventPayload
} from './featureUsage'
@@ -107,3 +126,34 @@ describe('hubScriptUsageKey', () => {
).toBe('acme/list_a_user_s_items_sorted')
})
})
describe('hubProjectUsageKey', () => {
it('reports the slug for every spelling of the public hub', () => {
for (const hub of [
'https://hub.windmill.dev',
'http://hub.windmill.dev/',
'HTTPS://hub.windmill.dev',
'https://HUB.WINDMILL.DEV',
'https://hub.windmill.dev:443',
' https://hub.windmill.dev '
]) {
hubBaseUrl.set(hub)
expect(hubProjectUsageKey('stripe-invoices'), hub).toBe('stripe-invoices')
}
})
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.
for (const hub of [
'https://hub.internal.example',
'https://hub.windmill.dev.evil.example',
'https://windmill.dev',
'hub.windmill.dev',
'not a url'
]) {
hubBaseUrl.set(hub)
expect(hubProjectUsageKey('acme-payroll'), hub).toBe('private')
}
})
})
+30 -2
View File
@@ -1,7 +1,7 @@
import { get } from 'svelte/store'
import { OpenAPI } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { PRIVATE_HUB_MIN_VERSION } from '$lib/hub'
import { 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
// backend `feature_usage` accumulator. Only aggregated counts ever leave the
@@ -187,3 +187,31 @@ export function hubScriptUsageKey(script: {
if (!app) return PRIVATE_HUB_KEY
return (summary ? `${app}/${summary}` : app).slice(0, 100)
}
/**
* A hub project's slug is only reportable when it names something on the public hub. An
* instance pointed at its own hub imports its own projects, whose names are the customer's
* content — the same reason `hubScriptUsageKey` collapses a private script to `private`,
* 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.
*/
export function hubProjectUsageKey(slug: string): string {
return isPublicHub(get(hubBaseUrlStore)) ? slug : PRIVATE_HUB_KEY
}
function isPublicHub(hub: string): boolean {
const host = (url: string): string | undefined => {
try {
const parsed = new URL(url.trim())
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
? parsed.hostname.replace(/\.$/, '').toLowerCase()
: undefined
} catch {
return undefined
}
}
const configured = host(hub)
return configured !== undefined && configured === host(DEFAULT_HUB_BASE_URL)
}