From 6675c1f0fcbb8c2a12065ccf92e1234d71898fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Thu, 16 Jul 2026 14:31:05 +0200 Subject: [PATCH] collab-presence 0009 A: real session identity for presence + comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit presenceUser()/userSlug() now resolve the authenticated user: a plain /api/me fetch (GPL no-link rule — no closed contract import) races the WASM boot and is awaited before presence/comments bind. `?user=`/`?libowner=` overrides are gated behind VITE_ALLOW_USER_OVERRIDE=1 (dev script + e2e harnesses set it; prod builds never do). Anonymous/example-backend sessions keep the pre-auth slug fallback. Fixes prod's empty presence (everyone was "local-user", deduped as own tabs) and comment authorship in one move. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01A42xfPFNdfsUt9eowkC9eM --- tests/playwright-web.config.ts | 3 + web/standalone/.env.example | 6 ++ web/standalone/package.json | 2 +- web/standalone/src/components/WasmTool.tsx | 11 ++++ web/standalone/src/lib/config.ts | 36 +++++++--- .../src/lib/session-identity.test.ts | 66 +++++++++++++++++++ web/standalone/src/lib/session-identity.ts | 57 ++++++++++++++++ web/standalone/src/vite-env.d.ts | 2 + 8 files changed, 173 insertions(+), 10 deletions(-) create mode 100644 web/standalone/src/lib/session-identity.test.ts create mode 100644 web/standalone/src/lib/session-identity.ts diff --git a/tests/playwright-web.config.ts b/tests/playwright-web.config.ts index a5b83a6..567669c 100644 --- a/tests/playwright-web.config.ts +++ b/tests/playwright-web.config.ts @@ -91,6 +91,9 @@ export default defineConfig({ // (scripts/dev-gpl.mjs). Only effective on cold starts: with // reuseExistingServer an already-running stack must have set it itself. VITE_LOCAL_PROJECTS: 'idb', + // e2e identity isolation: presence/comments specs mint per-run users via + // `?user=` — only builds that set this honor the param (0009). + VITE_ALLOW_USER_OVERRIDE: '1', }, }, }); diff --git a/web/standalone/.env.example b/web/standalone/.env.example index dae428e..4739225 100644 --- a/web/standalone/.env.example +++ b/web/standalone/.env.example @@ -40,6 +40,12 @@ VITE_WASM_ROOT=/wasm # Leave UNSET for dev and the demo: every route renders locally. # VITE_APP_URL=https://app.pcbjam.com +# Honor `?user=` / `?libowner=` identity overrides ("1"). Dev servers and e2e +# harnesses set this so specs can mint isolated per-run identities; NEVER set +# it on a production build — the session user from /api/me (or the VITE_USER +# fallback) is the identity there (collab-presence 0009). +# VITE_ALLOW_USER_OVERRIDE=1 + # Where the in-editor waitlist form POSTs (default https://www.pcbjam.com/api/waitlist). # The demo is static with no backend, so it cross-posts to the landing site's # serverless endpoint, which must send CORS for this origin (see diff --git a/web/standalone/package.json b/web/standalone/package.json index 1b163d9..5a7fa43 100644 --- a/web/standalone/package.json +++ b/web/standalone/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "node scripts/link-wasm.mjs && vite", + "dev": "node scripts/link-wasm.mjs && VITE_ALLOW_USER_OVERRIDE=1 vite", "dev:demo": "node ../../scripts/deploy/dev-demo.mjs", "link-wasm": "node scripts/link-wasm.mjs", "build": "tsc --noEmit && vite build", diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index c8b8b2a..bd343fa 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -16,6 +16,7 @@ import { } from "@pcbjam/shared"; import { ChevronDown, ChevronUp, EyeOff, Loader2, PanelsTopLeft } from "lucide-react"; import { + API_BASE_URL, currentScope, libsSourceConfig, modelsSourceConfig, @@ -25,6 +26,7 @@ import { type DocSource, } from "@/lib/config"; import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file"; +import { loadSessionIdentity } from "@/lib/session-identity"; import { bootKicadTool } from "@/wasm/boot"; import { resolveWasmBase } from "@/wasm/wasm-assets"; import { @@ -1195,6 +1197,12 @@ export function WasmTool({ void (async () => { try { + // Real identity (collab-presence 0009 A): resolve the session user in + // parallel with the WASM download; awaited after boot, before anything + // binds presence/comments, so presenceUser()/userSlug() speak for the + // authenticated user (anonymous/example backends resolve to null and + // the pre-auth slug fallback stays). + const identityReady = loadSessionIdentity(API_BASE_URL); // Resolve the per-tool asset base at runtime (CDN manifest → versioned // folder, or the flat local /wasm in dev). See wasm/wasm-assets.ts. const base = await resolveWasmBase(tool, assetBaseUrl); @@ -1240,6 +1248,9 @@ export function WasmTool({ frame: TOOL_FRAME[tool], mobile: mobileUi, }); + // Identity must be settled before the doc session / presence binds + // below — effectively instant, it raced the multi-second wasm boot. + await identityReady; // Register the save sink before the file opens: from here on, every // editor File→Save (MEMFS write) is routed onward through saveBytes. // Read-only sessions register neither upload nor the save-driven room diff --git a/web/standalone/src/lib/config.ts b/web/standalone/src/lib/config.ts index 3060cd2..3b12593 100644 --- a/web/standalone/src/lib/config.ts +++ b/web/standalone/src/lib/config.ts @@ -108,6 +108,7 @@ export const LOCAL_PROJECTS_ENABLED = import.meta.env.VITE_LOCAL_PROJECTS === "idb"; import { colorForUser, type PresenceUser } from "@pcbjam/shared"; +import { sessionIdentity } from "@/lib/session-identity"; import type { ProviderConfig, ProviderKind } from "@/wasm/collab"; import { cdnLibsSource } from "@/wasm/libs/cdn-source"; import { cdnModelsSource, type Model3dSource } from "@/wasm/libs/models-source"; @@ -170,30 +171,47 @@ export function docSourceConfig(): DocSource { * "off" — disable libs (empty sym-lib-table). */ /** - * The (thin, pre-auth) current user — sent on every request via USER_HEADER and - * doubling as the personal scope slug. `?user=`/`?libowner=` (e2e isolation) win - * over `VITE_USER`/`VITE_LIBS_OWNER`, else a stable local default. + * `?user=`/`?libowner=` identity overrides are honored only when the BUILD + * opts in (VITE_ALLOW_USER_OVERRIDE=1 — dev servers and e2e harnesses set it); + * production builds never do, so a user can't pick an arbitrary identity via + * the URL (collab-presence 0009). + */ +const USER_OVERRIDE_ALLOWED = + import.meta.env.VITE_ALLOW_USER_OVERRIDE === "1"; + +/** + * The current user slug — sent on every request via USER_HEADER and doubling + * as the personal scope slug. Precedence (collab-presence 0009): + * `?user=`/`?libowner=` when the build allows overrides (e2e isolation) → + * the authenticated session user (lib/session-identity.ts, resolved from + * /api/me during tool boot) → `VITE_USER`/`VITE_LIBS_OWNER` → a stable local + * default. */ export function userSlug(): string { - if (typeof window !== "undefined") { + if (USER_OVERRIDE_ALLOWED && typeof window !== "undefined") { const q = new URLSearchParams(window.location.search); const p = q.get("user") ?? q.get("libowner"); if (p) return p; } + const session = sessionIdentity(); + if (session) return session.slug; return ( import.meta.env.VITE_USER ?? import.meta.env.VITE_LIBS_OWNER ?? "local-user" ); } /** - * The local user's presence identity (collab-presence 0001): the pre-auth slug - * doubles as id + display name, color is the deterministic palette hash — so - * every peer computes the same identity for this user with no coordination. - * Real auth/avatars later replace only how this object is built. + * The local user's presence identity (collab-presence 0001/0009): id is the + * slug (session slug once /api/me resolves), name is the session display name + * (or email) when the session identity is the active one, else the slug + * verbatim; color is the deterministic palette hash — the live room replaces + * it with the nth-in-room claim (presence.ts). */ export function presenceUser(): PresenceUser { const slug = userSlug(); - return { id: slug, name: slug, color: colorForUser(slug) }; + const session = sessionIdentity(); + const name = session && session.slug === slug ? session.name : slug; + return { id: slug, name, color: colorForUser(slug) }; } /** diff --git a/web/standalone/src/lib/session-identity.test.ts b/web/standalone/src/lib/session-identity.test.ts new file mode 100644 index 0000000..11c93c4 --- /dev/null +++ b/web/standalone/src/lib/session-identity.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + loadSessionIdentity, + resetSessionIdentityForTest, + sessionIdentity, +} from "./session-identity"; + +function mockMe(body: unknown, ok = true) { + return vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok, + json: () => Promise.resolve(body), + } as Response); +} + +afterEach(() => { + vi.restoreAllMocks(); + resetSessionIdentityForTest(); +}); + +describe("loadSessionIdentity", () => { + it("resolves the session user (name preferred, slug as id)", async () => { + const f = mockMe({ user: { slug: "alice", name: "Alice A", email: "a@x.y" } }); + expect(await loadSessionIdentity("http://api")).toEqual({ + slug: "alice", + name: "Alice A", + }); + expect(sessionIdentity()).toEqual({ slug: "alice", name: "Alice A" }); + expect(f).toHaveBeenCalledWith("http://api/api/me", { + credentials: "include", + }); + }); + + it("falls back to email, then slug, for the display name", async () => { + mockMe({ user: { slug: "bob", name: "", email: "bob@x.y" } }); + expect(await loadSessionIdentity("http://api")).toEqual({ + slug: "bob", + name: "bob@x.y", + }); + }); + + it("is null for anonymous sessions ({user: null})", async () => { + mockMe({ user: null, authMode: "open" }); + expect(await loadSessionIdentity("http://api")).toBeNull(); + expect(sessionIdentity()).toBeNull(); + }); + + it("is null when the endpoint is missing (example backend / demo)", async () => { + mockMe({ error: "not found" }, false); + expect(await loadSessionIdentity("http://api")).toBeNull(); + }); + + it("is null on network failure and never throws", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline")); + expect(await loadSessionIdentity("http://api")).toBeNull(); + }); + + it("fetches once — concurrent and later callers share the flight", async () => { + const f = mockMe({ user: { slug: "alice", name: "A", email: "" } }); + await Promise.all([ + loadSessionIdentity("http://api"), + loadSessionIdentity("http://api"), + ]); + await loadSessionIdentity("http://api"); + expect(f).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/standalone/src/lib/session-identity.ts b/web/standalone/src/lib/session-identity.ts new file mode 100644 index 0000000..0f5a1bf --- /dev/null +++ b/web/standalone/src/lib/session-identity.ts @@ -0,0 +1,57 @@ +/** + * Session identity (collab-presence 0009 A): the authenticated user behind the + * session cookie, read once from the backend's `/api/me`. The GPL editor must + * not import the closed contract (GPL no-link rule), so this is a plain fetch + * of a tiny documented shape: `{ user: { slug, name, email } | null }` — the + * slug doubles as the personal scope, name/email are for display. Backends + * without the endpoint (example backend, demo/static) simply yield null and + * the pre-auth slug fallback in config.ts stays in effect. + */ +export type SessionIdentity = { slug: string; name: string }; + +let identity: SessionIdentity | null = null; +let pending: Promise | null = null; + +/** The resolved session user; null before load and for anonymous sessions. */ +export function sessionIdentity(): SessionIdentity | null { + return identity; +} + +/** + * Fetch the session user (once per page; concurrent callers share the flight). + * Kicked off at tool boot in parallel with the WASM download and awaited + * before presence/comments bind, so the await is effectively free. + */ +export function loadSessionIdentity( + apiBase: string, +): Promise { + if (!pending) { + pending = fetch(`${apiBase}/api/me`, { credentials: "include" }) + .then((r) => (r.ok ? r.json() : null)) + .then((body: unknown) => { + const u = ( + body as { + user?: { slug?: unknown; name?: unknown; email?: unknown } | null; + } | null + )?.user; + if (u && typeof u.slug === "string" && u.slug) { + identity = { + slug: u.slug, + name: + (typeof u.name === "string" && u.name) || + (typeof u.email === "string" && u.email) || + u.slug, + }; + } + return identity; + }) + .catch(() => null); + } + return pending; +} + +/** Test-only: forget the cached identity + in-flight fetch. */ +export function resetSessionIdentityForTest(): void { + identity = null; + pending = null; +} diff --git a/web/standalone/src/vite-env.d.ts b/web/standalone/src/vite-env.d.ts index 2135073..996c4c2 100644 --- a/web/standalone/src/vite-env.d.ts +++ b/web/standalone/src/vite-env.d.ts @@ -34,6 +34,8 @@ interface ImportMetaEnv { readonly VITE_PLAUSIBLE_SRC?: string; /** Management app origin (e.g. https://app.pcbjam.com); set ⇒ non-editor routes redirect there (lib/redirect.ts). */ readonly VITE_APP_URL?: string; + /** "1" ⇒ honor `?user=`/`?libowner=` identity overrides (dev/e2e harnesses only — NEVER production builds). */ + readonly VITE_ALLOW_USER_OVERRIDE?: string; } interface ImportMeta {