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:
parent
abb0923f70
commit
6675c1f0fc
8 changed files with 173 additions and 10 deletions
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
66
web/standalone/src/lib/session-identity.test.ts
Normal file
66
web/standalone/src/lib/session-identity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
57
web/standalone/src/lib/session-identity.ts
Normal file
57
web/standalone/src/lib/session-identity.ts
Normal 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;
|
||||
}
|
||||
2
web/standalone/src/vite-env.d.ts
vendored
2
web/standalone/src/vite-env.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue