fix(standalone): never let the boot-time default-lib create blank the lib tables

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtN6ASBvMGNbjPqY5boycg
This commit is contained in:
Gergő Törcsvári 2026-08-29 15:18:53 +02:00
commit fab6108cbb
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
3 changed files with 95 additions and 9 deletions

View file

@ -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.

View file

@ -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

View file

@ -41,6 +41,38 @@ export function hasWritableLib(lists: Iterable<LibInfo[]>): 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<LibsSource, "createLib">,
listsByKind: Iterable<LibInfo[]>,
opts: { readOnly?: boolean; log: (msg: string) => void; name?: string },
): Promise<LibInfo | null> {
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<void> } | null = null;
@ -363,6 +398,7 @@ async function doBoot(opts: BootOptions): Promise<void> {
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<void> {
// 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) {