From 4a46df02d1fe9738db41c9d7f6cbc219ceb986d3 Mon Sep 17 00:00:00 2001 From: Madraka Date: Tue, 20 Oct 2026 20:06:41 +0300 Subject: [PATCH] fix(playground): ship a fresh Studio, hide what a playground leaves out 0.3.0's CLI shipped an apps/studio/dist built before Studio's playground mode (build-packages only built Studio when no dist existed), so a playground opened at the sign-in page. The release now always builds it. On a playground, Studio's site sidebar leaves out Analytics, Activity, Approvals and the Automation group and stops polling approvals, and a 403 playground answer reads as a feature that is off (isNotEnabled). The minutes-left count rounds down. FetcherBlobStore with keys never fetches another key: with SPA fallback, a static-assets binding answers 200 with index.html for any missing file. Found while wiring cloudhal-dev/try to the published 0.3.0. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka --- .changeset/playground-followups.md | 6 + .../src/components/usePendingApprovals.ts | 3 +- apps/studio/src/components/usePlayground.ts | 5 +- apps/studio/src/lib/api/http.ts | 4 +- apps/studio/src/routes/SiteShell.tsx | 194 +++++++++++------- apps/studio/test/playground.test.tsx | 11 +- docs/design/DESIGN.md | 2 +- packages/storage/src/blob/fetcher.ts | 10 +- packages/storage/test/blob-layers.test.ts | 8 + scripts/build-packages.ts | 11 +- scripts/size-baseline.json | 2 +- 11 files changed, 162 insertions(+), 94 deletions(-) create mode 100644 .changeset/playground-followups.md diff --git a/.changeset/playground-followups.md b/.changeset/playground-followups.md new file mode 100644 index 00000000..68e1905f --- /dev/null +++ b/.changeset/playground-followups.md @@ -0,0 +1,6 @@ +--- +"@cloudhal/cli": patch +"@cloudhal/storage": patch +--- + +The CLI ships a freshly built Studio: 0.3.0 shipped one built before Studio's playground mode, so a playground opened at the sign-in page. On a playground, Studio's site sidebar also leaves out Analytics, Activity, Approvals, Agents and Schedules, and a `403 playground` answer reads as a feature that is off (no retries, no error states). `FetcherBlobStore` with `keys` never fetches another key: a static-assets binding with SPA fallback answers 200 for missing files. diff --git a/apps/studio/src/components/usePendingApprovals.ts b/apps/studio/src/components/usePendingApprovals.ts index 9fd68563..bdc9f77a 100644 --- a/apps/studio/src/components/usePendingApprovals.ts +++ b/apps/studio/src/components/usePendingApprovals.ts @@ -22,9 +22,10 @@ const pendingQuery = (siteId: string) => ({ * (the site shell, so only while the site surface is open). A server without approvals answers * 404 once and is not polled again. */ -export function usePendingApprovals(siteId: string) { +export function usePendingApprovals(siteId: string, enabled = true) { return useQuery({ ...pendingQuery(siteId), + enabled, refetchInterval: (query) => (isNotEnabled(query.state.error) ? false : APPROVALS_POLL_MS), }); } diff --git a/apps/studio/src/components/usePlayground.ts b/apps/studio/src/components/usePlayground.ts index c22c4eee..4e67597c 100644 --- a/apps/studio/src/components/usePlayground.ts +++ b/apps/studio/src/components/usePlayground.ts @@ -17,7 +17,8 @@ export function isPlaygroundBlockedPath(pathname: string): boolean { return /^\/(agents|team|account)(\/|$)/.test(pathname); } -/** Whole minutes until `expiresAt`, never below zero. */ +/** Whole minutes until `expiresAt`: 0 once it has passed, at least 1 before. */ export function minutesLeft(expiresAt: string, now: number): number { - return Math.max(0, Math.ceil((Date.parse(expiresAt) - now) / 60_000)); + const ms = Date.parse(expiresAt) - now; + return ms <= 0 ? 0 : Math.max(1, Math.floor(ms / 60_000)); } diff --git a/apps/studio/src/lib/api/http.ts b/apps/studio/src/lib/api/http.ts index 2a69c649..bd6a269e 100644 --- a/apps/studio/src/lib/api/http.ts +++ b/apps/studio/src/lib/api/http.ts @@ -53,7 +53,9 @@ export function query(params: Record /** True when the server answered 404 for a feature it does not run (audit log, agent keys). */ export function isNotEnabled(error: unknown): boolean { - return error instanceof ApiRequestError && error.status === 404; + if (!(error instanceof ApiRequestError)) return false; + // RFC-0036 §5: a playground refuses what it leaves out with 403 `playground`; read it as off. + return error.status === 404 || (error.status === 403 && error.body.error === "playground"); } /** The API error code (`body.error`) of a failed request, e.g. `rank` or `invite_gone`. */ diff --git a/apps/studio/src/routes/SiteShell.tsx b/apps/studio/src/routes/SiteShell.tsx index a19f8cda..0244ef7a 100644 --- a/apps/studio/src/routes/SiteShell.tsx +++ b/apps/studio/src/routes/SiteShell.tsx @@ -27,6 +27,7 @@ import { useTaxonomyData } from "../components/Taxonomy.tsx"; import { useCan } from "../components/useMe.ts"; import { useNotifications } from "../components/useNotifications.ts"; import { usePendingApprovals } from "../components/usePendingApprovals.ts"; +import { usePlayground } from "../components/usePlayground.ts"; import { useSitePalette } from "../components/useSitePalette.ts"; import { useT } from "../i18n/index.tsx"; import { api } from "../lib/api.ts"; @@ -49,8 +50,10 @@ export function SiteShell() { queryKey: ["snapshots", siteId], queryFn: () => api.snapshots.list(siteId), }); + // RFC-0036 §5: a playground has no analytics, activity log, approvals, agents or schedules. + const full = !usePlayground(); // Polls while the site surface is open; drives the sidebar badge (and the overview banner). - const approvals = usePendingApprovals(siteId); + const approvals = usePendingApprovals(siteId, full); const pendingApprovals = approvals.data?.length ?? 0; // Proposed terms wait for a person (RFC-0002 §8): their count rides on the sidebar item. const taxonomy = useTaxonomyData(siteId); @@ -121,14 +124,16 @@ export function SiteShell() { > {t("nav.home")} - - {t("nav.analytics")} - + {full && ( + + {t("nav.analytics")} + + )} @@ -190,75 +195,14 @@ export function SiteShell() { )} - - {t("nav.history")} - - - {t("nav.snapshots")} - {snapshots.data?.length ?? 0} - - - {t("nav.activity")} - - - {t("nav.approvals")} - {pendingApprovals > 0 && {pendingApprovals}} - - - - - {t("nav.automation")} - - - - - {t("nav.agents")} - - - } - /> - - - - {t("nav.agentKeys")} - - - {t("nav.agentSkills")} - - - - - - - {t("nav.schedules")} - {scheduleAttention > 0 && ( - {scheduleAttention} - )} - - - + + {full && } } @@ -304,3 +248,97 @@ function SettingsGroup({ base, is }: { base: string; is: (path: string) => boole ); } + +interface GroupProps { + base: string; + is: (path: string) => boolean; +} + +/** The sidebar's History group: snapshots, and (not on a playground) activity and approvals. */ +function HistoryGroup({ + base, + is, + full, + snapshots, + pendingApprovals, +}: GroupProps & { full: boolean; snapshots: number; pendingApprovals: number }) { + const t = useT(); + return ( + + {t("nav.history")} + + + {t("nav.snapshots")} + {snapshots} + + {full && ( + <> + + {t("nav.activity")} + + + {t("nav.approvals")} + {pendingApprovals > 0 && {pendingApprovals}} + + + )} + + + ); +} + +/** The sidebar's Automation group: agents and schedules. */ +function AutomationGroup({ base, is, attention }: GroupProps & { attention: number }) { + const t = useT(); + return ( + + {t("nav.automation")} + + + + + {t("nav.agents")} + + + } + /> + + + {t("nav.agentKeys")} + + {t("nav.agentSkills")} + + + + + + + {t("nav.schedules")} + {attention > 0 && {attention}} + + + + ); +} diff --git a/apps/studio/test/playground.test.tsx b/apps/studio/test/playground.test.tsx index 1d6ce33f..c8524454 100644 --- a/apps/studio/test/playground.test.tsx +++ b/apps/studio/test/playground.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { PlaygroundSessionBar } from "../src/components/PlaygroundSessionBar.tsx"; import { PlaygroundUnavailable } from "../src/components/PlaygroundUnavailable.tsx"; import { isPlaygroundBlockedPath, minutesLeft } from "../src/components/usePlayground.ts"; +import { ApiRequestError, isNotEnabled } from "../src/lib/api/http.ts"; import { INSTANCE, on, serve, wrap } from "./roles-fixtures.tsx"; afterEach(() => { @@ -32,6 +33,14 @@ describe("playground helpers", () => { expect(minutesLeft("2026-10-20T17:59:00Z", now)).toBe(0); }); + it("reads a playground refusal as a feature that is off", () => { + const refusal = (status: number, error: string) => + new ApiRequestError(status, { error, message: "x" } as never); + expect(isNotEnabled(refusal(403, "playground"))).toBe(true); + expect(isNotEnabled(refusal(404, "not_found"))).toBe(true); + expect(isNotEnabled(refusal(403, "forbidden"))).toBe(false); + }); + it("knows the account screens a playground leaves out", () => { for (const p of ["/team", "/agents", "/agents/skills", "/account"]) expect(isPlaygroundBlockedPath(p), p).toBe(true); @@ -49,7 +58,7 @@ describe("PlaygroundSessionBar", () => { }); it("shows the time left and Deploy, and Reset deletes the session", async () => { - const expires = new Date(Date.now() + 41 * 60_000 - 1000).toISOString(); + const expires = new Date(Date.now() + 41 * 60_000 + 30_000).toISOString(); const fetch = serve( on("GET", "/api/instance", { status: 200, body: playground(expires) }), on("POST", "/_playground/reset", { status: 204 }), diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md index f51c8e8b..e10a006b 100644 --- a/docs/design/DESIGN.md +++ b/docs/design/DESIGN.md @@ -466,7 +466,7 @@ Site › Settings gets an **Appearance** section after Domains (`components/Appe ### 2af. Playground session (RFC-0036 §5, ADR-0038) - Only when `/api/instance` reports `features.playground` (try.cloudhal.com). Studio has no sign-in there: `requireSession` accepts a 403 from `/api/auth` when the instance is a playground. - **Session bar**: `PlaygroundBar` under the top bar on every screen — a compact secondary `Banner` (`Timer`), title "Playground", the minutes left as an ICU plural with the privacy note, a ghost "Reset" (`POST resetUrl`, then the landing page) and a primary "Deploy to Cloudflare" `LinkButton` (external). At zero minutes the browser returns to the landing page. -- **Left out**: the account surface's Agents group and Team/Account items (and their palette commands), the user menu's Account, Team and Sign out. A direct visit to `/agents`, `/team` or `/account` shows `PlaygroundUnavailable`: a bare `Empty` (`LockSimple`) "Available in your own install" with the Deploy button. Site screens are gated as everywhere else, by `features.*` and the principal's abilities (no uploads, analytics, comments, schedules, AI, preview links). +- **Left out**: the account surface's Agents group and Team/Account items (and their palette commands), the user menu's Account, Team and Sign out. A direct visit to `/agents`, `/team` or `/account` shows `PlaygroundUnavailable`: a bare `Empty` (`LockSimple`) "Available in your own install" with the Deploy button. The site sidebar leaves out Analytics, Activity, Approvals and the Automation group (Agents, Schedules). Elsewhere site screens are gated as everywhere else, by `features.*` and the principal's abilities; a `403 playground` answer counts as a feature that is off (`isNotEnabled`). ## 3. Sidebar specification ```tsx diff --git a/packages/storage/src/blob/fetcher.ts b/packages/storage/src/blob/fetcher.ts index fb2fa130..2e40bce4 100644 --- a/packages/storage/src/blob/fetcher.ts +++ b/packages/storage/src/blob/fetcher.ts @@ -12,7 +12,12 @@ export interface BlobFetcher { } export interface FetcherBlobStoreOptions { - /** Keys `list` reports (static assets cannot be listed). Default: none. */ + /** + * The keys this store holds. With it, `get`, `head` and `list` know only these keys and never + * fetch another: a static-assets binding with SPA fallback answers 200 with `index.html` for any + * missing file, which would otherwise read as a blob. Without it, every key is fetched and + * `list` reports none (static assets cannot be listed). + */ readonly keys?: readonly string[]; } @@ -26,15 +31,18 @@ export class FetcherBlobStore implements BlobStore { readonly #fetcher: BlobFetcher; readonly #prefix: string; readonly #keys: readonly string[]; + readonly #known: ReadonlySet | undefined; constructor(fetcher: BlobFetcher, prefix: string, options: FetcherBlobStoreOptions = {}) { this.#fetcher = fetcher; this.#prefix = prefix.replace(/^\/+/, ""); this.#keys = [...(options.keys ?? [])].sort(); + this.#known = options.keys ? new Set(options.keys) : undefined; } async #fetch(key: string): Promise { assertBlobKey(key); + if (this.#known && !this.#known.has(key)) return null; const res = await this.#fetcher.fetch(`https://assets.local/${this.#prefix}${key}`); if (res.status === 404) return null; if (!res.ok) throw new StorageError(`fetching ${key} failed with ${res.status}`); diff --git a/packages/storage/test/blob-layers.test.ts b/packages/storage/test/blob-layers.test.ts index 3171d09d..e33e8c70 100644 --- a/packages/storage/test/blob-layers.test.ts +++ b/packages/storage/test/blob-layers.test.ts @@ -99,6 +99,14 @@ describe("FetcherBlobStore", () => { expect(fetcher.calls).toEqual([]); }); + it("never fetches a key outside its list (SPA fallback answers 200 for anything)", async () => { + const spa = { fetch: async () => new Response("studio", { status: 200 }) }; + const store = new FetcherBlobStore(spa, "demo/", { keys: ["media/a.png"] }); + expect(await store.get("nodes/abc.json")).toBeNull(); + expect(await store.head("nodes/abc.json")).toBeNull(); + expect(await store.get("media/a.png")).not.toBeNull(); + }); + it("reports other failures as StorageError", async () => { const store = new FetcherBlobStore( { fetch: async () => new Response("", { status: 500 }) }, diff --git a/scripts/build-packages.ts b/scripts/build-packages.ts index 22e65c5b..22fd2548 100644 --- a/scripts/build-packages.ts +++ b/scripts/build-packages.ts @@ -202,14 +202,9 @@ async function buildCliAssets(): Promise { )) as typeof import("../packages/cli/src/starter-files.ts"); const assets = path.join(ROOT, "packages/cli/dist/assets"); const studio = path.join(ROOT, "apps/studio/dist"); - try { - await stat(path.join(studio, "index.html")); - } catch { - execFileSync("pnpm", ["--filter", "@cloudhal/studio", "build"], { - stdio: "inherit", - cwd: ROOT, - }); - } + // Always a fresh build: an existing dist may predate the sources being released (0.3.0 shipped + // a Studio from before its playground mode this way). + execFileSync("pnpm", ["--filter", "@cloudhal/studio", "build"], { stdio: "inherit", cwd: ROOT }); await cp(studio, path.join(assets, "studio"), { recursive: true, filter: (src) => !src.endsWith(".map"), diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index dbcf0b30..56427416 100644 --- a/scripts/size-baseline.json +++ b/scripts/size-baseline.json @@ -268,7 +268,7 @@ "lines": 164 }, "apps/studio/src/routes/SiteShell.tsx::SiteShell": { - "lines": 235 + "lines": 178 }, "apps/studio/src/routes/SkillsPage.tsx::SkillsPage": { "lines": 170