Goal: Add a user identity layer so memory, sessions, libraries, and preferences are properly partitioned per user. All stores are keyed to a user_id from day one so multi-user is a data concern, not an architecture rewrite.
Browser Gateway Bus / Agents
────── ─────── ────────────
POST /api/auth/login ──────► validate credentials
username + password issue session token ────────► cookie (HTTP-only)
WS /ws/chat/{session_id} ──► check cookie
(with cookie) resolve user_id
inject into query.received
{query, session_id, user_id} ──────────────────► GeneratorAgent
MemoryAgent → MemoryService (filter by user_id)
ConversationService (tag sessions)
A JSON file at ~/.local2/users.json managed by a UserService. Simple, portable, no external dependency.
{
"users": [
{
"user_id": "a3f8c2d1-...", // stable UUID, assigned at creation
"username": "richard",
"password_hash": "$2b$12$...", // bcrypt
"created_at": 1750000000.0,
"role": "admin" // admin | user (future use)
}
]
}
# src/local/services/user_service.py
class UserService:
def create_user(self, username: str, password: str, role: str = "user") -> str:
"""Create a user. Returns user_id. Raises if username taken."""
def authenticate(self, username: str, password: str) -> str | None:
"""Verify credentials. Returns user_id or None."""
def get_user(self, user_id: str) -> dict | None:
"""Return user record by user_id."""
def list_users(self) -> list[dict]:
"""Return all users (without password_hash)."""
def change_password(self, user_id: str, new_password: str) -> None:
local2 useradd <username> # prompts for password; first user gets role=admin local2 userlist # list users local2 passwd <username> # change password
After successful login, the gateway issues a random 32-byte token (URL-safe base64), stores it server-side in a TokenStore dict mapping token → {user_id, expires_at}, and sets it as an HTTP-only cookie named local2_session.
TokenStore (in-memory, not persisted):
{ "aB3xQ...": { "user_id": "a3f8c2d1-...", "expires_at": 1750086400.0 } }
Default TTL: 30 days. Token is renewed on each authenticated request. On process restart, all tokens are invalidated — users re-login. This is acceptable for a local assistant; persistent tokens can be added later.
| Method | Path | Description |
|---|---|---|
POST | /api/auth/login | Body: {username, password}. Sets local2_session cookie. Returns {user_id, username}. |
POST | /api/auth/logout | Clears cookie, revokes token. |
GET | /api/auth/me | Returns current user info or 401. |
POST | /api/auth/setup | Create the first admin user (only works if no users exist). |
FastAPI dependency require_auth(request) — reads local2_session cookie, validates against TokenStore, returns user_id. Applied to all /api/* endpoints and the WebSocket handshake.
Static assets (/assets/*, /) and the auth endpoints themselves are unauthenticated. The frontend handles the redirect to login if /api/auth/me returns 401.
At WS /ws/chat/{session_id} connect time, the gateway reads the local2_session cookie from the WebSocket handshake headers and resolves user_id. If the token is invalid, the WebSocket is rejected with a 4001 close code. The resolved user_id is attached to the session for the lifetime of the connection.
user_id is added to QueryReceived and flows through every downstream message that currently carries session_id.
| Message | Change |
|---|---|
QueryReceived | Add user_id: str = "default" |
ResponseGeneration | Add user_id: str = "default" |
AnswerDialog | Add user_id: str = "default" |
MessageEnvelope metadata | Add user_id alongside session_id |
| Component | Change |
|---|---|
GeneratorAgent | Read user_id from QueryReceived; pass to MemoryService calls and AnswerDialog |
MemoryAgent | Read user_id from ResponseGeneration; pass to write_episodic |
MemoryService | Add user_id param to write_episodic and search_episodic; store in metadata; filter on search |
ConversationService | Tag each session with user_id; list_sessions filters by user |
gateway.py | Inject user_id from resolved token into QueryReceived; filter session list and session history by user |
Add user_id to every engram's metadata. Filter search_episodic with a ChromaDB where clause: {"user_id": {"$eq": user_id}}. Users never see each other's memories.
Add user_id field to each session entry in ConversationService. list_sessions(user_id) returns only that user's sessions. get_history(session_id, user_id) returns 404 if the session belongs to a different user.
user.<user_id>.<collection_name>collective.documents)search_library searches the requesting user's private collections + all shared collectionsExisting data has no user_id. On first startup after this phase, a migration script assigns all orphaned records to a "default" user. The first user created via local2 useradd or the setup screen should have their account linked to this "default" user_id, or a separate migration step reassigns data.
local2 useradd richard --claim-default — creates the user and reassigns all user_id="default" records to the new user_id. Keeps the original owner's data intact.
The React app checks GET /api/auth/me on load. If 401, it renders a login form (username + password) instead of the chat UI. On success, the cookie is set by the server and the app renders normally.
If /api/auth/me returns a special 403 setup_required status, the app renders a "Create your account" form that calls POST /api/auth/setup. This only works once.
Small username + logout button in the header bar next to the token gauge.
| Sub-phase | Scope | Est. |
|---|---|---|
| 20a | UserService + users.json store + CLI commands (useradd, userlist, passwd) | 2h |
| 20b | TokenStore + auth endpoints (login, logout, me, setup) + FastAPI auth middleware | 3h |
| 20c | WebSocket auth guard; thread user_id through QueryReceived → all messages | 2h |
| 20d | Partition episodic memory + session history by user_id | 2h |
| 20e | Partition library by user_id; shared collection concept | 2h |
| 20f | Migration script: assign orphaned data to first user via --claim-default | 1h |
| 20g | Frontend: login screen, first-run setup, username + logout in header | 3h |
| File | Change |
|---|---|
src/local/services/user_service.py | NEW UserService (users.json, bcrypt) |
src/local/api/auth.py | NEW TokenStore + auth endpoints + require_auth dependency |
src/local/api/gateway.py | MOD Apply auth middleware; inject user_id into QueryReceived; filter sessions by user |
src/local/protocol/messages.py | MOD Add user_id to QueryReceived, ResponseGeneration, AnswerDialog |
src/local/protocol/envelope.py | MOD Add user_id to metadata |
src/local/agents/generator_agent.py | MOD Read + propagate user_id |
src/local/agents/memory_agent.py | MOD Pass user_id to write_episodic |
src/local/services/memory_service.py | MOD user_id param on write/search; filter by user_id |
src/local/services/conversation_service.py | MOD Tag sessions with user_id; filter list/get by user |
src/local/services/document_service.py | MOD Per-user collection naming; shared collection concept |
src/local/cli.py | MOD Add useradd, userlist, passwd, --claim-default |
src/local/run.py | MOD Instantiate UserService; pass to gateway configure() |
frontend/src/App.tsx | MOD Auth check on load; login screen; first-run setup screen |
frontend/src/components/LoginScreen.tsx | NEW |
frontend/src/components/SetupScreen.tsx | NEW |
scripts/migrate_default_user.py | NEW Reassign orphaned data |
tests/test_user_service.py | NEW |
tests/test_auth.py | NEW |