From fab6108cbb29912cff4f8d16cf5840b769681ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Sat, 29 Aug 2026 15:18:53 +0200 Subject: [PATCH] fix(standalone): never let the boot-time default-lib create blank the lib tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anonymous open of a public project (read-only viewer): boot saw no writable lib, POSTed createLib("My Symbols"), the session gate 401'd, and the throw escaped the listLibs try/catch — both sym/fp lib tables were seeded EMPTY for the session (staging: /tg44/projects/arduino/... Arduino Leonardo.kicad_sch). - ensureWritableLib(): skipped for readOnly sessions; a failed create is logged and swallowed, never propagated (the listed libs stay seeded). - WasmTool passes readOnly through to boot. - unit tests for the skip / create / already-writable / rejected-create paths. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CtN6ASBvMGNbjPqY5boycg --- web/standalone/src/components/WasmTool.tsx | 4 ++ web/standalone/src/wasm/boot.test.ts | 52 ++++++++++++++++++++++ web/standalone/src/wasm/boot.ts | 48 ++++++++++++++++---- 3 files changed, 95 insertions(+), 9 deletions(-) diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 5d4be0f..35c6ead 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -997,6 +997,10 @@ export function WasmTool({ // frame token tells its single_top launcher which editor frame to open. frame: TOOL_FRAME[tool], mobile: mobileUi, + // Viewers can't write: skip the boot-time default-lib create (a + // session-gated POST that 401s anonymously and used to blank the + // lib tables — anonymous public-schematic open on staging). + readOnly, }); // Identity must be settled before the doc session / presence binds // below — effectively instant, it raced the multi-second wasm boot. diff --git a/web/standalone/src/wasm/boot.test.ts b/web/standalone/src/wasm/boot.test.ts index 8f8ae25..e91771d 100644 --- a/web/standalone/src/wasm/boot.test.ts +++ b/web/standalone/src/wasm/boot.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + ensureWritableLib, hasWritableLib, installPthreadWorkerRedirect, pthreadWorkerScript, @@ -28,6 +29,57 @@ describe("hasWritableLib", () => { }); }); +/** + * Boot-time default-lib create. Regression: an anonymous viewer of a public + * project has no writable lib, so boot POSTed createLib, the session gate + * 401'd, and the throw escaped the caller's try — seeding EMPTY lib tables. + */ +describe("ensureWritableLib", () => { + const log = () => {}; + + it("skips the create entirely for read-only sessions", async () => { + const createLib = vi.fn(async () => lib("org")); + const lists = [[lib("origin")]]; + expect(await ensureWritableLib({ createLib }, lists, { readOnly: true, log })).toBeNull(); + expect(createLib).not.toHaveBeenCalled(); + expect(lists[0]).toHaveLength(1); + }); + + it("creates once and joins every per-kind list when nothing writable is listed", async () => { + const created = lib("org"); + const createLib = vi.fn(async () => created); + const sym = [lib("origin")]; + const fp = [lib("mirror")]; + expect(await ensureWritableLib({ createLib }, [sym, fp], { log })).toBe(created); + expect(createLib).toHaveBeenCalledWith("My Symbols"); + expect(sym).toContain(created); + expect(fp).toContain(created); + }); + + it("does nothing when a writable lib already exists", async () => { + const createLib = vi.fn(async () => lib("org")); + await ensureWritableLib({ createLib }, [[lib("user")]], { log }); + expect(createLib).not.toHaveBeenCalled(); + }); + + it("swallows a rejected create (401) and leaves the listed libs intact", async () => { + const createLib = vi.fn(async () => { + throw new Error("401"); + }); + const logs: string[] = []; + const sym = [lib("origin"), lib("mirror")]; + await expect( + ensureWritableLib({ createLib }, [sym], { log: (m) => logs.push(m) }), + ).resolves.toBeNull(); + expect(sym).toHaveLength(2); + expect(logs.some((m) => /non-fatal/.test(m))).toBe(true); + }); + + it("is a no-op for sources without createLib", async () => { + expect(await ensureWritableLib({}, [[lib("origin")]], { log })).toBeNull(); + }); +}); + /** * Cross-origin (CDN) pthread spawn fix — doc-23 §7 KNOWN GAP. Emscripten * spawns pthread workers from the glue's absolute URL; when that URL is the diff --git a/web/standalone/src/wasm/boot.ts b/web/standalone/src/wasm/boot.ts index c6c3721..391240f 100644 --- a/web/standalone/src/wasm/boot.ts +++ b/web/standalone/src/wasm/boot.ts @@ -41,6 +41,38 @@ export function hasWritableLib(lists: Iterable): boolean { ); } +/** + * Ensure the owner has at least one writable lib to save items into, creating + * the default one when none is listed. A writable lib holds either kind, so a + * created lib joins every per-kind list (mutated in place). + * + * Skipped outright for read-only sessions (anonymous viewer of a public + * project, read-only-viewer): they cannot write, so there is nothing to save + * into — and the create is a session-gated POST that 401s for them. Best-effort + * either way: a failed create (401/403, backend without the route, a network + * blip) is logged and swallowed, NEVER propagated — an exception here used to + * escape the caller's listLibs try/catch and seed EMPTY lib tables for the + * whole session (observed on staging: anonymous open of a public schematic). + */ +export async function ensureWritableLib( + source: Pick, + listsByKind: Iterable, + opts: { readOnly?: boolean; log: (msg: string) => void; name?: string }, +): Promise { + const lists = [...listsByKind]; + if (opts.readOnly || !source.createLib || hasWritableLib(lists)) return null; + try { + const created = await source.createLib(opts.name ?? DEFAULT_USER_LIB_NAME); + if (!created) return null; + for (const libs of lists) libs.push(created); + opts.log(`[libs] created default user lib "${created.name}"`); + return created; + } catch (e) { + opts.log(`[libs] default user lib create failed (non-fatal): ${String(e)}`); + return null; + } +} + /** * Boot a KiCad tool directly in the main React document — no iframe. * @@ -116,6 +148,9 @@ export interface BootOptions { * only — chrome visibility is owned by the shell's chrome-visibility store * (WasmTool applies it via kicadSetChrome). */ mobile?: boolean; + /** Read-only session (anonymous/public viewer): never attempt writes at + * boot — in particular no default-user-lib create (see ensureWritableLib). */ + readOnly?: boolean; } let booted: { tool: Tool; promise: Promise } | null = null; @@ -363,6 +398,7 @@ async function doBoot(opts: BootOptions): Promise { libsSource, modelsSource, enumerateGate, + readOnly, } = opts; // Truthful fetch label: a warm start (download-completion marker present) // reads from the HTTP cache, so "Downloading" would be a lie — and vice versa. @@ -436,15 +472,9 @@ async function doBoot(opts: BootOptions): Promise { // kind-agnostic containers and appear in every list). const listsByKind = new Map<"symbol" | "footprint", LibInfo[]>(); for (const k of libKinds) listsByKind.set(k, await libsSource.listLibs(k)); - // Ensure the owner has at least one writable lib to save items into. - // A writable lib holds either kind, so the created lib joins every table. - if (libsSource.createLib && !hasWritableLib(listsByKind.values())) { - const created = await libsSource.createLib(DEFAULT_USER_LIB_NAME); - if (created) { - for (const libs of listsByKind.values()) libs.push(created); - log(`[libs] created default user lib "${created.name}"`); - } - } + // Ensure the owner has at least one writable lib to save items into + // (skipped read-only; never throws — a failure must not empty the tables). + await ensureWritableLib(libsSource, listsByKind.values(), { readOnly, log }); const symList = listsByKind.get("symbol"); const fpList = listsByKind.get("footprint"); if (symList) {