diff --git a/engraphis/dashboard_app.py b/engraphis/dashboard_app.py index 0b67f0b..7f6f87a 100644 --- a/engraphis/dashboard_app.py +++ b/engraphis/dashboard_app.py @@ -27,12 +27,13 @@ _INDEX = _STATIC / "index.html" # Reachable without any session/token in every mode: the page shell, liveness, and -# the auth bootstrap endpoints themselves (state/login/setup must work while logged -# out) — same shape as engraphis/inspector/app.py's _PUBLIC set. +# the auth bootstrap endpoints themselves (state/login/setup must be reachable while +# logged out; setup still refuses to create the first admin until Team is active) — same +# shape as engraphis/inspector/app.py's _PUBLIC set. # -# The /api/license and /api/license/*-trial entries close a real deadlock: create_user() -# (called by /api/auth/setup) requires require_feature("team"), so a brand-new team-mode -# instance with zero users can't create its first admin without an active license — but +# The /api/license and /api/license/*-trial entries close a real deadlock: /api/auth/setup +# requires an active Team entitlement, so a brand-new team-mode instance with zero users +# can't create its first admin without an active license — but # before this fix, obtaining that license (reading /api/license, or starting a Pro/Team # trial) itself required an authenticated team session, which is impossible with zero users. # Every visitor hit a 401 the instant they touched Settings → License or clicked "Start diff --git a/engraphis/routes/v2_team.py b/engraphis/routes/v2_team.py index 81977d2..5cea5a7 100644 --- a/engraphis/routes/v2_team.py +++ b/engraphis/routes/v2_team.py @@ -128,7 +128,8 @@ class TokenReq(BaseModel): def _enabled() -> bool: - return os.environ.get("ENGRAPHIS_TEAM_MODE", "").lower() in {"1", "true", "yes", "on"} + value = os.environ.get("ENGRAPHIS_TEAM_MODE", "").strip().lower() + return value not in {"0", "false", "no", "off"} def _users_db_path(db_path: str) -> str: @@ -188,13 +189,24 @@ def _require(request: Request, minimum: str = "viewer") -> dict: def state(request: Request): # `enabled` reflects whether the login wall is actually active (needs a team # license, checked live), so the UI shows the open/solo experience until a - # license is added and the enforced mode the moment one is present. - return {"enabled": licensing.has_feature("team"), - "needs_setup": store.count_users() == 0, + # license is added and the enforced mode the moment one is present. The setup + # prompt is likewise hidden until Team is active; otherwise a public dashboard + # could be pre-seeded with an attacker-owned first admin before the owner starts + # a Team trial or installs a Team key. + active = licensing.has_feature("team") + return {"enabled": active, + "needs_setup": active and store.count_users() == 0, "user": _user(request)} @router.post("/setup") def setup(body: SetupReq, request: Request, response: Response): + if not licensing.has_feature("team"): + raise HTTPException(status_code=402, detail={ + "error": "Team setup requires an active Team license", + "feature": "team", + "tier_required": licensing.required_plan("team"), + "upgrade_url": licensing.upgrade_url(), + }) if store.count_users() > 0: raise HTTPException(status_code=400, detail={"error": "team already set up"}) try: diff --git a/engraphis/static/index.html b/engraphis/static/index.html index 922f67d..ac50eaf 100644 --- a/engraphis/static/index.html +++ b/engraphis/static/index.html @@ -541,7 +541,7 @@ toast('Signed out','ok');boot()}catch(x){toast(x.message,'err')}} function updateSessionIndicator(user){const sd=document.getElementById('session-dot'),st=document.getElementById('session-text');if(!sd||!st)return;if(user){sd.style.background='var(--accent)';st.textContent=user.email.split('@')[0];st.title='Signed in as '+user.email}else{sd.style.background='var(--text-dim)';st.textContent='Sign in';st.title=''}} async function onSessionIndicatorClick(){try{const st=await api('/auth/state');if(!st.enabled)return;if(st.user){navTo('team');return}showAuth(st)}catch(e){}} -function teamTeaser(st){const paid=LIC&&(LIC.features||[]).includes('team');return `
Team mode
Multi-user access with admin / member / viewer roles, PBKDF2 logins and per-seat keys.
${paid?'
Enable by starting the dashboard with ENGRAPHIS_TEAM_MODE=1.
':'
'}
`} +function teamTeaser(st){const paid=LIC&&(LIC.features||[]).includes('team');return `
Team mode
Multi-user access with admin / member / viewer roles, PBKDF2 logins and per-seat keys.
${paid?'
Team mode is enabled by default; set ENGRAPHIS_TEAM_MODE=0 to opt out.
':'
'}
`} function showAuth(st){AUTH_MODE=(st&&st.needs_setup)?'setup':'login';const ov=document.getElementById('auth-overlay');ov.classList.add('show');const first=st&&st.needs_setup;document.getElementById('auth-title').textContent=first?'Create admin account':'Sign in';document.getElementById('auth-body').innerHTML=`
${first?'
':''}
${first?'':'
Forgot password?
'}
Skip for now
`} async function doAuth(first){const email=(document.getElementById('au-email')||{}).value,pass=(document.getElementById('au-pass')||{}).value,name=(document.getElementById('au-name')||{}).value;try{if(first){await api('/auth/setup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,name,password:pass})})}else{await api('/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password:pass})})}AUTH_SKIPPED=false;document.getElementById('auth-overlay').classList.remove('show');renderAuthBanner(false);toast('Signed in','ok');boot()}catch(e){toast(e.message,'err')}} /* forgot / reset password — same overlay, swapped body; AUTH_MODE tracks which form is diff --git a/tests/test_agent_connect.py b/tests/test_agent_connect.py index fca9cc5..84fb4a1 100644 --- a/tests/test_agent_connect.py +++ b/tests/test_agent_connect.py @@ -107,13 +107,13 @@ def test_remember_with_bearer_writes_to_cloud(monkeypatch, tmp_path): def test_remember_requires_team_license_402(monkeypatch, tmp_path): - # team mode ON but no Team license -> agent write is 402 ("need a team license") + # No Team license -> agent writes are 402 even when authenticated with the + # instance service token. Per-user token minting itself is unavailable until Team + # setup is unlocked by an active Team entitlement. + monkeypatch.setattr(settings, "api_token", "service-token") with _client(monkeypatch, tmp_path, key=None) as c: - _setup_admin(c) # bootstrap admin is exempt from the license gate - token = _mint(c)["token"] - c.cookies.clear() r = c.post("/api/remember", json={"content": "x", "workspace": "demo"}, - headers={"Authorization": f"Bearer {token}"}) + headers={"Authorization": "Bearer service-token"}) assert r.status_code == 402 assert r.json()["detail"]["feature"] == "team" diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py index 8749f84..845c762 100644 --- a/tests/test_dashboard_v2.py +++ b/tests/test_dashboard_v2.py @@ -67,6 +67,40 @@ def test_dashboard_serves_and_bootstraps(monkeypatch, tmp_path): assert b["license"]["plan"] == "free" +@pytest.mark.parametrize("raw", ["0", " false ", "NO", "Off"]) +def test_team_mode_env_opt_out_parsing(monkeypatch, raw): + from engraphis.routes.v2_team import _enabled + monkeypatch.setenv("ENGRAPHIS_TEAM_MODE", raw) + assert _enabled() is False + + +def test_team_mode_defaults_on_but_auth_wall_waits_for_team_license(monkeypatch, tmp_path): + db = str(tmp_path / "dash.db") + monkeypatch.setattr(settings, "db_path", db) + monkeypatch.setattr(settings, "embed_model", "") + monkeypatch.setenv("ENGRAPHIS_EMBED_MODEL", "") + monkeypatch.delenv("ENGRAPHIS_TEAM_MODE", raising=False) + monkeypatch.setattr(lic, "_LICENSE_FILE", tmp_path / "license.key") + monkeypatch.delenv("ENGRAPHIS_LICENSE_KEY", raising=False) + lic.current_license(refresh=True) + svc = _seed(db) + from engraphis.routes import v2_api + v2_api.set_service(svc) + from engraphis.dashboard_app import create_app + with TestClient(create_app()) as c: + assert c.get("/api/auth/state").json() == { + "enabled": False, + "needs_setup": False, + "user": None, + } + assert c.post("/api/auth/setup", json={ + "email": "w@x.co", + "name": "W", + "password": "supersecret1", + }).status_code == 402 + assert c.get("/api/bootstrap").status_code == 200 + + def test_recall_why_timeline_and_detail(monkeypatch, tmp_path): with _client(monkeypatch, tmp_path) as c: r = c.get("/api/recall?q=database&workspace=demo").json() @@ -587,6 +621,10 @@ def test_team_trial_reachable_with_zero_users_and_no_session(monkeypatch, tmp_pa # reading license state pre-login must not 401 r0 = c.get("/api/license") assert r0.status_code == 200 and r0.json()["plan"] == "free" + # first-admin setup stays closed until Team is active, even though the route is public + locked = c.post("/api/auth/setup", json={"email": "w@x.co", "name": "W", + "password": "supersecret1"}) + assert locked.status_code == 402 # starting the Team trial pre-login must not 401 either r1 = c.post("/api/license/team-trial", json={"email": "w@x.co"}) assert r1.status_code == 200 and r1.json()["plan"] == "team"