Sentinel started as a single-user system. One PIN, one workspace, one set of memories. Everything assumed user ID 1. That assumption was baked in everywhere — hardcoded in API routes, embedded in tool calls, implicit in how files were stored and retrieved.
The multi-user work was a hard cutover, not a gradual migration. PIN-only authentication was replaced entirely with JWT. No dual-auth period, no fallback path. The first time the system starts, it reads the PIN file once to bootstrap the owner account in the database. After that, authentication is JWT-only — login with username and PIN, receive a token, include it in every request.
The token design uses sliding refresh rather than separate refresh tokens. Every API response includes a fresh JWT in the X-Refreshed-Token header. The frontend silently swaps it. Tokens expire after one hour, but active use keeps extending the window. No refresh token rotation to manage, no token storage beyond localStorage. A user who’s actively using the system never sees a login prompt; one who walks away for an hour gets a clean re-auth.
JTI (JWT ID) claims enable per-token revocation. An admin can invalidate a specific session without nuking everyone. The revocation set lives in memory — fast lookups, but it doesn’t survive restarts. A persistent sessions_invalidated_at timestamp provides the backup: revoke all tokens issued before a certain time. The gap between “revoke one token” and “revoke all tokens before timestamp” is acceptable at the current scale.
Workspace isolation was the more architectural change. Files moved from a flat /workspace/ to /workspace/{user_id}/. A single get_user_workspace() helper reads the current user from a ContextVar — it’s the only place in the codebase where user ID maps to a file path. Every file tool, every site builder, every path reference goes through this function.
The ContextVar propagation surfaced a subtle async bug. asyncio.create_task() doesn’t inherit the parent’s context variables. A background task spawned to handle logging or cleanup would lose track of which user triggered it. The fix was a spawn_task() wrapper that copies the context before spawning. Small function, non-obvious problem.
The settings UI gives users a profile panel — change PIN, view trust level, see assigned channels. Admins get an additional user management tab: create accounts, adjust roles, manage sessions. A non-blocking banner nudges users to change their default PIN on first login.
WebSocket auth needed a workaround. Browsers can’t send custom headers on WebSocket upgrade requests, so the JWT goes as a query parameter. It appears in server logs, which isn’t ideal, but it’s acceptable for a self-hosted system at this scale. A future option is first-message authentication — send the token as the first WebSocket frame — but that adds protocol complexity for a marginal security gain in this context.
The hardcoded user_id=1 references were scattered across more files than expected. API routes, tool handlers, memory stores, webhook dispatchers, routine schedulers. Each one needed to be replaced with current_user_id.get(). Some were obvious. Others were buried in helper functions three calls deep. The kind of cleanup where you think you’ve found them all, then a test fails in an unexpected place and you find another one.
Static file serving remained intentionally unauthenticated. Users build websites through Sentinel and share the URLs — requiring auth to view them defeats the purpose. The trade-off is that user IDs in the URL path are sequential and enumerable. Fine for self-hosted use. Would need addressing before any kind of public deployment.