feat(users): unlock and start unencrypted users at boot
Nightly Build / build (push) Successful in 8m10s
Nightly Build / build (push) Successful in 8m10s
A login is what makes an *encrypted* database readable; for an
unencrypted one it gated nothing but the runtime — the file has no key
and is already readable by this process. The cost was user-visible and
read as a bug: after every restart the Telegram bot answered "your
account is locked, log in via the web app", cron fired nothing and no
background agent ran, until a human opened the SPA.
`Skald::new` now calls `UserManager::unlock_all_unencrypted`, which
registers the pools exactly as a login would and refuses an encrypted or
inactive user. Unlocking alone only makes the data readable, so
`wiring::spawn_unlocked_user_runtimes` then builds a `UserContext` for
each — cron, the notify queue, the hub and the per-user MCP runtime all
hang off it. That build is a background supervisor task rather than part
of `new()`: it starts every member's MCP servers inside their container,
and the HTTP listener must not wait behind it. The same two steps run
per user off the lifecycle bus (`UserCreated`,
`UserActiveChanged{active:true}`, after the container `ensure`), so a
member created at runtime does not wait for the next restart.
Two boundaries stay where they were. Authentication is untouched:
`SessionStore` sits above `UserManager`, so no HTTP request
authenticates as anyone because of this. And the auto-unlock is
deliberately not on a lazy path such as `Skald::user_context` —
`revoke_user_runtime` locks a pool synchronously and expects nothing to
re-open it, so the writers of that map stay boot, login and the bus.
`open_db` and the two unencrypted openers now share `register_unlocked`
and `open_unencrypted_file`; `open_unencrypted` (the supervision path)
still does not register its pool.
This commit is contained in:
@@ -59,6 +59,8 @@ Domain words are allowed only in seed data, preset labels, UI copy and positioni
|
||||
|
||||
`UserManager` (§11) is now **consumed**. Login exists (`crates/skald-core/src/auth/mod.rs`: `SessionStore` — `login`/`user_of`/`logout` plus `revoke_user`, the admin-side "drop every session of this user" used by `Skald::revoke_user_runtime`; the deny-by-default middleware is `src/frontend/api/guard.rs`, whose `require_auth` maps token → id and does **not** re-read the row, which is exactly why revocation must be pushed rather than polled; first admin created by `skald-setup`), and the per-user owner-bound runtime is `UserContext` (`crates/skald-core/src/skald/user_context.rs`) — resolved by `Skald::user_context` / the frontend's `require_context`, keyed off `UserManager::pool_of`, and carrying its **own `CancellationToken`** (a child of the instance one) so a single user's cron/hub/MCP loops can be stopped without touching anyone else's. The frontend owner call-sites (WS, sessions, inbox, approval-pending, projects, uploads, run-context, **cron**) route through the per-user pool; dev/stats read `llm_requests` — a *registry* table — from `system.db`, which is correct. The "owner-without-a-user" question resolved to **there isn't one**: every owner content belongs to a logged-in user (the admin included). The global owner-bound bundles (`Conversation`/`Tasks`: the "ownerless" `ChatSessionManager`, `ChatHub`, cron `TaskManager`) are still constructed but **inert** — their loops never spawn and nothing consumes their accessors; removing them is pending follow-on work (kept for now because `RunContextManager` shares the `Conversation` bundle and *is* used, being registry-backed). See blueprint §19.
|
||||
|
||||
**Boot unlocks the databases that have no key, and starts their runtimes.** §9 ties readability to a login, and for an encrypted file that *is* the mechanism — the key only exists once the password has been typed. For an unencrypted one it was a rule with nothing behind it: the data is already readable by anything in this process, so the only thing the login gated was the runtime. The cost was user-visible and looked like a bug — after every restart the Telegram bot answered *"your account is locked, log in via the web app"*, cron fired nothing and no background agent ran, until a human opened the SPA. So `Skald::new` calls `UserManager::unlock_all_unencrypted` (which registers the pools exactly as a login would, refusing an encrypted or inactive user), and `wiring::spawn_unlocked_user_runtimes` then builds a `UserContext` for each — **unlocking only makes the data readable; cron, the notify queue, the hub and the per-user MCP runtime all hang off the context**, so an instance is *working* only once those exist. That build is a background supervisor task, not part of `new()`: it starts every member's MCP servers inside their container, and the HTTP listener must not wait behind that. The same two steps run per user off the lifecycle bus (`UserCreated`, `UserActiveChanged{active:true}`, after the container `ensure`) so a member created at runtime does not wait for the next restart. Two boundaries are untouched and worth stating: **authentication is unaffected** (`SessionStore` sits above `UserManager`; no HTTP request authenticates as anyone because of this), and `open_unencrypted` still exists for the supervision path, still deliberately *not* registering its pool. The auto-unlock is deliberately not on a lazy path (e.g. inside `Skald::user_context`): `revoke_user_runtime` locks a pool synchronously and expects nothing to re-open it, so the writers of that map stay boot, login, and the lifecycle bus.
|
||||
|
||||
Direction of travel, decided but not yet executed: strip the **power-user surface** (self-rewriting, arbitrary shell, dev-agent suite, ticket system) and move to a **binary-first** layout — the app is built once and run from a compiled binary, not executed from its own source tree.
|
||||
|
||||
## Workspace layout
|
||||
@@ -101,7 +103,7 @@ Two rules keep the boundary real, and both are enforced by the compiler:
|
||||
| `crates/skald-core/src/tool_catalog.rs` | `ToolCatalog`: unified tool listing façade (wraps ToolRegistry + McpManager) |
|
||||
| `crates/skald-core/src/events.rs` | `ServerEvent` enum streamed over WebSocket to the frontend |
|
||||
| `crates/skald-core/src/db/` | sqlx SQLite — see below |
|
||||
| `crates/skald-core/src/users/` | `UserManager` (§11): user directory CRUD on `system.db`, credential check, and the map `userid → SqlitePool` of **unlocked** databases. The pool *is* the unlock token — its connect options carry the DEK as SQLCipher's raw key, so an open pool means the key is in RAM (§9) and dropping it re-locks. Knows nothing about cookies: whatever maps an HTTP session to a user id sits above it |
|
||||
| `crates/skald-core/src/users/` | `UserManager` (§11): user directory CRUD on `system.db`, credential check, and the map `userid → SqlitePool` of **unlocked** databases. The pool *is* the unlock token — its connect options carry the DEK as SQLCipher's raw key, so an open pool means the key is in RAM (§9) and dropping it re-locks. Knows nothing about cookies: whatever maps an HTTP session to a user id sits above it. **A login is what unlocks an *encrypted* file only** — see the boot-unlock section below |
|
||||
| `crates/skald-core/src/crypto/` | Envelope encryption (§4/§5.1). A random 256-bit DEK encrypts `{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under `Argon2id(password, salt)`. **The AEAD tag is the password verifier** — one derivation both authenticates and yields the key, and no second hash sits in the admin-readable DB. Cleartext users store the Argon2id output directly, compared constant-time. Argon2 runs in `spawn_blocking` behind a 2-permit semaphore (256 MiB per derivation) |
|
||||
| `src/config.rs` | Loads `config.yml`; LLM clients, strength, data root. All relative paths (db, logs, data, …) resolve against the launch cwd |
|
||||
| `crates/skald-core/src/mcp/` | MCP runtimes + the `McpProvider` seam (§7): the shared host **global** runtime and the per-user **container** runtimes, unioned per session as `UserMcpView`. See the MCP connectors section |
|
||||
|
||||
Reference in New Issue
Block a user