From 75a0b735fd52b6fa1ce054a0f2a3b46b32edab64 Mon Sep 17 00:00:00 2001 From: coding-dev-tools Date: Tue, 14 Jul 2026 20:26:26 -0400 Subject: [PATCH] feat(agent-connect): per-user API tokens + Team-gated /api/remember MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team members connect agents to a hosted Engraphis instance and store memories in the cloud v2 store instead of running locally. - auth: per-user API tokens (api_tokens table) — SHA-256 hashed at rest, raw token returned once; resolve rejects revoked + disabled-owner tokens. - v2_team: POST /api/auth/token, GET /api/auth/tokens, DELETE /api/auth/token/{id}, GET /api/auth/connect-info (verify token + discover API base/snippet). - dashboard_app._auth_gate: accept a per-user bearer token exactly like a cookie session (bound to the member for role + personal-folder authz); try bearer then cookie. Instance ENGRAPHIS_API_TOKEN bypass unchanged. - v2_api: POST /api/remember gated by _paid('team') (402 without a Team license), params mirroring the local engraphis_remember MCP tool; writes hit the same v2 store the dashboard reads. - docs/AGENT_CONNECT.md; CHANGELOG [Unreleased]. - tests/test_agent_connect.py: 7 tests (token lifecycle, bearer write+recall, 402 without Team license, 401 without auth, disabled-user token rejected, instance-token bypass). Suite: 701 passed, 3 skipped. MCP-over-HTTP at /mcp is deferred (needs mcp_server service-injection to avoid a second SQLite writer); agents use the HTTP API for now. --- CHANGELOG.md | 16 ++++ docs/AGENT_CONNECT.md | 114 +++++++++++++++++++++++ engraphis/dashboard_app.py | 11 ++- engraphis/inspector/auth.py | 67 ++++++++++++++ engraphis/routes/v2_api.py | 33 +++++++ engraphis/routes/v2_team.py | 62 +++++++++++++ tests/test_agent_connect.py | 178 ++++++++++++++++++++++++++++++++++++ 7 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 docs/AGENT_CONNECT.md create mode 100644 tests/test_agent_connect.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8247c74..baa2a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to Engraphis are documented here. Format loosely follows ## [Unreleased] +### Added +- **Agent Connect (Team): connect agents to a hosted instance instead of running locally.** + Team members mint a per-user API token from the dashboard and point their agent at the + instance's HTTP API to store memories in the cloud v2 store (the same DB the dashboard + reads). New: `POST /api/remember` (Team-gated, mirrors the local `engraphis_remember` + MCP tool's params), per-user bearer-token endpoints (`POST /api/auth/token`, + `GET /api/auth/tokens`, `DELETE /api/auth/token/{id}`), and `GET /api/auth/connect-info` + to verify a token + discover the API base. The dashboard auth gate now accepts a + per-user bearer token exactly like a cookie session (bound to the member for role + + personal-folder authz); disabled members' tokens are refused instantly. Tokens are + SHA-256 hashed at rest (raw token shown once). A free / lapsed instance returns `402` + on `/api/remember`, so a Team license is required to host team agents. See + `docs/AGENT_CONNECT.md`. (`tests/test_agent_connect.py`.) + *Note:* MCP-over-HTTP at `/mcp` is deferred to a follow-up (needs a service-injection + refactor of `mcp_server.py` to avoid a second SQLite writer); agents use the HTTP API. + ## [0.9.5] - 2026-07-14 ### Changed diff --git a/docs/AGENT_CONNECT.md b/docs/AGENT_CONNECT.md new file mode 100644 index 0000000..39b00a4 --- /dev/null +++ b/docs/AGENT_CONNECT.md @@ -0,0 +1,114 @@ +# Agent Connect — point your agent at a hosted Engraphis instance + +Team members can connect their coding agents (Claude Code, Cursor, any HTTP-capable +agent) **directly to a hosted Engraphis dashboard** and store memories in the cloud +instance instead of running Engraphis locally. A Team license (the instance's) is +required to write — a free / lapsed instance refuses agent writes with `402`. + +This is the team counterpart to the local-first model in [SYNC.md](./SYNC.md): instead +of each member running a local MCP server + syncing, one admin hosts a single instance +(e.g. on Railway) and everyone else just connects. + +## How it works + +1. **Admin** deploys one instance (see the Dockerfile / `docker compose up`) and activates + a **Team license** (Settings → License → paste key). The Team key sets the seat cap. +2. **Admin** invites members (Add member → email + password). Each member is a seat. +3. **Member** signs in at the dashboard URL (e.g. `https://team.engraphis.com/`) with + email + password — no key, no local install. (Login is never license-gated, so a lapsed + key never locks people out of the UI.) +4. **Member** opens **Settings → API tokens → Create token**, copies the bearer token. +5. **Member** configures their agent to call the instance's HTTP API with that token. + +## Agent authentication + +Agents authenticate with a **per-user bearer token** (`Authorization: Bearer `), +minted from the dashboard. The token is bound to the member: their role, their personal +folders, and their seat. Disabling the member instantly invalidates their token. + +Token management (requires a browser session): + +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/api/auth/token` `{label}` | Mint a token (raw token returned **once**) | +| `GET` | `/api/auth/tokens` | List your tokens (never includes the raw token) | +| `DELETE` | `/api/auth/token/{id}` | Revoke one of your tokens | +| `GET` | `/api/auth/connect-info` | Verify a token + discover the API base / snippet | + +`GET /api/auth/connect-info` works with either a cookie or a bearer, so an agent can hit +it first to confirm its token is valid and learn the base URL: + +```bash +curl -H "Authorization: Bearer " https://team.engraphis.com/api/auth/connect-info +``` + +## The agent write/read API + +| Method | Path | Notes | +|---|---|---| +| `POST` | `/api/remember` | **Team-gated** (402 without a Team license). Same params as the local `engraphis_remember` MCP tool. | +| `GET` | `/api/recall?q=…&workspace=…` | Read (not gated). | +| `GET` | `/api/memory/{id}?workspace=…` | One memory. | +| `GET` | `/api/why?q=…&workspace=…` / `/api/timeline?…` | Provenance / history. | + +`POST /api/remember` body (all optional except `content`): + +```json +{ + "content": "We use pnpm for all frontend repos.", + "workspace": "default", + "repo": null, + "mtype": "semantic", + "scope": "repo", + "title": "", + "importance": 0.0, + "keywords": null, + "metadata": null, + "source": "agent", + "trusted": true, + "dedupe": true +} +``` + +Example: + +```bash +curl -X POST https://team.engraphis.com/api/remember \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"content":"Redis caches the gateway.","workspace":"default"}' + +curl "https://team.engraphis.com/api/recall?q=Redis&workspace=default" \ + -H "Authorization: Bearer " +``` + +Writes go to the **same v2 store the dashboard reads** — there is no separate agent DB, +so memories written by an agent immediately appear in the UI and in every other member's +recall (subject to workspace / personal-folder scoping). + +## "They need a Team license to connect" + +The Team license is the **instance's** (the admin activates one key; the relay enforces +the seat cap server-side). Members never present a license to log in or connect — they +present a **seat** (an account) and a **token**. The `402` on `POST /api/remember` fires +only when the **instance** has no active Team key (or it has lapsed / been revoked), which +is exactly "a Team license is required to host team agents." + +## Security notes + +- Tokens are stored **SHA-256 hashed** (like session cookies); a leaked users DB contains + no usable bearer secrets. The raw token is shown **once** at creation. +- Disable a member → their tokens stop resolving immediately (no need to revoke each). +- Expose the instance over **HTTPS** only (the session/token cookies and bearer tokens + must not transit cleartext). Behind Railway/a proxy, set + `ENGRAPHIS_FORWARDED_ALLOW_IPS=*` so the `Secure` flag is applied. +- The agent endpoints are rate-limit candidates for high-write deployments (the trial + endpoint already rate-limits; mirror that pattern if you expose this publicly). + +## MCP-over-HTTP (`/mcp`) + +A streamable-HTTP MCP endpoint at `/mcp` (so MCP-native agents point one URL at the cloud +instance) is planned but **not yet mounted** — mounting it as-is would spin up a second +writer to the same SQLite (WAL lock contention). It needs a service-injection refactor of +`engraphis/mcp_server.py` so it shares the dashboard's `MemoryService`. Until then, agents +use the HTTP API above (most agents can call HTTP via a tool/MCP-shell). \ No newline at end of file diff --git a/engraphis/dashboard_app.py b/engraphis/dashboard_app.py index cd7cd9e..0b67f0b 100644 --- a/engraphis/dashboard_app.py +++ b/engraphis/dashboard_app.py @@ -136,7 +136,16 @@ async def _auth_gate(request: Request, call_next): if team_enabled and auth_store is not None and licensing.has_feature("team"): from engraphis.inspector.auth import min_role, role_at_least from engraphis.routes.v2_team import _COOKIE - user = auth_store.resolve_session(request.cookies.get(_COOKIE, "")) + # Agent connect: a per-user API bearer token (minted via POST /api/auth/token) + # authenticates exactly like a cookie session, but for headless agents. Try it + # first so an agent with no cookie is still bound to its member identity, then + # fall back to the browser session cookie. Either way the resolved member is + # bound via set_current_user so personal-folder ownership holds on every + # workspace-scoped read/write. + supplied = (request.headers.get("Authorization") or "").removeprefix("Bearer ").strip() + user = auth_store.resolve_api_token(supplied) if supplied else None + if user is None: + user = auth_store.resolve_session(request.cookies.get(_COOKIE, "")) if user is None: return JSONResponse({"error": "authentication required", "auth": "team"}, status_code=401) diff --git a/engraphis/inspector/auth.py b/engraphis/inspector/auth.py index f84045f..063032c 100644 --- a/engraphis/inspector/auth.py +++ b/engraphis/inspector/auth.py @@ -69,6 +69,20 @@ used INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_reset_user ON password_resets(user_id); +-- Per-user API tokens (agent connect): a Team member mints a long-lived bearer token +-- from the dashboard and pastes it into their agent's config. Only the SHA-256 hash is +-- stored (like session tokens), so a leaked users DB contains no usable secrets. +CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + label TEXT NOT NULL DEFAULT '', + token_hash TEXT NOT NULL UNIQUE, + created_at REAL NOT NULL, + last_used_at REAL, + revoked INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_api_token_user ON api_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_api_token_hash ON api_tokens(token_hash); CREATE TABLE IF NOT EXISTS audit_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL NOT NULL, @@ -462,6 +476,59 @@ def revoke_user_sessions(self, user_id: str) -> None: self.conn.execute("DELETE FROM auth_sessions WHERE user_id=?", (user_id,)) self.conn.commit() + # ── per-user API tokens (agent connect) ───────────────────────────────────── + def create_api_token(self, user_id: str, *, label: str = "") -> dict: + """Mint a long-lived per-user bearer token for an agent/automation client. + + The raw token is returned ONCE; only its SHA-256 hash is persisted (see + :data:`api_tokens`), so a stolen users DB yields no usable secrets. Bound to + ``user_id``; a disabled user's tokens are refused by :meth:`resolve_api_token`. + """ + tok = secrets.token_urlsafe(32) + tid = "tok_" + secrets.token_hex(8) + now = time.time() + label = (label or "")[:120] + self.conn.execute( + "INSERT INTO api_tokens (id, user_id, label, token_hash, created_at) " + "VALUES (?,?,?,?,?)", (tid, user_id, label, _hash_token(tok), now)) + self.conn.commit() + return {"id": tid, "label": label, "created_at": now, + "last_used_at": None, "revoked": 0, "token": tok} + + def resolve_api_token(self, token: str) -> Optional[dict]: + """Resolve a bearer API token to its user, or ``None``. Rejects revoked tokens + and tokens whose owner is disabled. Best-effort stamps ``last_used_at``.""" + if not token: + return None + row = self.conn.execute( + "SELECT t.id, t.user_id, t.revoked, u.disabled FROM api_tokens t " + "JOIN users u ON u.id = t.user_id WHERE t.token_hash=?", + (_hash_token(token),)).fetchone() + if row is None or row["revoked"] or row["disabled"]: + return None + try: + self.conn.execute("UPDATE api_tokens SET last_used_at=? WHERE id=?", + (time.time(), row["id"])) + self.conn.commit() + except sqlite3.Error: + pass + return self.get_user(row["user_id"]) + + def list_api_tokens(self, user_id: str) -> list: + rows = self.conn.execute( + "SELECT id, label, created_at, last_used_at, revoked FROM api_tokens " + "WHERE user_id=? ORDER BY created_at DESC", (user_id,)).fetchall() + return [dict(r) for r in rows] + + def revoke_api_token(self, user_id: str, token_id: str) -> bool: + """Revoke one of *user_id*'s own tokens (scoped to the caller so a member can't + revoke another member's). Returns True if a row was affected.""" + cur = self.conn.execute( + "UPDATE api_tokens SET revoked=1 WHERE id=? AND user_id=? AND revoked=0", + (token_id, user_id)) + self.conn.commit() + return cur.rowcount > 0 + # ── team audit log ───────────────────────────────────────────────────────── def record_event(self, action: str, *, actor_id: Optional[str] = None, actor_email: Optional[str] = None, target: Optional[str] = None, diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py index 5581687..d8bd587 100644 --- a/engraphis/routes/v2_api.py +++ b/engraphis/routes/v2_api.py @@ -507,6 +507,39 @@ def merge(req: _MergeReq): title=req.title, mtype=req.memory_type, reason=req.reason) +# ── agent connect (Team) ─────────────────────────────────────────────────────── +# The remote agent write path. An agent authenticates with a per-user bearer token +# (POST /api/auth/token) and stores memories on THIS cloud instance's v2 store — the +# same DB the dashboard reads — instead of running Engraphis locally. Gated by the +# instance's Team license (``_paid('team')``) so a free / lapsed instance can't host +# team agents: 402 without it. Workspace scoping + personal-folder ownership come from +# the auth gate's ``set_current_user`` (see dashboard_app._auth_gate). Parameters mirror +# the local MCP ``engraphis_remember`` tool so an agent gets identical semantics whether +# it writes locally or to the cloud. +class _RememberReq(BaseModel): + content: str + workspace: str = "default" + repo: Optional[str] = None + mtype: str = "semantic" + scope: str = "repo" + title: str = "" + importance: float = 0.0 + keywords: Optional[list] = None + metadata: Optional[dict] = None + source: str = "agent" + trusted: bool = True + dedupe: bool = True + + +@router.post("/remember") +def remember(req: _RememberReq): + _paid("team") + return _run(service().remember, req.content, workspace=req.workspace, + repo=req.repo, mtype=req.mtype, scope=req.scope, title=req.title, + importance=req.importance, keywords=req.keywords, metadata=req.metadata, + source=req.source, trusted=req.trusted, resolve_conflicts=req.dedupe) + + # ── consolidate ─────────────────────────────────────────────────────────────── class _ConsolidateReq(BaseModel): workspace: Optional[str] = None diff --git a/engraphis/routes/v2_team.py b/engraphis/routes/v2_team.py index f5837d8..81977d2 100644 --- a/engraphis/routes/v2_team.py +++ b/engraphis/routes/v2_team.py @@ -122,6 +122,11 @@ class DelUserReq(BaseModel): user_id: str +class TokenReq(BaseModel): + label: str = Field(default="", max_length=120, + description="A memorable name for this agent token (e.g. 'claude-code-laptop').") + + def _enabled() -> bool: return os.environ.get("ENGRAPHIS_TEAM_MODE", "").lower() in {"1", "true", "yes", "on"} @@ -380,5 +385,62 @@ def overview(request: Request): "activity": store.action_counts(), "events_total": store.count_events()} + # ── per-user API tokens (agent connect) ───────────────────────────────────── + # A signed-in member mints a long-lived bearer token here (from the dashboard UI) + # and pastes it into their agent's config. The token authenticates the agent to + # /api/* exactly like a cookie session would (see dashboard_app._auth_gate), bound + # to that member for personal-folder authz and role enforcement. The Team-license + # gate itself lives on the agent endpoints (e.g. POST /api/remember -> _paid('team')). + + @router.post("/token") + def create_token(body: TokenReq, request: Request): + u = _require(request, "viewer") + row = store.create_api_token(u["id"], label=body.label) + ip = request.client.host if request.client else None + store.record_event("api_token.created", actor_id=u["id"], actor_email=u["email"], + detail=row["label"] or "(unlabelled)", ip=ip) + # ``token`` is returned ONCE; list_api_tokens below never includes it. + return row + + @router.get("/tokens") + def list_tokens(request: Request): + u = _require(request, "viewer") + return {"tokens": store.list_api_tokens(u["id"])} + + @router.delete("/token/{token_id}") + def revoke_token(token_id: str, request: Request): + u = _require(request, "viewer") + ok = store.revoke_api_token(u["id"], token_id) + if not ok: + raise HTTPException(status_code=404, detail={"error": "token not found"}) + ip = request.client.host if request.client else None + store.record_event("api_token.revoked", actor_id=u["id"], actor_email=u["email"], + target=token_id, ip=ip) + return {"ok": True} + + @router.get("/connect-info") + def connect_info(request: Request): + """Who am I + the base URL/config an agent should use. Works with either a + cookie session (browser) or a per-user bearer token (agent verifying itself).""" + u = getattr(request.state, "user", None) or _user(request) + if not u: + raise HTTPException(status_code=401, detail={"error": "authentication required"}) + base = os.environ.get("ENGRAPHIS_DASHBOARD_URL", "").strip().rstrip("/") + if not base: + base = str(request.base_url).rstrip("/") + return { + "user": {"id": u["id"], "email": u["email"], "name": u.get("name", ""), + "role": u["role"]}, + "api_base": base + "/api", + "dashboard_url": base, + # A ready-to-paste snippet for an HTTP-capable agent/MCP-shell: + "snippet": ( + f"ENGRAPHIS_API_URL={base}/api\n" + f"Authorization: Bearer \n" + f"POST {base}/api/remember {{\"content\": \"...\", \"workspace\": \"default\"}}\n" + f"GET {base}/api/recall?q=...&workspace=default"), + "mcp_over_http": False, # /mcp mount is a follow-up; agents use HTTP today. + } + app.include_router(router) return True, store diff --git a/tests/test_agent_connect.py b/tests/test_agent_connect.py new file mode 100644 index 0000000..fca9cc5 --- /dev/null +++ b/tests/test_agent_connect.py @@ -0,0 +1,178 @@ +"""Agent connect: per-user API tokens + the Team-gated ``/api/remember`` write path. + +A Team member mints a long-lived bearer token from the dashboard and uses it to store +memories on the cloud instance (no local Engraphis install). The write endpoint requires +the instance to hold an active Team license — a free / lapsed instance returns 402, so +"a Team license is required to connect" is enforced at the agent endpoint, not at login. +""" +import time + +import pytest + +pytest.importorskip("fastapi", reason="full-stack extra not installed") +pytest.importorskip("httpx", reason="httpx not installed") + +from fastapi.testclient import TestClient # noqa: E402 + +from engraphis import licensing as lic # noqa: E402 +from engraphis.config import settings # noqa: E402 +from engraphis.licensing import compose_key, ed25519_public_key # noqa: E402 +from engraphis.service import MemoryService # noqa: E402 + +_SECRET = bytes(range(32)) + + +def _team_key(seats: int = 5) -> str: + return compose_key({"v": 1, "plan": "team", "email": "w@x.co", "seats": seats, + "issued": int(time.time()), + "expires": int(time.time() + 365 * 86400)}, _SECRET) + + +def _seed(db_path: str) -> None: + svc = MemoryService.create(db_path) + svc.remember("The team uses Postgres 16 for the main database.", workspace="demo", + scope="workspace", title="DB choice") + + +def _client(monkeypatch, tmp_path, *, key=None): + db = str(tmp_path / "agent.db") + monkeypatch.setattr(settings, "db_path", db) + monkeypatch.setattr(settings, "embed_model", "") + monkeypatch.setenv("ENGRAPHIS_EMBED_MODEL", "") + monkeypatch.setenv("ENGRAPHIS_TEAM_MODE", "1") + monkeypatch.setattr(lic, "_LICENSE_FILE", tmp_path / "license.key") + if key: + monkeypatch.setenv("ENGRAPHIS_LICENSE_KEY", key) + monkeypatch.setenv("ENGRAPHIS_LICENSE_PUBKEY", ed25519_public_key(_SECRET).hex()) + else: + monkeypatch.delenv("ENGRAPHIS_LICENSE_KEY", raising=False) + lic.current_license(refresh=True) + _seed(db) + from engraphis.dashboard_app import create_app + return TestClient(create_app()) + + +def _setup_admin(c, email="admin@x.co", password="supersecret1") -> dict: + r = c.post("/api/auth/setup", json={"email": email, "name": "Admin", + "password": password}) + assert r.status_code == 200, r.text + return r.json()["user"] + + +def _mint(c, label="claude-code") -> dict: + r = c.post("/api/auth/token", json={"label": label}) + assert r.status_code == 200, r.text + return r.json() + + +# ── token lifecycle ──────────────────────────────────────────────────────────── +def test_token_lifecycle_and_connect_info(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path, key=_team_key()) as c: + _setup_admin(c) + tok = _mint(c) + assert len(tok["token"]) > 30 and tok["id"].startswith("tok_") + + # listing never returns the raw token + lst = c.get("/api/auth/tokens").json()["tokens"] + assert len(lst) == 1 and lst[0]["id"] == tok["id"] and "token" not in lst[0] + + # connect-info describes the caller + the agent config + ci = c.get("/api/auth/connect-info").json() + assert ci["user"]["email"] == "admin@x.co" + assert ci["api_base"].endswith("/api") + assert "/api/remember" in ci["snippet"] + + # revoke, then it's gone (404 on a second revoke) + assert c.delete(f"/api/auth/token/{tok['id']}").status_code == 200 + assert c.delete(f"/api/auth/token/{tok['id']}").status_code == 404 + + +# ── agent write path ──────────────────────────────────────────────────────────── +def test_remember_with_bearer_writes_to_cloud(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path, key=_team_key()) as c: + _setup_admin(c) + token = _mint(c)["token"] + c.cookies.clear() # bearer-only, like a headless agent with no browser session + h = {"Authorization": f"Bearer {token}"} + + r = c.post("/api/remember", json={"content": "Redis caches the gateway.", + "workspace": "demo"}, headers=h) + assert r.status_code == 200, r.text + + # the write landed in the cloud store and is recallable with the same token + rec = c.get("/api/recall?q=Redis&workspace=demo", headers=h) + assert rec.status_code == 200 + assert any("Redis" in (m.get("content") or "") + for m in rec.json()["memories"]) + + +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") + 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}"}) + assert r.status_code == 402 + assert r.json()["detail"]["feature"] == "team" + + +def test_remember_without_auth_401(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path, key=_team_key()) as c: + _setup_admin(c) + c.cookies.clear() # no cookie, no bearer -> the team auth wall refuses it + assert c.post("/api/remember", + json={"content": "x", "workspace": "demo"}).status_code == 401 + + +def test_connect_info_verifies_a_bearer_token(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path, key=_team_key()) as c: + _setup_admin(c) + token = _mint(c)["token"] + c.cookies.clear() + # an agent can hit connect-info with its bearer to verify the token + discover base + ci = c.get("/api/auth/connect-info", + headers={"Authorization": f"Bearer {token}"}).json() + assert ci["user"]["email"] == "admin@x.co" + + +# ── disabled user's token is refused ──────────────────────────────────────────── +def test_disabled_user_token_rejected(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path, key=_team_key(seats=5)) as c: + _setup_admin(c) + mem = c.post("/api/auth/users", json={"email": "mem@x.co", "name": "Mem", + "password": "memberpass1", + "role": "member"}).json()["user"] + + c.post("/api/auth/logout") + assert c.post("/api/auth/login", json={"email": "mem@x.co", + "password": "memberpass1"}).status_code == 200 + token = _mint(c, label="member-agent")["token"] + h = {"Authorization": f"Bearer {token}"} + assert c.post("/api/remember", json={"content": "m1", "workspace": "demo"}, + headers=h).status_code == 200 + + # admin disables the member + c.post("/api/auth/logout") + c.post("/api/auth/login", json={"email": "admin@x.co", + "password": "supersecret1"}) + c.post("/api/auth/users/update", json={"user_id": mem["id"], "disabled": True}) + + # the member's token is now inert (resolve_api_token rejects disabled owners). + # Clear the admin's browser cookie so the bearer is the sole credential. + c.cookies.clear() + assert c.post("/api/remember", json={"content": "m2", "workspace": "demo"}, + headers=h).status_code == 401 + + +# ── instance-wide service token still bypasses (unchanged) ───────────────────── +def test_instance_bearer_bypass_still_works(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "api_token", "inst-secret") + with _client(monkeypatch, tmp_path, key=_team_key()) as c: + _setup_admin(c) + c.cookies.clear() # the instance service token is the sole credential here + r = c.post("/api/remember", json={"content": "via instance token", + "workspace": "demo"}, + headers={"Authorization": "Bearer inst-secret"}) + assert r.status_code == 200, r.text \ No newline at end of file