fix: answer the round-33 nits on the hub route and the empty state

- `empty_state_view` is no longer logged for an archived-only workspace, which
  is not the state the counter measures.
- `detail`'s fetcher keeps its last answer in a local instead of reading
  `detail.current`, a self-reference that typed the resource `any`.
- `list_projects`' comment, including its authorization contract, is back on the
  handler rather than on the predicate inserted above it.
- `listHubProjects` documents the 400 an instance with the hub disabled returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9
This commit is contained in:
Guilhem Lemouel
2026-09-08 11:10:11 +02:00
co-authored by Claude Opus 5
parent 1745573ec6
commit d30bb8a82c
4 changed files with 22 additions and 13 deletions
+4 -1
View File
@@ -25624,7 +25624,8 @@ paths:
Forwards to the configured Hub's public project catalogue and returns its
status code and raw response body. Readable by any workspace member: the
listing is not workspace-scoped, and it is proxied only because the Hub's
listing endpoint sends no CORS header.
listing endpoint sends no CORS header. Refused with 400 when the instance
has the Hub disabled, in which case no outbound request is made.
operationId: listHubProjects
tags:
- hubPublish
@@ -25637,6 +25638,8 @@ paths:
text/plain:
schema:
type: string
"400":
description: the Hub is disabled on this instance
/w/{workspace}/hub/project:
get:
+10 -10
View File
@@ -550,16 +550,6 @@ async fn get_project_by_source(ctx: HubPublishCtx) -> Result<impl IntoResponse,
ctx.get("/projects/by_source").await
}
// The hub's project catalogue. Read by any workspace member rather than through
// `HubPublishCtx`, which requires an admin: nothing here is workspace-scoped or
// publishing-related. It exists at all because the hub's listing endpoint sends no
// CORS header, so the browser cannot read it directly the way it reads a single
// project. `accept: application/json` is what makes the hub answer with JSON.
//
// The caller's token is sent only to a hub this instance was pointed at deliberately.
// Every other route here is admin-only; this one is not, so forwarding a member's
// bearer token to `hub.windmill.dev` would put a credential replayable against this
// instance on a host outside it — for a listing that needs no credential at all.
/// Whether this instance points at the public hub. Compared by parsed host rather than by the
/// string: `hub_base_url` is stored as the operator typed it, so `http://`, a port, a trailing
/// slash, a mixed-case scheme or host, userinfo and a trailing dot all name the same public
@@ -589,6 +579,16 @@ fn is_public_hub(hub: &str) -> bool {
}
}
// The hub's project catalogue. Read by any workspace member rather than through
// `HubPublishCtx`, which requires an admin: nothing here is workspace-scoped or
// publishing-related. It exists at all because the hub's listing endpoint sends no
// CORS header, so the browser cannot read it directly the way it reads a single
// project. `accept: application/json` is what makes the hub answer with JSON.
//
// The caller's token is sent only to a hub this instance was pointed at deliberately.
// Every other route here is admin-only; this one is not, so forwarding a member's
// bearer token to `hub.windmill.dev` would put a credential replayable against this
// instance on a host outside it — for a listing that needs no credential at all.
async fn list_projects(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -145,12 +145,16 @@
// previous controller but `fetchHubProject` takes no signal, and nothing orders the
// responses — so picking A, dismissing, then picking B can land A's name, author and
// counts over an import that writes B.
// Kept in a local rather than read back off `detail.current` inside `detail`'s own fetcher,
// which makes the resource's type circular and resolves it to `any`.
let lastDetail = $state<ImportProjectSummary | undefined>(undefined)
const detail = resource(
() => slug,
async (s) => {
if (!s) return undefined
const fetched = await fetchHubProject(s)
return s === slug ? fetched : detail.current
if (s === slug) lastDetail = fetched
return lastDetail
}
)
let project = $derived<ImportProjectSummary | undefined>(
@@ -40,7 +40,9 @@
let newLinkEl: HTMLButtonElement | undefined = $state(undefined)
onMount(() => {
logFeatureUsage('home', 'empty_state_view')
// Only the empty case: the counter answers how many workspaces sit empty and what
// their owners do next, and a workspace whose items are all archived is neither.
if (!archivedOnly) logFeatureUsage('home', 'empty_state_view')
})
// The catalogue is fetched when the picker opens, never on render: `disable_hub` says an