diff --git a/.env.example b/.env.example index 65fb568..481507d 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,8 @@ OPENAI_API_KEY= # TPK_EXTRACTION_BACKEND= # [llm].backend auto | claude | openai # TPK_EXTRACTION_MODEL= # [llm].model semantic-extraction model # TPK_CONFIG=repos.toml # path to the config file itself +# TPK_ANONYMOUS_ACCESS=0 # [server].anonymous_access 1 = unauthenticated chat over public entries +# TPK_ANONYMOUS_DAILY_TOKEN_LIMIT=100000 # [server].anonymous_daily_token_limit shared daily budget (0 = closed) # TPK_MCP_HTTP_ENABLED=1 # [server].mcp_http false/0 disables the remote MCP endpoint (/mcp) # TPK_MCP_ALLOWED_HOSTS= # [server].mcp_allowed_hosts comma-separated Host allow-list for /mcp (empty = no check) diff --git a/README.md b/README.md index 8df5eaa..768e2e5 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ export the matching env var — whichever suits your deployment. Secrets are | `TPK_SESSION_TTL` | `[server].session_ttl` | `86400` | Login session lifetime (seconds) | | `TPK_CHAT_AUDIT` | `[server].chat_audit` | `true` | Chat Q&A auditing (`false`/`0` disables) | | `TPK_DAILY_TOKEN_LIMIT` | `[server].daily_token_limit` | `500000` | Global fallback per-user daily token budget for non-admins (`0` = unlimited; a per-user or role `daily_token_limit` wins) | +| `TPK_ANONYMOUS_ACCESS` | `[server].anonymous_access` | `false` | Serve unauthenticated chat over the public corpus entries (chat only) | +| `TPK_ANONYMOUS_DAILY_TOKEN_LIMIT` | `[server].anonymous_daily_token_limit` | `100000` | One daily token budget shared by all anonymous chat (`0` = closed) | | `TPK_MCP_HTTP_ENABLED` | `[server].mcp_http` | `true` | Remote MCP endpoint `/mcp` (`false`/`0` disables it) | | `TPK_MCP_ALLOWED_HOSTS` | `[server].mcp_allowed_hosts` | `` | Comma-separated `Host` allow-list for `/mcp` (empty = no check) | | `TPK_EXTRACTION_BACKEND` | `[llm].backend` | `auto` | Semantic-extraction backend (`auto`\|`claude`\|`openai`) | @@ -650,6 +652,22 @@ with `users:view`) covers all of this — create/edit/delete users and roles, 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 +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 +those entries. All anonymous traffic shares **one** daily token budget, +`TPK_ANONYMOUS_DAILY_TOKEN_LIMIT` (`[server].anonymous_daily_token_limit`, +default 100000; `0` closes anonymous chat) — when it is spent the chat answers +429 with a "sign in for your own budget" hint. A wrong or expired token is +still rejected (401), never downgraded to anonymous. The names `anonymous` +(user and role) are reserved. Because a public entry is exposed to anyone once +this is on, the add-entry form warns when `public` is chosen; visibility +cannot be changed after creation. + **Break-glass (locked out of every admin account).** The API's last-admin guard only stops you from doing this through `/api`; if every admin is disabled, deleted, or its password is lost, there is no in-app way back. Run diff --git a/deploy/docker/repos.container.toml b/deploy/docker/repos.container.toml index 7fec85d..7a871f4 100644 --- a/deploy/docker/repos.container.toml +++ b/deploy/docker/repos.container.toml @@ -48,6 +48,8 @@ token_budget = 16000 # chat_audit = true # env: TPK_CHAT_AUDIT # daily_token_limit = 500000 # env: TPK_DAILY_TOKEN_LIMIT (global fallback, non-admin per-user/day; 0 = unlimited) # mcp_http = true # env: TPK_MCP_HTTP_ENABLED (false/0 disables the remote MCP endpoint /mcp) +# anonymous_access = false # env: TPK_ANONYMOUS_ACCESS (unauthenticated chat over public entries) +# anonymous_daily_token_limit = 100000 # env: TPK_ANONYMOUS_DAILY_TOKEN_LIMIT (shared by all anonymous chat; 0 = closed) # mcp_allowed_hosts = "" # env: TPK_MCP_ALLOWED_HOSTS (comma-separated Host allow-list; empty = no check) [repos.proton-enterprise] diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f805ec8..7ae031a 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -120,6 +120,7 @@ Login-based access control, with roles that scope what each user can query. - **Role-scoped chat.** Orthogonal to capabilities: a role lists the exact `name@ref` corpus entries its members may query, and a non-admin's chat/explore results are transparently restricted to that scope (admin, the local stdio MCP server, and the CLI are unrestricted; the remote `/mcp` endpoint runs as the token's user and is scoped like chat). Isolation is enforced server-side across every graph tool path. - **Bounded delegation.** A non-admin with `users:manage` can never mint admins or manage admin users, and can only grant capabilities and corpus entries within its own grant — no self-promotion path. - **Admin console.** A Users/Roles console manages accounts, role assignments, password resets, per-role capabilities, and per-role entry-key access; last-admin lockout is prevented, and `tpk auth reset-admin` recovers the admin account from the command line if it happens anyway. +- **Anonymous access (opt-in).** With `TPK_ANONYMOUS_ACCESS` on, visitors can chat — and only chat — over the corpus entries marked `public`, under one shared daily token budget; internal entries stay invisible to them, enforced per entry on the server. - **Hardened DB layer.** The compose stack provisions a dedicated `tpk` timeplusd user and password-locks the previously open `default` user. ## Deployment surfaces diff --git a/repos.toml b/repos.toml index 2607d0b..cfa7e77 100644 --- a/repos.toml +++ b/repos.toml @@ -54,6 +54,8 @@ token_budget = 16000 # chat_audit = true # env: TPK_CHAT_AUDIT (false/0 disables chat auditing) # daily_token_limit = 500000 # env: TPK_DAILY_TOKEN_LIMIT (global fallback per-user/day, non-admin; 0 = unlimited; a per-user/role limit wins) # mcp_http = true # env: TPK_MCP_HTTP_ENABLED (false/0 disables the remote MCP endpoint /mcp) +# anonymous_access = false # env: TPK_ANONYMOUS_ACCESS (unauthenticated chat over public entries) +# anonymous_daily_token_limit = 100000 # env: TPK_ANONYMOUS_DAILY_TOKEN_LIMIT (shared by all anonymous chat; 0 = closed) # mcp_allowed_hosts = "" # env: TPK_MCP_ALLOWED_HOSTS (comma-separated Host allow-list; empty = no check) [repos.proton-enterprise] diff --git a/src/tpk/agent.py b/src/tpk/agent.py index 08addce..f24fc98 100644 --- a/src/tpk/agent.py +++ b/src/tpk/agent.py @@ -199,20 +199,34 @@ def build_agent( if corpus_provider is None: return create_react_agent(chat_model, tools, prompt=system_prompt(repos)) - # `repos` seeds the fallback corpus: if `corpus_provider()` raises (e.g. - # a transient DB failure), the turn must still get a usable prompt - # instead of the chat dying with an error event. Mirrors the - # degrade-gracefully pattern in tools.py's KnowledgeGraph._corpus_state. + return create_react_agent(chat_model, tools, prompt=live_prompt(repos, corpus_provider)) + + +def live_prompt(repos, corpus_provider): + """Per-turn prompt builder. `repos` seeds the fallback corpus: if + `corpus_provider()` raises (e.g. a transient DB failure), the turn must + still get a usable prompt instead of the chat dying with an error event. + Mirrors the degrade-gracefully pattern in tools.py's + KnowledgeGraph._corpus_state. + + The corpus list is narrowed to the turn's ROLE_SCOPE (a role's entries, + or the anonymous public scope, #94): the same ContextVar the tools obey, + so the prompt never advertises entries the tools cannot reach.""" + from tpk.tools import ROLE_SCOPE + last_good_repos = repos - def _live_prompt(state): + def _prompt(state): nonlocal last_good_repos try: last_good_repos = corpus_provider() except Exception: pass # keep serving the last successful (or seed) corpus - return [ - {"role": "system", "content": system_prompt(last_good_repos)} - ] + list(state["messages"]) - - return create_react_agent(chat_model, tools, prompt=_live_prompt) + entries = (list(last_good_repos.values()) if isinstance(last_good_repos, dict) + else list(last_good_repos)) + scope = ROLE_SCOPE.get() + if scope is not None: + entries = [r for r in entries if entry_key(r) in scope] + return [{"role": "system", "content": system_prompt(entries)}] + list(state["messages"]) + + return _prompt diff --git a/src/tpk/api.py b/src/tpk/api.py index 3952cd2..eea30b4 100644 --- a/src/tpk/api.py +++ b/src/tpk/api.py @@ -395,6 +395,8 @@ def api_list_users(actor: User = Depends(auth.require_cap(auth_mod.CAP_USERS_VIE @router.post("/users") def api_add_user(body: AddUser, actor: User = Depends(auth.require_cap(auth_mod.CAP_USERS_MANAGE))): + if body.username == auth_mod.ANONYMOUS_USERNAME: + raise HTTPException(400, "'anonymous' is a reserved username") if not _USERNAME_RE.match(body.username) or body.username in (".", ".."): raise HTTPException(400, "username must match ^[A-Za-z0-9._-]+$") err = auth_mod.validate_new_password(body.password) @@ -484,6 +486,8 @@ def api_list_roles(actor: User = Depends(auth.require_cap(auth_mod.CAP_USERS_VIE def api_upsert_role(body: UpsertRole, actor: User = Depends(auth.require_cap(auth_mod.CAP_USERS_MANAGE))): if body.name == auth_mod.ROLE_ADMIN: raise HTTPException(400, "'admin' is a reserved role name") + if body.name == auth_mod.ROLE_ANONYMOUS: + raise HTTPException(400, "'anonymous' is a reserved role name") if not _USERNAME_RE.match(body.name): raise HTTPException(400, "role name must match ^[A-Za-z0-9._-]+$") if any(not isinstance(k, str) or not k for k in body.entry_keys): diff --git a/src/tpk/auth.py b/src/tpk/auth.py index 72298ff..edd0c32 100644 --- a/src/tpk/auth.py +++ b/src/tpk/auth.py @@ -19,6 +19,13 @@ log = logging.getLogger(__name__) ROLE_ADMIN = "admin" +# The anonymous principal (#94): served for a request with NO credentials when +# TPK_ANONYMOUS_ACCESS is on. Like `admin`, its role is a sentinel and never a +# kg_roles row; unlike admin it holds exactly one capability (chat) and its +# corpus scope is the enabled PUBLIC entries (public_scope). Both names are +# reserved so no account or role can impersonate it. +ROLE_ANONYMOUS = "anonymous" +ANONYMOUS_USERNAME = "anonymous" SEED_USERNAME = "admin" SEED_PASSWORD = "changeme" @@ -506,11 +513,14 @@ def _session_ttl() -> int: def effective_capabilities(user: User, role: Role | None) -> set[str]: - """The capabilities a user actually holds. `admin` gets all of them; any - other user gets the (view-expanded) capabilities of their role, or the - empty set if the role is missing/unreadable (fail closed).""" + """The capabilities a user actually holds. `admin` gets all of them; + `anonymous` gets chat only; any other user gets the (view-expanded) + capabilities of their role, or the empty set if the role is + missing/unreadable (fail closed).""" if user.role == ROLE_ADMIN: return set(ALL_CAPABILITIES) + if user.role == ROLE_ANONYMOUS: + return {CAP_CHAT} if role is None: return set() return expand_capabilities(role.capabilities) @@ -523,6 +533,8 @@ def resolve_scope(client, user: User, prefix: str = "") -> frozenset[str] | None Explorer API (graph_api) and the remote MCP guard (mcp_http).""" if user.role == ROLE_ADMIN: return None + if user.role == ROLE_ANONYMOUS: + return public_scope(client, prefix=prefix) try: role = get_role(client, user.role, prefix=prefix) except Exception: @@ -530,6 +542,31 @@ def resolve_scope(client, user: User, prefix: str = "") -> frozenset[str] | None return frozenset(role.entry_keys) if role else frozenset() +def public_scope(client, prefix: str = "") -> frozenset[str]: + """Entry keys an anonymous caller may query: the ENABLED entries whose + visibility is `public`, read live so a flip takes effect on the next + turn. Enforced per entry -- a node's own stored visibility is never + consulted. Fails closed to the empty scope.""" + from tpk import corpus # local: corpus imports db, auth must stay light + from tpk.config import entry_key + + try: + return frozenset(entry_key(e) for e in corpus.list_entries(client, prefix=prefix) + if e.enabled and e.visibility == "public") + except Exception: + return frozenset() + + +def anonymous_access_enabled() -> bool: + from tpk.config import as_bool, setting + + return as_bool(setting("TPK_ANONYMOUS_ACCESS", "server", "anonymous_access", False)) + + +# Never stored: username/role are reserved sentinels (see ROLE_ANONYMOUS). +ANONYMOUS = User(ANONYMOUS_USERNAME, "", ROLE_ANONYMOUS) + + # Precomputed at import time so an unknown-username login still pays the # same argon2 cost as a known-user/wrong-password login -- otherwise the # `user is None` short-circuit is a timing oracle for username enumeration @@ -551,10 +588,21 @@ def _client(self): return db.get_client(Settings.from_env()) + @staticmethod + def _anonymous_if_allowed(authorization: str | None) -> User | None: + """The anonymous principal for a request that carries NO credential at + all -- never for a wrong one: a bad token must not silently degrade to + public access.""" + if authorization is None and anonymous_access_enabled(): + return ANONYMOUS + return None + def _resolve(self, authorization: str | None) -> User: if not authorization or not authorization.startswith("Bearer "): raise HTTPException(401, "missing bearer token") token = authorization.removeprefix("Bearer ") + if not token: + raise HTTPException(401, "missing bearer token") try: client = self._client() username = get_session(client, token, prefix=self.prefix) @@ -587,8 +635,8 @@ def effective_caps(self, user: User) -> set[str]: """Resolve a user's effective capabilities, reading their role from the store for non-admins. Fails closed (empty set) if the role is unreadable.""" - if user.role == ROLE_ADMIN: - return set(ALL_CAPABILITIES) + if user.role in (ROLE_ADMIN, ROLE_ANONYMOUS): + return effective_capabilities(user, None) try: role = get_role(self._client(), user.role, prefix=self.prefix) except Exception: @@ -599,6 +647,10 @@ def require_cap(self, capability: str): """Build a FastAPI dependency that admits a user only if they hold `capability`. Usage: `Depends(auth.require_cap(auth.CAP_CHAT))`.""" def dependency(authorization: str | None = Header(None)) -> User: + if capability == CAP_CHAT: + anon = self._anonymous_if_allowed(authorization) + if anon is not None: + return anon user = self.require_user(authorization) if capability not in self.effective_caps(user): raise HTTPException(403, f"missing capability: {capability}") @@ -625,6 +677,8 @@ def login(body: LoginRequest): try: client = auth_layer._client() user = get_user(client, body.username, prefix=prefix) + if body.username == ANONYMOUS_USERNAME: + user = None # reserved: the anonymous principal is never a login except Exception: raise HTTPException(503, "auth store unavailable") # Always run an argon2 verify, even for an unknown username, so the @@ -653,10 +707,14 @@ def logout(authorization: str | None = Header(None), raise HTTPException(503, "auth store unavailable") @router.get("/me") - def me(user: User = Depends(auth_layer.require_user_any)): - return {"username": user.username, "role": user.role, - "must_change_password": user.must_change_password, - "capabilities": sorted(auth_layer.effective_caps(user))} + def me(authorization: str | None = Header(None)): + user = auth_layer._anonymous_if_allowed(authorization) or auth_layer.require_user_any(authorization) + out = {"username": user.username, "role": user.role, + "must_change_password": user.must_change_password, + "capabilities": sorted(auth_layer.effective_caps(user))} + if user.role == ROLE_ANONYMOUS: + out["anonymous"] = True + return out @router.post("/change-password") def change_password(body: ChangePasswordRequest, diff --git a/src/tpk/config.py b/src/tpk/config.py index d184894..75612db 100644 --- a/src/tpk/config.py +++ b/src/tpk/config.py @@ -111,6 +111,13 @@ def database() -> str: return name +def anonymous_daily_token_limit() -> int: + """One GLOBAL daily token budget shared by all anonymous chat (#94): + env TPK_ANONYMOUS_DAILY_TOKEN_LIMIT > [server].anonymous_daily_token_limit + > 100000. 0 disables anonymous chat even when anonymous access is on.""" + return int(setting("TPK_ANONYMOUS_DAILY_TOKEN_LIMIT", "server", "anonymous_daily_token_limit", 100_000, cast=int)) + + def daily_token_limit() -> int: """Global fallback daily per-user token budget for non-admin chat (#62): env TPK_DAILY_TOKEN_LIMIT > [server].daily_token_limit > 500000. Applies to diff --git a/src/tpk/graphify_runner.py b/src/tpk/graphify_runner.py index 0048299..05449b6 100644 --- a/src/tpk/graphify_runner.py +++ b/src/tpk/graphify_runner.py @@ -73,8 +73,8 @@ `rationale`, `concept`. Only `code` is source-derived; the other five are always non-code entities (extracted from docs/papers/images, or concept-like nodes such as "ideas, principles, mechanisms, design - patterns") and are treated as doc-kind (`DOC_KINDS`) regardless of the - repo's default visibility. `--code-only` runs only ever emit `code` (for + patterns") and are treated as doc-kind (`DOC_KINDS`). Every node, doc or + code, carries its entry's visibility (#94). `--code-only` runs only ever emit `code` (for files/functions) in practice, but the parser honors the full six-value enum so a future non-`--code-only` run parses correctly too. - `confidence` is one of `EXTRACTED` (explicit in source: import, call, @@ -376,7 +376,10 @@ def parse_graph_json( line = _parse_line(rn) stable = node_id(repo, kind, qualified) id_map[raw_id] = stable - visibility = "public" if kind in DOC_KINDS else default_visibility + # Every node carries its ENTRY's visibility. (Doc-kind nodes used to + # be stamped "public" regardless; with anonymous access, #94, the + # entry is the unit of access control, so the label must agree.) + visibility = default_visibility nodes.append( Node( id=stable, diff --git a/src/tpk/server.py b/src/tpk/server.py index 10ad964..ba0f261 100644 --- a/src/tpk/server.py +++ b/src/tpk/server.py @@ -161,7 +161,7 @@ def create_app( from tpk.agent import RECURSION_LIMIT from tpk.api import create_api_router from tpk.auth import AuthLayer, User, create_auth_router - from tpk.config import daily_token_limit + from tpk.config import anonymous_daily_token_limit, daily_token_limit from tpk.tools import ROLE_SCOPE from tpk.usage import day_window, effective_daily_limit @@ -254,22 +254,29 @@ def chat_usage_status(user: User = Depends(auth.require_cap(auth_mod.CAP_CHAT))) the global default limit, and the usage read fails open (0 used).""" if user.role == auth_mod.ROLE_ADMIN or usage is None: return {"limited": False} - try: - role = auth_mod.get_role(auth._client(), user.role, prefix=stream_prefix) - except Exception: - role = None - limit = effective_daily_limit(user.daily_token_limit, role, daily_token_limit()) - if limit <= 0: - return {"limited": False} + if user.role == auth_mod.ROLE_ANONYMOUS: + # One shared pool for all anonymous traffic (#94); 0 = closed. + limit = anonymous_daily_token_limit() + else: + try: + role = auth_mod.get_role(auth._client(), user.role, prefix=stream_prefix) + except Exception: + role = None + limit = effective_daily_limit(user.daily_token_limit, role, daily_token_limit()) + if limit <= 0: + return {"limited": False} used = usage.used_today(user.username) _, reset = day_window() - return { + out = { "limited": True, "used": used, "limit": limit, "remaining": max(0, limit - used), "reset": reset.isoformat(), } + if user.role == auth_mod.ROLE_ANONYMOUS: + out["anonymous"] = True + return out @app.post("/chat") async def chat(req: ChatRequest, user: User = Depends(auth.require_cap(auth_mod.CAP_CHAT))): @@ -288,7 +295,34 @@ async def chat(req: ChatRequest, user: User = Depends(auth.require_cap(auth_mod. scope = None role = None turn_limit = 0 # effective daily token budget for this user (0 = unlimited) - if user.role != auth_mod.ROLE_ADMIN: + anonymous = user.role == auth_mod.ROLE_ANONYMOUS + if anonymous: + # #94: scope = the enabled PUBLIC entries (per entry, read live; + # fails closed to empty); budget = ONE global pool shared by every + # anonymous caller, metered under the reserved username. A limit + # of 0 keeps the endpoint answering 429, i.e. anonymous chat off. + def _public(): + try: + return auth_mod.public_scope(auth._client(), prefix=stream_prefix) + except Exception: # store unreachable: closed, never unrestricted + return frozenset() + scope = await run_in_threadpool(_public) + turn_limit = anonymous_daily_token_limit() + used = await run_in_threadpool(lambda: usage.used_today(user.username)) if usage is not None else 0 + if turn_limit <= 0 or used >= turn_limit: + _, reset = day_window() + raise HTTPException( + status_code=429, + detail={ + "message": ( + f"The shared daily budget for anonymous use is spent ({used}/{turn_limit}). " + f"Sign in for your own budget, or try again after {reset.isoformat()}." + ), + "used": used, "limit": turn_limit, "reset": reset.isoformat(), + "anonymous": True, + }, + ) + elif user.role != auth_mod.ROLE_ADMIN: try: # Off the event loop: against an unreachable-but-not-refusing # store this is a blocking TCP connect timeout, which would diff --git a/tests/test_agent.py b/tests/test_agent.py index cf64cf9..81edb0e 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -153,3 +153,31 @@ def broken_provider(): assert result["messages"][-1].content == "fallback ok" system_text = seen_messages[0][0].content assert "proton" in system_text and "Core streaming SQL engine" in system_text + + +def test_live_prompt_lists_only_entries_inside_the_active_scope(): + """The corpus list in the prompt follows ROLE_SCOPE, so a scoped turn + (a role, or the anonymous public scope, #94) never claims coverage of + entries the tools cannot reach.""" + from tpk.agent import live_prompt + from tpk.config import RepoConfig + from tpk.tools import ROLE_SCOPE + + docs = RepoConfig(name="docs", github="o/docs", ref="main", visibility="public", description="Public docs") + proton = RepoConfig(name="proton", github="o/proton", ref="v1", visibility="internal", description="Engine") + prompt_fn = live_prompt([docs, proton], corpus_provider=lambda: [docs, proton]) + + unscoped = prompt_fn({"messages": []})[0]["content"] + assert "docs@main" in unscoped and "proton@v1" in unscoped + token = ROLE_SCOPE.set(frozenset({"docs@main"})) + try: + scoped = prompt_fn({"messages": []})[0]["content"] + finally: + ROLE_SCOPE.reset(token) + assert "docs@main" in scoped and "proton@v1" not in scoped + token = ROLE_SCOPE.set(frozenset()) + try: + empty = prompt_fn({"messages": []})[0]["content"] + finally: + ROLE_SCOPE.reset(token) + assert "proton@v1" not in empty and "docs@main" not in empty diff --git a/tests/test_anonymous.py b/tests/test_anonymous.py new file mode 100644 index 0000000..c9ee908 --- /dev/null +++ b/tests/test_anonymous.py @@ -0,0 +1,133 @@ +"""Anonymous chat over the public corpus (#94): off by default; when on, a +request with NO credentials is the `anonymous` principal -- chat only, scoped +to enabled public entries. A bad credential is never downgraded to anonymous.""" +import time + +import pytest +from fastapi.testclient import TestClient + +from conftest import requires_timeplus +from tpk import auth, corpus +from tpk.config import RepoConfig +from tpk.server import create_app + +pytestmark = requires_timeplus + + +class _NoAgent: + async def astream_events(self, *a, **k): + yield # pragma: no cover + + +def _eventually(fn, predicate=bool, timeout=5.0, interval=0.1): + deadline = time.monotonic() + timeout + result = fn() + while not predicate(result) and time.monotonic() < deadline: + time.sleep(interval) + result = fn() + return result + + +@pytest.fixture() +def env(tp, monkeypatch): + client, prefix = tp + corpus.upsert_entry(client, RepoConfig(name="docs", github="o/docs", ref="main", visibility="public"), prefix=prefix) + corpus.upsert_entry(client, RepoConfig(name="proton", github="o/proton", ref="v1", visibility="internal"), prefix=prefix) + corpus.upsert_entry(client, RepoConfig(name="old-docs", github="o/docs", ref="v0", visibility="public", + enabled=False), prefix=prefix) + _eventually(lambda: corpus.list_entries(client, prefix=prefix), lambda v: len(v) == 3) + return client, prefix + + +def _app(prefix): + return TestClient(create_app(agent=_NoAgent(), stream_prefix=prefix)) + + +# -- the principal ------------------------------------------------------------- + +def test_anonymous_principal_has_chat_only(): + caps = auth.effective_capabilities(auth.ANONYMOUS, None) + assert caps == {auth.CAP_CHAT} + assert auth.ANONYMOUS.username == auth.ANONYMOUS_USERNAME == "anonymous" + assert auth.ANONYMOUS.role == auth.ROLE_ANONYMOUS + + +def test_public_scope_is_the_enabled_public_entries(env): + client, prefix = env + assert auth.public_scope(client, prefix=prefix) == frozenset({"docs@main"}) # not proton, not disabled old-docs + + +def test_public_scope_fails_closed(): + class Boom: + def query(self, *a, **k): + raise RuntimeError("store down") + assert auth.public_scope(Boom()) == frozenset() + + +def test_resolve_scope_for_anonymous_is_the_public_scope(env): + client, prefix = env + assert auth.resolve_scope(client, auth.ANONYMOUS, prefix=prefix) == frozenset({"docs@main"}) + + +# -- the gate ------------------------------------------------------------------ + +def test_off_by_default_everything_stays_401(env): + client, prefix = env + c = _app(prefix) + assert c.get("/auth/me").status_code == 401 + assert c.get("/chat/usage").status_code == 401 + assert c.post("/chat", json={"message": "hi"}).status_code == 401 + + +def test_on_no_credentials_is_anonymous_for_chat_only(env, monkeypatch): + monkeypatch.setenv("TPK_ANONYMOUS_ACCESS", "1") + client, prefix = env + c = _app(prefix) + me = c.get("/auth/me") + assert me.status_code == 200 + assert me.json() == {"username": "anonymous", "role": "anonymous", "must_change_password": False, + "capabilities": ["chat"], "anonymous": True} + assert c.get("/chat/usage").status_code == 200 + assert c.get("/chat/model").status_code == 200 + # everything that is not `chat` is still closed + assert c.get("/api/graph/search", params={"q": "x"}).status_code in (401, 403, 404) + assert c.get("/api/repos").status_code in (401, 403) + assert c.get("/api/tokens").status_code in (401, 403) + assert c.get("/api/users").status_code in (401, 403) + assert c.post("/auth/logout").status_code == 401 + assert c.post("/auth/change-password", json={"old_password": "a", "new_password": "b"}).status_code == 401 + + +def test_on_a_bad_credential_is_still_401(env, monkeypatch): + monkeypatch.setenv("TPK_ANONYMOUS_ACCESS", "1") + client, prefix = env + c = _app(prefix) + for hdr in ({"Authorization": "Bearer nope"}, {"Authorization": "Basic abc"}, {"Authorization": "Bearer "}): + assert c.get("/auth/me", headers=hdr).status_code == 401, hdr + assert c.post("/chat", json={"message": "hi"}, headers=hdr).status_code == 401, hdr + + +def test_on_a_real_login_still_wins(env, monkeypatch): + monkeypatch.setenv("TPK_ANONYMOUS_ACCESS", "1") + client, prefix = env + c = _app(prefix) + auth.upsert_user(client, auth.User("root", auth.hash_password("adminpass1"), auth.ROLE_ADMIN), prefix=prefix) + login = lambda: c.post("/auth/login", json={"username": "root", "password": "adminpass1"}) + _eventually(lambda: login().status_code == 200) + me = c.get("/auth/me", headers={"Authorization": f"Bearer {login().json()['token']}"}).json() + assert me["username"] == "root" and me.get("anonymous") is not True + + +def test_anonymous_username_is_reserved(env, monkeypatch): + client, prefix = env + c = _app(prefix) + auth.upsert_user(client, auth.User("root", auth.hash_password("adminpass1"), auth.ROLE_ADMIN), prefix=prefix) + login = lambda: c.post("/auth/login", json={"username": "root", "password": "adminpass1"}) + _eventually(lambda: login().status_code == 200) + hdr = {"Authorization": f"Bearer {login().json()['token']}"} + r = c.post("/api/users", headers=hdr, json={"username": "anonymous", "password": "password-1", "role": "admin"}) + assert r.status_code == 400 and "reserved" in r.text + r = c.post("/api/roles", headers=hdr, json={"name": "anonymous", "entry_keys": []}) + assert r.status_code == 400 and "reserved" in r.text + # and a login as "anonymous" can never succeed, even if a row existed + assert c.post("/auth/login", json={"username": "anonymous", "password": "x"}).status_code == 401 diff --git a/tests/test_graphify_runner.py b/tests/test_graphify_runner.py index b46eba3..79c5eb3 100644 --- a/tests/test_graphify_runner.py +++ b/tests/test_graphify_runner.py @@ -49,7 +49,11 @@ def test_parse_drops_edges_with_unknown_endpoints(tmp_path: Path): # documented values directly (no LLM run required to observe them). -def test_doc_kind_node_is_public_even_with_internal_default(tmp_path: Path): +def test_every_node_carries_its_entry_visibility(tmp_path: Path): + # A doc-kind node inside an INTERNAL entry used to be stamped "public" + # (a leftover from before visibility meant anything). Now that anonymous + # access makes visibility a boundary (#94, enforced per entry), the node + # label must not contradict the entry it belongs to. g = tmp_path / "graph.json" g.write_text( json.dumps( @@ -67,8 +71,10 @@ def test_doc_kind_node_is_public_even_with_internal_default(tmp_path: Path): nodes, edges = parse_graph_json(g, repo="r", default_visibility="internal") doc_node = next(n for n in nodes if n.kind == "document") code_node = next(n for n in nodes if n.kind == "file") - assert doc_node.visibility == "public" + assert doc_node.visibility == "internal" assert code_node.visibility == "internal" + nodes, _ = parse_graph_json(g, repo="r", default_visibility="public") + assert {n.visibility for n in nodes} == {"public"} def test_ambiguous_confidence_maps_to_inferred(tmp_path: Path): diff --git a/tests/test_server.py b/tests/test_server.py index 5aa0778..ef2dab7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -645,3 +645,99 @@ def test_chat_admin_gets_source_and_tool_events(): assert "tool" in types and "source" in types done = next(e for e in events if e["type"] == "done") assert len(done["sources"]) == 1 + + +# -- anonymous chat over the public corpus (#94) -------------------------------- + +class _ScopeAgent(FakeAgent): + """Records the corpus scope in force while the agent runs -- what every + tool call would see.""" + + def __init__(self, events): + super().__init__(events) + self.seen_scope = "unset" + + async def astream_events(self, _input, version="v2", config=None): + from tpk.tools import ROLE_SCOPE + self.seen_scope = ROLE_SCOPE.get() + async for e in super().astream_events(_input, version=version, config=config): + yield e + + +class _AnonAuth(_StubAuth): + """Real gate logic (anonymous only when no header + feature on), no store: + `public_scope` is monkeypatched in `_anon_app`, so the client handed to + it is a dummy.""" + + def _resolve(self, authorization): + from fastapi import HTTPException + raise HTTPException(401, "missing bearer token") + + def _client(self): + return object() + + +def _anon_app(monkeypatch, agent, usage=None, sink=None, public=("docs@main",)): + from tpk import auth as auth_mod + monkeypatch.setenv("TPK_ANONYMOUS_ACCESS", "1") + monkeypatch.delenv("TPK_CONFIG", raising=False) + monkeypatch.setattr(auth_mod, "public_scope", lambda client, prefix="": frozenset(public)) + return TestClient(create_app(agent=agent, auth=_AnonAuth(), usage=usage, audit_sink=sink)) + + +def test_anonymous_chat_runs_under_the_public_scope_only(monkeypatch): + agent = _ScopeAgent([_tok("hi")]) + client = _anon_app(monkeypatch, agent) + resp = client.post("/chat", json={"message": "q"}) + assert resp.status_code == 200 + assert agent.seen_scope == frozenset({"docs@main"}) + from tpk.tools import ROLE_SCOPE + assert ROLE_SCOPE.get() is None # reset after the turn + + +def test_anonymous_chat_with_no_public_entries_sees_nothing(monkeypatch): + agent = _ScopeAgent([_tok("hi")]) + client = _anon_app(monkeypatch, agent, public=()) + assert client.post("/chat", json={"message": "q"}).status_code == 200 + assert agent.seen_scope == frozenset() # closed, never None (= unrestricted) + + +def test_anonymous_chat_shares_one_global_budget(monkeypatch): + monkeypatch.setenv("TPK_ANONYMOUS_DAILY_TOKEN_LIMIT", "500") + monkeypatch.setenv("TPK_DAILY_TOKEN_LIMIT", "999999") # the per-user default must NOT apply + usage = _Usage(used=500) + client = _anon_app(monkeypatch, FakeAgent([_tok("hi")]), usage=usage) + resp = client.post("/chat", json={"message": "q"}) + assert resp.status_code == 429 + detail = resp.json()["detail"] + assert detail["limit"] == 500 and detail["anonymous"] is True + assert "sign in" in detail["message"].lower() + + +def test_anonymous_usage_is_metered_under_the_anonymous_name(monkeypatch): + monkeypatch.setenv("TPK_ANONYMOUS_DAILY_TOKEN_LIMIT", "500") + usage = _Usage(used=0) + records = [] + client = _anon_app(monkeypatch, FakeAgent([_tok("hi"), _end_usage(10)]), usage=usage, sink=records.append) + assert client.post("/chat", json={"message": "q"}).status_code == 200 + assert usage.recorded == [("anonymous", 10)] + assert records and records[0].username == "anonymous" + + +def test_anonymous_chat_never_gets_thinking_or_sources(monkeypatch): + client = _anon_app(monkeypatch, _think_agent()) + body = client.post("/chat", json={"message": "q"}).text + assert '"thinking"' not in body and '"source"' not in body + + +def test_anonymous_usage_endpoint_reports_the_shared_budget(monkeypatch): + monkeypatch.setenv("TPK_ANONYMOUS_DAILY_TOKEN_LIMIT", "500") + client = _anon_app(monkeypatch, FakeAgent([]), usage=_Usage(used=120)) + assert client.get("/chat/usage").json() == {"limited": True, "used": 120, "limit": 500, "remaining": 380, + "reset": client.get("/chat/usage").json()["reset"], "anonymous": True} + + +def test_anonymous_limit_zero_disables_anonymous_chat(monkeypatch): + monkeypatch.setenv("TPK_ANONYMOUS_DAILY_TOKEN_LIMIT", "0") + client = _anon_app(monkeypatch, FakeAgent([_tok("hi")]), usage=_Usage(used=0)) + assert client.post("/chat", json={"message": "q"}).status_code == 429 diff --git a/web/src/App.tsx b/web/src/App.tsx index 2650504..386cb6e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,7 +10,7 @@ import Users from "./Users"; import ChangePassword from "./ChangePassword"; import { CAP, type Capability, hasCap } from "./capabilities"; -type Me = { username: string; role: string; capabilities: string[] }; +type Me = { username: string; role: string; capabilities: string[]; anonymous?: boolean }; // Nav order + the capability each view needs, so a caller lands on the first // view they're allowed to see (a chat-less role must not open on Chat). @@ -34,6 +34,8 @@ 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). + const [anonymousAvailable, setAnonymousAvailable] = useState(false); const [pendingChangeUser, setPendingChangeUser] = useState(null); const [checked, setChecked] = useState(false); @@ -67,15 +69,19 @@ export default function App() { useEffect(() => { let cancelled = false; (async () => { - if (!getToken()) { setChecked(true); return; } + // No token: /auth/me still answers 200 when anonymous access is on + // (#94) -- that is how the login page learns to offer "continue + // without signing in". A 401 there is the normal signed-out state. + const hadToken = !!getToken(); try { - const resp = await apiFetch("/auth/me"); + const resp = await apiFetch("/auth/me", {}, { skip401Handling: !hadToken }); if (cancelled) return; if (resp.ok) { const body = await resp.json(); + if (body.anonymous) { setAnonymousAvailable(true); } // A must_change_password answer routes straight to Login's change // mode (skipping the login form — we already hold a valid token). - if (body.must_change_password) setPendingChangeUser(body.username); + else if (body.must_change_password) setPendingChangeUser(body.username); else setMe({ username: body.username, role: body.role, capabilities: body.capabilities ?? [] }); } @@ -104,7 +110,7 @@ export default function App() { async function logout() { try { - await apiFetch("/auth/logout", { method: "POST" }); + if (!me?.anonymous) await apiFetch("/auth/logout", { method: "POST" }); } catch { // Even if the network request fails, always clear the local session // so the user isn't stranded in the authenticated view. @@ -130,7 +136,7 @@ export default function App() { if (!me) { return (
- +
); } diff --git a/web/src/Chat.tsx b/web/src/Chat.tsx index 8f43284..a055374 100644 --- a/web/src/Chat.tsx +++ b/web/src/Chat.tsx @@ -196,7 +196,7 @@ function SourceCard({ n, source }: { n?: number; source: SourceEventPayload }) { // Daily token budget for the current user (#62). `limited: false` for admins, // unlimited roles, or when no budget is enforced -> no indicator shown. type UsageInfo = - | { limited: true; used: number; limit: number; remaining: number; reset: string } + | { limited: true; used: number; limit: number; remaining: number; reset: string; anonymous?: boolean } | { limited: false } | null; @@ -561,7 +561,7 @@ export default function Chat({ )} {usage?.limited && ( <> -
daily tokens
+
{usage.anonymous ? "shared daily tokens (anonymous)" : "daily tokens"}
{usage.used.toLocaleString()} / {usage.limit.toLocaleString()} diff --git a/web/src/Login.tsx b/web/src/Login.tsx index cd8f6dc..de6505f 100644 --- a/web/src/Login.tsx +++ b/web/src/Login.tsx @@ -2,13 +2,14 @@ import { useState } from "react"; import { apiFetch, setToken } from "./api"; import { useServerVersion, versionLabel } from "./version"; -type Me = { username: string; role: string; capabilities: string[] }; +type Me = { username: string; role: string; capabilities: string[]; anonymous?: boolean }; async function fetchMe(): Promise { const resp = await apiFetch("/auth/me"); if (!resp.ok) throw new Error(`could not load profile (HTTP ${resp.status})`); const body = await resp.json(); - return { username: body.username, role: body.role, capabilities: body.capabilities ?? [] }; + return { username: body.username, role: body.role, capabilities: body.capabilities ?? [], + anonymous: body.anonymous === true }; } /** @@ -23,10 +24,13 @@ async function fetchMe(): Promise { */ export default function Login({ onDone, + anonymousAvailable = false, initialMode = "login", initialUsername = "", }: { onDone: (me: Me) => void; + // The server serves unauthenticated chat over the public corpus (#94). + anonymousAvailable?: boolean; initialMode?: "login" | "change"; initialUsername?: string; }) { @@ -129,6 +133,18 @@ export default function Login({ + {anonymousAvailable && ( + + )} ) : (
diff --git a/web/src/Manage.tsx b/web/src/Manage.tsx index 3ea199d..aaa96d0 100644 --- a/web/src/Manage.tsx +++ b/web/src/Manage.tsx @@ -392,6 +392,12 @@ export default function Manage({ capabilities }: { capabilities: string[] }) { + {form.visibility === "public" && ( +
+ Public entries are visible to unauthenticated users whenever anonymous access is + enabled — and this cannot be changed after the entry is created. +
+ )}
diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index d40624c..ff6269f 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -8,7 +8,7 @@ import { CAP, type Capability, hasCap } from "./capabilities"; // stays outside (App.tsx renders it on the centered `.shell`). export type View = "chat" | "explorer" | "manage" | "users" | "tokens"; -type Me = { username: string; role: string; capabilities: string[] }; +type Me = { username: string; role: string; capabilities: string[]; anonymous?: boolean }; const NAV_ITEMS: { key: View; label: string; cap: Capability }[] = [ { key: "chat", label: "Chat", cap: CAP.chat }, @@ -150,6 +150,16 @@ export default function Shell({ {versionLabel(serverVersion)}
)} + {me.anonymous ? ( +
+
+ Browsing the public docs +
+ +
+ ) : (
+ )}
{children}
diff --git a/web/src/api.ts b/web/src/api.ts index 37d3877..80b4330 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -42,7 +42,10 @@ export async function apiFetch( // response on an otherwise-still-valid session (e.g. change-password // rejecting a wrong old password) rather than session expiry — clearing // the token there would strand the user with no way to retry. - if (resp.status === 401 && !opts.skip401Handling) { + // A 401 means "your session is gone" only when there WAS a session token + // to lose. An anonymous session (#94) holds no token and gets 401 from + // every non-chat endpoint by design; that must not bounce it to login. + if (resp.status === 401 && !opts.skip401Handling && token) { setToken(null); unauthorized?.(); } diff --git a/web/src/app.css b/web/src/app.css index 2def68e..58bf652 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -92,7 +92,7 @@ h2 { font-size: 14px; font-weight: 600; margin-top: 24px; color: var(--gray-200) /* Login (mockup 1g): sign-in card + forced password-change card. Reuses .tk-card / .tk-btn / .tk-input / .tk-logo-mark; the classes below cover only what those shared components don't already provide. */ -.login-shell { flex: 1; display: flex; align-items: center; justify-content: center; } +.login-shell { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; } .login-card { width: 320px; display: flex; flex-direction: column; gap: 16px; } .login-header { display: flex; align-items: center; gap: 10px; } .login-title { font-size: 18px; font-weight: 600; color: var(--gray-200); } @@ -902,3 +902,13 @@ h2 { font-size: 14px; font-weight: 600; margin-top: 24px; color: var(--gray-200) .tk-version { font-size: 11px; color: var(--gray-500); padding: 4px 12px 0; user-select: text; } .tk-sidebar.collapsed .tk-version { display: none; } .login-version { padding: 12px 0 0; text-align: center; } + +/* Anonymous access (#94): login-page entry point, sidebar badge, public-entry warning. */ +.login-anonymous { margin-top: 8px; width: 100%; display: flex; flex-direction: column; align-items: center; gap: 2px; } +.login-anonymous-sub { font-size: 11px; color: var(--gray-500); font-weight: 400; } +.tk-account-anonymous { display: flex; flex-direction: column; gap: 8px; padding: 8px 4px; } +.tk-account-anon-badge { font-size: 12px; color: var(--gray-500); } +.tk-sidebar.collapsed .tk-account-anon-badge { display: none; } +.tk-account-signin { width: 100%; } +.tk-form-warning { color: var(--red-500); } +