diff --git a/README.md b/README.md index 768e2e5..788613f 100644 --- a/README.md +++ b/README.md @@ -653,9 +653,11 @@ tick capabilities and corpus entries by checkbox — without hand-writing these requests; it is read-only for a `users:view`-only caller. **Anonymous access (off by default).** Set `TPK_ANONYMOUS_ACCESS=1` -(`[server].anonymous_access`) and the login page offers **Continue without -signing in**: an unauthenticated visitor can chat over the corpus entries whose -visibility is `public` — and nothing else. The anonymous principal holds only +(`[server].anonymous_access`) and a visitor with no session lands straight in +Chat as the anonymous user — no login page first; **Sign in** is in the +sidebar, and the login page offers **Continue without signing in** to go back. +An unauthenticated visitor can chat over the corpus entries whose visibility +is `public` — and nothing else. The anonymous principal holds only the `chat` capability (no Explorer, no source fragments or thinking trace, no MCP, no management), its corpus scope is the enabled public entries (enforced per entry, server-side, on every turn), and the agent's prompt lists only diff --git a/web/src/App.tsx b/web/src/App.tsx index 386cb6e..482b989 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -34,7 +34,11 @@ export default function App() { // guards the one render before the mount-time /auth/me check resolves, so // a logged-in reload doesn't flash the login form. const [me, setMe] = useState(null); - // Server allows unauthenticated chat over the public corpus (#94). + // Server allows unauthenticated chat over the public corpus (#94). With it + // on, "no session" IS a session: a visitor lands straight in Chat as the + // anonymous principal (the mount-time /auth/me check adopts it). The login + // page is then only reached on purpose -- Sign in from the sidebar, or + // signing out of a real account -- both of which clear `me`. const [anonymousAvailable, setAnonymousAvailable] = useState(false); const [pendingChangeUser, setPendingChangeUser] = useState(null); const [checked, setChecked] = useState(false); @@ -78,7 +82,11 @@ export default function App() { if (cancelled) return; if (resp.ok) { const body = await resp.json(); - if (body.anonymous) { setAnonymousAvailable(true); } + if (body.anonymous) { + setAnonymousAvailable(true); + setMe({ username: body.username, role: body.role, + capabilities: body.capabilities ?? [], anonymous: true }); + } // A must_change_password answer routes straight to Login's change // mode (skipping the login form — we already hold a valid token). else if (body.must_change_password) setPendingChangeUser(body.username); @@ -122,6 +130,10 @@ export default function App() { } } + function anonymousMe(): Me { + return { username: "anonymous", role: "anonymous", capabilities: ["chat"], anonymous: true }; + } + if (!checked) return null; if (pendingChangeUser !== null) { @@ -136,7 +148,8 @@ export default function App() { if (!me) { return (
- + setMe(anonymousMe()) : undefined} />
); } diff --git a/web/src/Login.tsx b/web/src/Login.tsx index de6505f..659466d 100644 --- a/web/src/Login.tsx +++ b/web/src/Login.tsx @@ -25,12 +25,15 @@ async function fetchMe(): Promise { export default function Login({ onDone, anonymousAvailable = false, + onContinueAnonymously, initialMode = "login", initialUsername = "", }: { onDone: (me: Me) => void; // The server serves unauthenticated chat over the public corpus (#94). anonymousAvailable?: boolean; + // "Continue without signing in" -> back to the anonymous chat session. + onContinueAnonymously?: () => void; initialMode?: "login" | "change"; initialUsername?: string; }) { @@ -133,14 +136,9 @@ export default function Login({ - {anonymousAvailable && ( + {anonymousAvailable && onContinueAnonymously && (