Phase 20 — Login & User Identity

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.

Why this must come before Phase 19 (Preferences): Without user identity, every store — memory, sessions, library, and future preferences — is shared across all browsers and all people who access the system. A second user's conversations already pollute the memory store. Login is the foundation, not a feature.

1. Architecture Overview

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)

2. User Store

Storage

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)
    }
  ]
}

UserService

# 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:

CLI commands

local2 useradd <username>     # prompts for password; first user gets role=admin
local2 userlist               # list users
local2 passwd <username>      # change password
First user is admin. If no users exist and LoCAL2 starts, the web UI shows a "create first user" screen rather than a login form. This avoids the chicken-and-egg problem of needing a CLI to bootstrap access.

3. Session Tokens

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.

Decision: no JWT. JWTs require secret key management and are stateless (hard to revoke). A server-side token store is simpler, revocable instantly, and appropriate for a single-server local deployment.

4. Auth API Endpoints

MethodPathDescription
POST/api/auth/loginBody: {username, password}. Sets local2_session cookie. Returns {user_id, username}.
POST/api/auth/logoutClears cookie, revokes token.
GET/api/auth/meReturns current user info or 401.
POST/api/auth/setupCreate the first admin user (only works if no users exist).

Auth middleware

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.

5. WebSocket Auth

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.

6. Threading user_id Through the Pipeline

user_id is added to QueryReceived and flows through every downstream message that currently carries session_id.

Messages to update

MessageChange
QueryReceivedAdd user_id: str = "default"
ResponseGenerationAdd user_id: str = "default"
AnswerDialogAdd user_id: str = "default"
MessageEnvelope metadataAdd user_id alongside session_id

Agents and services to update

ComponentChange
GeneratorAgentRead user_id from QueryReceived; pass to MemoryService calls and AnswerDialog
MemoryAgentRead user_id from ResponseGeneration; pass to write_episodic
MemoryServiceAdd user_id param to write_episodic and search_episodic; store in metadata; filter on search
ConversationServiceTag each session with user_id; list_sessions filters by user
gateway.pyInject user_id from resolved token into QueryReceived; filter session list and session history by user

7. Data Partitioning

Episodic Memory

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.

Session History

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.

Library / RAG Documents

Decision: per-user ChromaDB collections + optional shared collections. This allows a team scenario: shared company knowledge base + each user's personal library.

Migration of Existing Data

Existing 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.

Practical migration path: Run 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.

8. Frontend Changes

Login screen

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.

First-run setup screen

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.

User indicator

Small username + logout button in the header bar next to the token gauge.

9. Sub-phases

Sub-phaseScopeEst.
20aUserService + users.json store + CLI commands (useradd, userlist, passwd)2h
20bTokenStore + auth endpoints (login, logout, me, setup) + FastAPI auth middleware3h
20cWebSocket auth guard; thread user_id through QueryReceived → all messages2h
20dPartition episodic memory + session history by user_id2h
20ePartition library by user_id; shared collection concept2h
20fMigration script: assign orphaned data to first user via --claim-default1h
20gFrontend: login screen, first-run setup, username + logout in header3h

10. Files Changed

FileChange
src/local/services/user_service.pyNEW UserService (users.json, bcrypt)
src/local/api/auth.pyNEW TokenStore + auth endpoints + require_auth dependency
src/local/api/gateway.pyMOD Apply auth middleware; inject user_id into QueryReceived; filter sessions by user
src/local/protocol/messages.pyMOD Add user_id to QueryReceived, ResponseGeneration, AnswerDialog
src/local/protocol/envelope.pyMOD Add user_id to metadata
src/local/agents/generator_agent.pyMOD Read + propagate user_id
src/local/agents/memory_agent.pyMOD Pass user_id to write_episodic
src/local/services/memory_service.pyMOD user_id param on write/search; filter by user_id
src/local/services/conversation_service.pyMOD Tag sessions with user_id; filter list/get by user
src/local/services/document_service.pyMOD Per-user collection naming; shared collection concept
src/local/cli.pyMOD Add useradd, userlist, passwd, --claim-default
src/local/run.pyMOD Instantiate UserService; pass to gateway configure()
frontend/src/App.tsxMOD Auth check on load; login screen; first-run setup screen
frontend/src/components/LoginScreen.tsxNEW
frontend/src/components/SetupScreen.tsxNEW
scripts/migrate_default_user.pyNEW Reassign orphaned data
tests/test_user_service.pyNEW
tests/test_auth.pyNEW

11. Out of Scope (Phase 20)