Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions engraphis/dashboard_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions engraphis/routes/v2_team.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep setup closed until Team is active

With the default now returning true, attach() mounts the public /api/auth/setup route even on a no-license install where /api/auth/state reports enabled: false. Since setup() allows the first admin to be created without a Team license and /api/auth/setup is in the dashboard's public allow-list, anyone who can reach a default open dashboard can pre-seed an admin account; once a Team license/trial is later activated, setup is blocked and that account becomes the team owner. Gate setup on an active Team entitlement or keep auth routes unmounted until Team is actually active.

Useful? React with 👍 / 👎.



def _users_db_path(db_path: str) -> str:
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion engraphis/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<div class="card teaser"><div class="card-head">Team mode</div><div style="font-size:13px;color:var(--text-muted);margin-bottom:10px">Multi-user access with admin / member / viewer roles, PBKDF2 logins and per-seat keys.</div>${paid?'<div class="field-hint">Enable by starting the dashboard with ENGRAPHIS_TEAM_MODE=1.</div>':'<div style="display:flex;gap:6px;flex-wrap:wrap"><button class="btn btn-primary btn-sm" onclick="startTeamTrial()">Start free Team trial</button><button class="btn btn-ghost btn-sm" onclick="navTo(\'settings\')">Team — Unlock</button></div>'}</div>`}
function teamTeaser(st){const paid=LIC&&(LIC.features||[]).includes('team');return `<div class="card teaser"><div class="card-head">Team mode</div><div style="font-size:13px;color:var(--text-muted);margin-bottom:10px">Multi-user access with admin / member / viewer roles, PBKDF2 logins and per-seat keys.</div>${paid?'<div class="field-hint">Team mode is enabled by default; set ENGRAPHIS_TEAM_MODE=0 to opt out.</div>':'<div style="display:flex;gap:6px;flex-wrap:wrap"><button class="btn btn-primary btn-sm" onclick="startTeamTrial()">Start free Team trial</button><button class="btn btn-ghost btn-sm" onclick="navTo(\'settings\')">Team — Unlock</button></div>'}</div>`}
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=`<div class="field"><label class="field-lbl">Email</label><input class="input" id="au-email"></div>${first?'<div class="field"><label class="field-lbl">Name</label><input class="input" id="au-name"></div>':''}<div class="field"><label class="field-lbl">Password</label><input class="input" type="password" id="au-pass" onkeydown="if(event.key==='Enter')doAuth(${first})"></div><button class="btn btn-primary" style="width:100%" onclick="doAuth(${first})">${first?'Create admin':'Sign in'}</button>${first?'':'<div style="text-align:center;margin-top:10px"><a href="#" onclick="showForgot();return false" style="color:var(--accent);font-size:12px">Forgot password?</a></div>'}<div style="text-align:center;margin-top:6px"><a href="#" onclick="closeAuth();return false" style="color:var(--text-dim);font-size:12px">Skip for now</a></div>`}
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
Expand Down
10 changes: 5 additions & 5 deletions tests/test_agent_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
38 changes: 38 additions & 0 deletions tests/test_dashboard_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"
Expand Down