collab-presence 0009 A: real session identity for presence + comments

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A42xfPFNdfsUt9eowkC9eM
This commit is contained in:
Gergő Törcsvári 2026-07-16 14:31:05 +02:00
commit 6675c1f0fc
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
8 changed files with 173 additions and 10 deletions

View file

@ -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',
},
},
});

View file

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

View file

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

View file

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

View file

@ -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) };
}
/**

View file

@ -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);
});
});

View file

@ -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<SessionIdentity | null> | 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<SessionIdentity | null> {
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;
}

View file

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