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:
parent
aa26125f61
commit
fab6108cbb
3 changed files with 95 additions and 9 deletions
|
|
@ -997,6 +997,10 @@ export function WasmTool({
|
||||||
// frame token tells its single_top launcher which editor frame to open.
|
// frame token tells its single_top launcher which editor frame to open.
|
||||||
frame: TOOL_FRAME[tool],
|
frame: TOOL_FRAME[tool],
|
||||||
mobile: mobileUi,
|
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
|
// Identity must be settled before the doc session / presence binds
|
||||||
// below — effectively instant, it raced the multi-second wasm boot.
|
// below — effectively instant, it raced the multi-second wasm boot.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
ensureWritableLib,
|
||||||
hasWritableLib,
|
hasWritableLib,
|
||||||
installPthreadWorkerRedirect,
|
installPthreadWorkerRedirect,
|
||||||
pthreadWorkerScript,
|
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
|
* 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
|
* spawns pthread workers from the glue's absolute URL; when that URL is the
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* 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
|
* only — chrome visibility is owned by the shell's chrome-visibility store
|
||||||
* (WasmTool applies it via kicadSetChrome). */
|
* (WasmTool applies it via kicadSetChrome). */
|
||||||
mobile?: boolean;
|
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;
|
let booted: { tool: Tool; promise: Promise<void> } | null = null;
|
||||||
|
|
@ -363,6 +398,7 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
||||||
libsSource,
|
libsSource,
|
||||||
modelsSource,
|
modelsSource,
|
||||||
enumerateGate,
|
enumerateGate,
|
||||||
|
readOnly,
|
||||||
} = opts;
|
} = opts;
|
||||||
// Truthful fetch label: a warm start (download-completion marker present)
|
// Truthful fetch label: a warm start (download-completion marker present)
|
||||||
// reads from the HTTP cache, so "Downloading" would be a lie — and vice versa.
|
// 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).
|
// kind-agnostic containers and appear in every list).
|
||||||
const listsByKind = new Map<"symbol" | "footprint", LibInfo[]>();
|
const listsByKind = new Map<"symbol" | "footprint", LibInfo[]>();
|
||||||
for (const k of libKinds) listsByKind.set(k, await libsSource.listLibs(k));
|
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.
|
// 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.
|
// (skipped read-only; never throws — a failure must not empty the tables).
|
||||||
if (libsSource.createLib && !hasWritableLib(listsByKind.values())) {
|
await ensureWritableLib(libsSource, listsByKind.values(), { readOnly, log });
|
||||||
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}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const symList = listsByKind.get("symbol");
|
const symList = listsByKind.get("symbol");
|
||||||
const fpList = listsByKind.get("footprint");
|
const fpList = listsByKind.get("footprint");
|
||||||
if (symList) {
|
if (symList) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue