diff --git a/docs/api.md b/docs/api.md index f92a8b9..0aeaa1a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,6 +19,64 @@ --- +### Auth + +Full auth documentation: [`docs/auth.md`](auth.md). + +#### `GET /auth/login` + +Redirect to gnexus-auth OAuth authorization endpoint. Sets PKCE + state internally. + +**Response `302`** → Location: gnexus-auth `/oauth/authorize` + +--- + +#### `GET /auth/callback` + +OAuth callback. Validates state, exchanges code for tokens, creates DB session, sets cookie. + +**Query params** +- `code` — authorization code from gnexus-auth +- `state` — state parameter + +**Response `302`** → Location: `/` + +**Errors** +- `400` — invalid state, PKCE failure, or token exchange failed + +--- + +#### `POST /auth/logout` + +Logout current user. Deletes DB session and clears cookie. + +**Response `200`** +```json +{ "ok": true } +``` + +--- + +#### `GET /auth/me` + +Return current authenticated user. + +**Response `200`** +```json +{ + "id": "user-uuid", + "email": "user@example.com", + "display_name": "User Name", + "role": "admin", + "permissions": ["navi.sessions.read_all", "navi.memory.read_all"] +} +``` + +**Errors** +- `401` — not authenticated + +--- + ### Profiles & Tools #### `GET /agents/profiles` @@ -621,6 +679,118 @@ --- +### Admin + +All admin endpoints require `admin` role or specific permissions. + +#### `GET /admin/sessions` + +All sessions across all users. + +**Response `200`** +```json +[ + { + "session_id": "...", + "profile_id": "secretary", + "user_id": "user-uuid", + "name": "Research task", + "message_count": 12, + "pinned": false, + "created_at": "2026-05-04T10:00:00+00:00", + "last_active": "2026-05-04T10:30:00+00:00" + } +] +``` + +--- + +#### `GET /admin/users` + +All registered `navi_users`. + +**Response `200`** +```json +[ + { + "id": "user-uuid", + "email": "user@example.com", + "display_name": "User Name", + "role": "admin", + "permissions": ["navi.sessions.read_all"], + "created_at": "...", + "updated_at": "..." + } +] +``` + +--- + +#### `GET /admin/memory` + +All memory facts (global view). Requires `navi.memory.read_all`. + +**Response `200`** +```json +{ "facts": [...], "count": 42 } +``` + +--- + +#### `GET /admin/profiles` + +All profiles including admin-only ones. Requires `navi.profiles.manage`. + +--- + +#### `PATCH /admin/users/{user_id}/role` + +Update cached role. Requires admin. + +**Request body** +```json +{ "role": "admin" } +``` + +**Response `200`** +```json +{ "ok": true } +``` + +--- + +#### `PATCH /admin/profiles/{profile_id}/availability` + +Toggle `is_admin_only`. Requires `navi.profiles.manage`. + +**Request body** +```json +{ "is_admin_only": true } +``` + +**Response `200`** +```json +{ "ok": true, "note": "Profile availability is managed via profile config files" } +``` + +--- + +### Webhooks + +#### `POST /webhooks/gnexus-auth` + +Receive webhooks from gnexus-auth. Verified via HMAC. + +**Response `200`** +```json +{ "ok": true } +``` + +**Errors** +- `400` — invalid payload + +--- + ## Files **Client static**: `GET /static/**` — served from `client/` directory. Header `Cache-Control: no-store`. diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..92aa7d6 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,515 @@ +# Authentication & Authorization + +Navi uses **gnexus-auth** as its identity provider via OAuth 2.0 + PKCE. The Navi backend acts as an OAuth client/proxy: it redirects users to gnexus-auth, exchanges the authorization code for tokens, encrypts and stores tokens in PostgreSQL, and issues an `httpOnly` session cookie (`navi_auth_session`). + +## Table of contents + +- [Architecture overview](#architecture-overview) +- [Role & permission model](#role--permission-model) +- [OAuth login flow](#oauth-login-flow) +- [Token refresh](#token-refresh) +- [Session & memory isolation](#session--memory-isolation) +- [Webhooks](#webhooks) +- [Environment variables](#environment-variables) +- [Setting up the gnexus-auth client](#setting-up-the-gnexus-auth-client) +- [API reference](#api-reference) +- [WebSocket auth](#websocket-auth) +- [Testing with mocked auth](#testing-with-mocked-auth) + +--- + +## Architecture overview + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────┐ +│ Browser │────▶│ Navi backend │────▶│ gnexus-auth │ +│ (cookie) │◀────│ (proxy) │◀────│ (OAuth) │ +└─────────────┘ └──────────────┘ └─────────────┘ + │ + ▼ + ┌─────────────┐ + │ PostgreSQL │ + │ user_auth_ │ + │ sessions │ + └─────────────┘ +``` + +Key design choices: + +- **Backend as OAuth proxy** — browser never sees gnexus-auth tokens directly. +- **Encrypted token storage** — `access_token` and `refresh_token` are encrypted with Fernet (`cryptography`) before hitting the DB. +- **Cookie-based sessions** — `navi_auth_session` is an `httpOnly` + `Secure` (when HTTPS) + `SameSite` cookie. +- **Hybrid role + permission model** — two fixed roles (`user`, `admin`) plus fine-grained permissions from gnexus-auth. +- **DDL auto-migration** — all auth tables and `user_id` columns are created automatically on startup using `CREATE TABLE IF NOT EXISTS` and `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`. + +--- + +## Role & permission model + +### Fixed roles (hard boundary) + +| Role | Slug source | Description | +|---|---|---| +| `user` | default | Regular user. Can own sessions, manage own memory, use profiles. | +| `admin` | `role_ids` contains `navi_admin` (configurable via `GNAUTH_ADMIN_ROLE_SLUG`) | Full access. Admins can view all sessions, manage users, access debug endpoints, and see admin-only profiles. | + +Role is determined at login by inspecting `client_access_list[].role_ids` from gnexus-auth for the matching `client_id`. + +### Permissions (fine-grained, from gnexus-auth) + +Permissions are `permission_ids` from gnexus-auth, also read from `client_access_list` for the Navi client. They control what a user can do **inside** their role. + +| Permission | Description | User default | Admin default | +|---|---|---|---| +| `navi.sessions.read_own` | View own sessions | ✅ | ✅ | +| `navi.sessions.read_all` | View all sessions | ❌ | ✅ | +| `navi.sessions.delete_own` | Delete own sessions | ✅ | ✅ | +| `navi.sessions.delete_all` | Delete other users' sessions | ❌ | ✅ | +| `navi.memory.read_own` | View own memory | ✅ | ✅ | +| `navi.memory.read_all` | View other users' memory | ❌ | ✅ | +| `navi.files.read_own` | Download own session files | ✅ | ✅ | +| `navi.files.read_all` | Download other users' files | ❌ | ✅ | +| `navi.profiles.use` | Use agent profiles | ✅ | ✅ | +| `navi.profiles.manage` | Manage profile availability | ❌ | ✅ | +| `navi.tools.use` | Use tools | ✅ | ✅ | +| `navi.admin.access` | Access admin endpoints | ❌ | ✅ | +| `navi.debug.access` | Access debug panel | ❌ | ✅ | + +**Permission check rules:** + +1. If the function is **admin-only** (e.g. debug panel, `/admin/*`) → `require_admin` → 403 if role != `admin`. +2. Otherwise → `require_permission("navi.xxx")` → 403 if permission missing. +3. Admin role **implicitly grants all permissions** as a soft fallback, but explicit permission checks are still used for clarity. + +**Example in code:** + +```python +# Hard admin-only boundary +@router.get("/admin/sessions") +async def admin_sessions(user: Annotated[User, Depends(require_admin)]): + ... + +# Flexible permission gate +@router.get("/sessions") +async def list_sessions(user: Annotated[User, Depends(require_user)]): + if user.role == "admin" or user.has_permission("navi.sessions.read_all"): + return all_sessions + return own_sessions +``` + +--- + +## OAuth login flow + +### 1. Initiate login + +``` +GET /auth/login +``` + +Navi backend: +1. Calls `client.build_authorization_request(return_to="/", scopes=["openid", "email", "profile", "roles", "permissions"])` +2. Stores PKCE verifier and state in `InMemoryPkceStore` / `InMemoryStateStore` (from `gnexus-auth-client-py`) +3. Returns `302` redirect to gnexus-auth `/oauth/authorize` + +### 2. User logs in at gnexus-auth + +User authenticates with gnexus-auth, approves scopes, and is redirected back to: + +``` +GET /auth/callback?code=...&state=... +``` + +### 3. Callback handling + +Navi backend: +1. Validates `state` against `InMemoryStateStore` +2. Exchanges `code` for tokens via `client.exchange_authorization_code(code, state)` (sync call wrapped in `asyncio.to_thread()`) +3. Fetches user info via `client.fetch_user(access_token)` +4. Determines `role` from `role_ids` and collects `permissions` from `permission_ids` +5. **Upserts** `navi_users` row (id, email, display_name, role, permissions) +6. **Creates** `user_auth_sessions` row with encrypted tokens +7. Sets `navi_auth_session` cookie +8. Redirects to `/` + +### 4. Subsequent requests + +Every request with the cookie triggers `get_current_user(request)`: +1. Reads `session_id` from cookie +2. Looks up `user_auth_sessions` row +3. Decrypts `access_token` +4. If expired: decrypts `refresh_token`, calls `client.refresh_token()`, updates DB with new tokens +5. Calls `client.fetch_user(access_token)` to get fresh user data +6. Upserts `navi_users` with latest role/permissions +7. Returns `User` object, cached in `request.state.user` + +--- + +## Token refresh + +Tokens are refreshed lazily — only when `expires_at < now()`. The refresh happens inside `get_current_user()` (and `get_current_user_ws()` for WebSocket). After refresh, the DB row is updated with new encrypted `access_token` and `refresh_token`. + +If refresh fails (e.g. user revoked access in gnexus-auth), the DB session is deleted and the user is treated as unauthenticated (401). + +--- + +## Session & memory isolation + +### New tables + +**`navi_users`** — cached user identity from gnexus-auth + +| Column | Type | Description | +|---|---|---| +| `id` | TEXT PRIMARY KEY | `user_id` from gnexus-auth | +| `email` | TEXT NOT NULL | | +| `display_name` | TEXT | From `profile.display_name` | +| `role` | TEXT NOT NULL DEFAULT 'user' | `user` or `admin` | +| `permissions` | TEXT NOT NULL DEFAULT '[]' | JSON array of permission slugs | +| `created_at` | TIMESTAMPTZ | | +| `updated_at` | TIMESTAMPTZ | | + +**`user_auth_sessions`** — encrypted OAuth tokens + +| Column | Type | Description | +|---|---|---| +| `id` | TEXT PRIMARY KEY | Cookie value | +| `user_id` | TEXT NOT NULL → navi_users.id | | +| `access_token_enc` | TEXT NOT NULL | Fernet-encrypted access token | +| `refresh_token_enc` | TEXT NOT NULL | Fernet-encrypted refresh token | +| `expires_at` | TIMESTAMPTZ | | +| `created_at` | TIMESTAMPTZ | | +| `last_used_at` | TIMESTAMPTZ | | + +### Isolation + +- **`sessions.user_id`** — nullable for legacy sessions. Legacy sessions are accessible only to admins. +- **`memory_facts.user_id`** — facts are scoped per user. Admin with `navi.memory.read_all` can pass `user_id=None` for global search. +- **`memory_summary.user_id`** — conversation summaries are per-user. + +### Ownership rules in endpoints + +| Endpoint | Access rule | +|---|---| +| `GET /sessions` | Admin or `navi.sessions.read_all` → all sessions; else → own sessions | +| `GET /sessions/{id}` | Owner, admin, or `navi.sessions.read_all` | +| `DELETE /sessions/{id}` | Owner, admin, or `navi.sessions.delete_all` | +| `GET /sessions/{id}/files/{name}` | Owner, admin, or `navi.files.read_all` | +| `GET /sessions/{id}/context` | Admin only (`require_admin`) | +| `GET /sessions/{id}/planning` | Admin only (`require_admin`) | + +--- + +## Webhooks + +Navi receives webhook events from gnexus-auth at: + +``` +POST /webhooks/gnexus-auth +``` + +**Payload verification:** HMAC-SHA256 via `HmacWebhookVerifier` (from `gnexus-auth-client-py`). The webhook secret must be configured in gnexus-auth admin panel and matched against `client_secret` (or a dedicated webhook secret if the library supports it). + +**Events handled:** + +| Event | Action | +|---|---| +| `user.blocked` / `user.archived` / `user.deleted` | `DELETE FROM user_auth_sessions WHERE user_id = $1` | +| `auth.global_logout` | `DELETE FROM user_auth_sessions` (all sessions) | +| `session.revoked` | If `user_id` present → invalidate all sessions for that user | +| `client.roles_changed` / `client.permissions_changed` | Clear cached `permissions` and `role` in `navi_users` so next request re-fetches from gnexus-auth | + +--- + +## Environment variables + +| Variable | Type | Default | Description | +|---|---|---|---| +| `GNAUTH_BASE_URL` | str | `http://gnexus-auth.local` | gnexus-auth server base URL | +| `GNAUTH_CLIENT_ID` | str | `""` | OAuth client ID from gnexus-auth | +| `GNAUTH_CLIENT_SECRET` | str | `""` | OAuth client secret | +| `GNAUTH_REDIRECT_URI` | str | `http://localhost:8000/auth/callback` | Must match the redirect URI registered in gnexus-auth exactly | +| `GNAUTH_ADMIN_ROLE_SLUG` | str | `navi_admin` | Role slug that grants admin status | +| `NAVI_AUTH_ENCRYPTION_KEY` | str | `""` | **Fernet key** (base64, 32 bytes). Generate once, keep constant. See below. | +| `NAVI_AUTH_COOKIE_NAME` | str | `navi_auth_session` | Cookie name | +| `NAVI_AUTH_COOKIE_SECURE` | bool | `False` | Set `True` when serving over HTTPS | +| `NAVI_AUTH_COOKIE_SAMESITE` | str | `lax` | SameSite policy | +| `NAVI_AUTH_COOKIE_MAX_AGE_DAYS` | int | `30` | Cookie lifetime in days | + +### Generating `NAVI_AUTH_ENCRYPTION_KEY` + +```bash +.venv/bin/python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +``` + +Output example: `gAAAAABm1234567890abcdefghijklmnopqrstuvwxyz1234567890abcd==` + +**Critical:** +- Generate **once** and reuse across all Navi instances sharing the same database. +- **Never change** after the first launch — existing encrypted tokens in `user_auth_sessions` will become unreadable. +- Without it, the app refuses to start (`ValueError` from `TokenEncryptor`). + +--- + +## Setting up the gnexus-auth client + +### 1. Create a Client in gnexus-auth + +Go to gnexus-auth admin panel → Clients → New. + +| Field | Value | +|---|---| +| Name | `navi` | +| Redirect URI | `https://navi.your-domain.com/auth/callback` (or `http://localhost:8000/auth/callback` for local dev) | +| Scopes | `openid`, `email`, `profile`, `roles`, `permissions` | + +If you need both server and local development, check if gnexus-auth supports multiple redirect URIs per client. Otherwise create a separate dev client. + +### 2. Create roles + +| Role slug | Display name | Mapped to in Navi | +|---|---|---| +| `navi_user` | Navi User | `role: "user"` | +| `navi_admin` | Navi Admin | `role: "admin"` | + +The admin slug must match `GNAUTH_ADMIN_ROLE_SLUG` (default: `navi_admin`). + +### 3. Create permissions + +Create permissions with these exact slugs (the Navi backend checks for them literally): + +- `navi.sessions.read_own` +- `navi.sessions.read_all` +- `navi.sessions.delete_own` +- `navi.sessions.delete_all` +- `navi.memory.read_own` +- `navi.memory.read_all` +- `navi.files.read_own` +- `navi.files.read_all` +- `navi.profiles.use` +- `navi.profiles.manage` +- `navi.tools.use` +- `navi.admin.access` +- `navi.debug.access` + +### 4. Assign permissions to roles + +| Permission | `navi_user` default | `navi_admin` default | +|---|---|---| +| `navi.sessions.read_own` | ✅ | ✅ | +| `navi.sessions.read_all` | ❌ | ✅ | +| ... | | | + +### 5. Configure the webhook (optional but recommended) + +Set the webhook URL in gnexus-auth to: + +``` +https://navi.your-domain.com/webhooks/gnexus-auth +``` + +Configure a shared secret for HMAC verification. The Navi backend uses `HmacWebhookVerifier` from `gnexus-auth-client-py`. + +### 6. Update `.env` + +```dotenv +GNAUTH_BASE_URL=https://auth.your-domain.com +GNAUTH_CLIENT_ID=your-client-id +GNAUTH_CLIENT_SECRET=your-client-secret +GNAUTH_REDIRECT_URI=https://navi.your-domain.com/auth/callback +NAVI_AUTH_ENCRYPTION_KEY=your-fernet-key-generated-once +``` + +--- + +## API reference + +### Auth endpoints + +#### `GET /auth/login` + +Redirect to gnexus-auth OAuth authorization endpoint. Sets PKCE + state internally. + +**Response `302`** → Location: gnexus-auth `/oauth/authorize` + +--- + +#### `GET /auth/callback` + +OAuth callback. Validates state, exchanges code for tokens, creates DB session, sets cookie. + +**Query params** +- `code` — authorization code from gnexus-auth +- `state` — state parameter from gnexus-auth + +**Response `302`** → Location: `/` + +**Errors** +- `400` — invalid state, PKCE failure, or token exchange failed + +--- + +#### `POST /auth/logout` + +Logout current user. Deletes DB session row and clears cookie. + +**Response `200`** +```json +{ "ok": true } +``` + +--- + +#### `GET /auth/me` + +Return current authenticated user. + +**Response `200`** +```json +{ + "id": "user-uuid", + "email": "user@example.com", + "display_name": "User Name", + "role": "admin", + "permissions": ["navi.sessions.read_all", "navi.memory.read_all"] +} +``` + +**Errors** +- `401` — not authenticated + +--- + +### Admin endpoints + +All admin endpoints require `require_admin` (role check) or `require_permission(...)`. + +#### `GET /admin/sessions` + +Return all sessions across all users. + +**Response `200`** +```json +[ + { + "session_id": "...", + "profile_id": "secretary", + "user_id": "user-uuid", + "name": "Research task", + "message_count": 12, + "pinned": false, + "created_at": "2026-05-04T10:00:00+00:00", + "last_active": "2026-05-04T10:30:00+00:00" + } +] +``` + +--- + +#### `GET /admin/users` + +Return all registered `navi_users`. + +**Response `200`** +```json +[ + { + "id": "user-uuid", + "email": "user@example.com", + "display_name": "User Name", + "role": "admin", + "permissions": ["navi.sessions.read_all"], + "created_at": "2026-05-04T10:00:00+00:00", + "updated_at": "2026-05-04T10:30:00+00:00" + } +] +``` + +--- + +#### `GET /admin/memory` + +Return all memory facts (global view). Requires `navi.memory.read_all`. + +**Response `200`** +```json +{ "facts": [...], "count": 42 } +``` + +--- + +#### `GET /admin/profiles` + +Return all profiles including admin-only ones. Requires `navi.profiles.manage`. + +--- + +#### `PATCH /admin/users/{user_id}/role` + +Update a user's cached role. Requires admin. + +**Request body** +```json +{ "role": "admin" } +``` + +**Response `200`** +```json +{ "ok": true } +``` + +--- + +#### `PATCH /admin/profiles/{profile_id}/availability` + +Toggle `is_admin_only` for a profile. Requires `navi.profiles.manage`. + +**Request body** +```json +{ "is_admin_only": true } +``` + +**Response `200`** +```json +{ "ok": true, "note": "Profile availability is managed via profile config files" } +``` + +--- + +### Webhook endpoint + +#### `POST /webhooks/gnexus-auth` + +Receive webhooks from gnexus-auth. Payload is verified via HMAC and parsed by `JsonWebhookParser`. + +**Response `200`** +```json +{ "ok": true } +``` + +**Errors** +- `400` — invalid JSON or HMAC verification failed + +--- + +## WebSocket auth + +WebSocket connections authenticate via the same `navi_auth_session` cookie. The browser sends cookies automatically during the WebSocket handshake. Navi's WebSocket handler (`navi/api/websocket.py`) resolves the user from the cookie the same way REST endpoints do (`get_current_user_ws`). + +Sessions created via WebSocket get `user_id` set from the authenticated user. Anonymous WebSocket connections are not supported for session creation. + +--- + +## Testing with mocked auth + +Integration tests in `tests/integration/conftest.py` bypass real OAuth by overriding FastAPI dependencies: + +```python +app.dependency_overrides[get_current_user] = lambda: fake_user +app.dependency_overrides[require_user] = lambda: fake_user +app.dependency_overrides[require_admin] = lambda: fake_user_admin +``` + +This allows tests to run without a running gnexus-auth instance. + +For unit tests that don't start the full app, auth is simply not injected. diff --git a/docs/config.md b/docs/config.md index 95a51ba..01a2c9c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -115,6 +115,23 @@ | `GMAIL_ADDRESS` | str | `""` | Gmail address for the `email_manager` tool (IMAP/SMTP with App Password) | | `GMAIL_APP_PASSWORD` | str | `""` | Gmail App Password (not the account password — generate at myaccount.google.com) | +## Authentication (gnexus-auth OAuth) + +| Variable | Type | Default | Description | +|---|---|---|---| +| `GNAUTH_BASE_URL` | str | `http://gnexus-auth.local` | gnexus-auth server base URL | +| `GNAUTH_CLIENT_ID` | str | `""` | OAuth client ID | +| `GNAUTH_CLIENT_SECRET` | str | `""` | OAuth client secret | +| `GNAUTH_REDIRECT_URI` | str | `http://localhost:8000/auth/callback` | Must match redirect URI registered in gnexus-auth | +| `GNAUTH_ADMIN_ROLE_SLUG` | str | `navi_admin` | Role slug that maps to Navi `admin` role | +| `NAVI_AUTH_ENCRYPTION_KEY` | str | `""` | **Fernet key** (base64, 32 bytes). Generate once with `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`. Never change after first launch. | +| `NAVI_AUTH_COOKIE_NAME` | str | `navi_auth_session` | Session cookie name | +| `NAVI_AUTH_COOKIE_SECURE` | bool | `False` | Set `True` behind HTTPS | +| `NAVI_AUTH_COOKIE_SAMESITE` | str | `lax` | SameSite policy | +| `NAVI_AUTH_COOKIE_MAX_AGE_DAYS` | int | `30` | Cookie lifetime | + +Full auth setup guide: [`docs/auth.md`](auth.md). + ## Persona | Variable | Type | Default | Description | diff --git a/docs/index.md b/docs/index.md index 9a7bf11..ab0e9b7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,6 +29,7 @@ | [`websocket.md`](websocket.md) | WebSocket protocol — all events, stop mechanism | | [`profiles.md`](profiles.md) | Profiles, system prompts, persona, profile switching | | [`memory.md`](memory.md) | Long-term memory — facts, extraction, search | +| [`auth.md`](auth.md) | Multi-user auth via gnexus-auth OAuth — roles, permissions, webhooks | | [`config.md`](config.md) | All environment variables with types and defaults | | [`api.md`](api.md) | REST API endpoints + full WebSocket event schemas and sequences | diff --git a/docs/memory.md b/docs/memory.md index 1dcc021..b16f9db 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -77,22 +77,24 @@ | Table | Purpose | |---|---| -| `memory_facts` | Individual facts: `(category, key, value)` — unique on `(category, key)` | -| `memory_summary` | Single-row narrative summary generated from all facts | +| `memory_facts` | Individual facts: `(user_id, category, key, value)` — unique on `(user_id, category, key)` | +| `memory_summary` | Per-user narrative summary (`user_id` scoped) | | `session_memory_state` | Tracks which sessions have been processed (by `extracted_at`) | +`user_id` references `navi_users(id)` with `ON DELETE CASCADE`. Facts and summaries are scoped per user. Admin with `navi.memory.read_all` can pass `user_id=None` for global search. + `MemoryStore` is initialized synchronously (creates tables), all operations are async via asyncpg (PostgreSQL). ### Key operations | Method | Description | |---|---| -| `upsert_fact(...)` | Insert or update a fact (generates embedding if pgvector + backend available) | -| `search_facts(query, limit=15)` | **Vector search first** (cosine distance, cutoff 0.3), then ILIKE fallback | -| `delete_fact(key, category=None)` | Delete by key, optionally filtered by category | -| `get_all_facts(limit=None)` | All facts ordered by `(category, updated_at DESC)` | -| `get_summary()` | Current narrative summary text | -| `set_summary(content)` | Replace the summary | +| `upsert_fact(..., user_id=None)` | Insert or update a fact scoped to user | +| `search_facts(query, limit=15, user_id=None)` | **Vector search first** (cosine distance, cutoff 0.3), then ILIKE fallback. `user_id=None` requires admin permission for global search. | +| `delete_fact(key, category=None, user_id=None)` | Delete by key, optionally filtered by category and user | +| `get_all_facts(limit=None, user_id=None)` | All facts ordered by `(category, updated_at DESC)` | +| `get_summary(user_id=None)` | Current narrative summary text for user | +| `set_summary(content, user_id=None)` | Replace the summary for user | | `mark_session_extracted(session_id)` | Record extraction timestamp | | `get_extracted_at(session_id)` | Check if/when a session was processed | | `backfill_embeddings(batch_size=8)` | Generate embeddings for facts with `embedding IS NULL` | diff --git a/docs/sessions.md b/docs/sessions.md index 96e4cbd..bfd460e 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -8,6 +8,7 @@ class Session(BaseModel): id: str # UUID profile_id: str # active profile + user_id: str | None # owner (null for legacy sessions) messages: list[Message] # full display history — never compressed context: list[Message] # LLM context — may be replaced with summary context_token_count: int # accumulated tokens; reset to 0 after compression @@ -50,16 +51,18 @@ ### `PgSessionStore` (`navi/core/pg_session_store.py`) Production store backed by PostgreSQL via asyncpg. -- `create(profile_id)` → new `Session` +- `create(profile_id, user_id=None)` → new `Session` - `get(session_id)` → `Session | None` - `save(session)` — serializes with `model_dump(mode='json')` (required for datetime serialization) -- `list_all()` → sorted by `(pinned DESC, last_active DESC)` +- `list_all(user_id=None, is_admin=False)` → if `is_admin`: all sessions; else: sessions for `user_id` or legacy (`user_id IS NULL`) sessions - `delete(session_id)` → `bool` - `set_pinned(session_id, pinned)` → `bool` - `set_name(session_id, name)` → `bool` Requires `DATABASE_URL` env variable (e.g. `postgresql://user:pass@localhost/navi`). +**Ownership:** Legacy sessions (`user_id IS NULL`) are accessible only to admins. New sessions created by authenticated users carry `user_id`. The `list_all()` method respects the `is_admin` flag to filter appropriately. + --- ## Context compression (`navi/core/compressor.py`)