Document user accounts: README section, config table, changelog, .env.example

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR
This commit is contained in:
Claude
2026-08-12 19:38:32 +00:00
parent 722a2f344c
commit eeb8259ba4
3 changed files with 135 additions and 4 deletions
+24 -1
View File
@@ -5,9 +5,32 @@
# Postgres: postgresql+psycopg://user:pass@host:5432/handler
DATABASE_URL=sqlite:////var/lib/handler/handler.db
# Single global bearer token gating every API route. Required for the API to start.
# Legacy/machine bearer token gating every API route. Human operators now sign in with
# email + password (user accounts — first sign-up becomes the admin); this token remains
# for scripts/CI and as a break-glass credential. Optional once accounts exist.
AUTH_TOKEN=change-me-to-a-long-random-string
# ---- User accounts (email + password sign-in for the dashboard/API) ----
# Browser session lifetime and one-shot link validity. Defaults shown.
# SESSION_TTL_DAYS=30
# RESET_TOKEN_TTL_HOURS=2
# INVITE_TOKEN_TTL_HOURS=168
# Outbound email for invite + password-reset links. Leave SMTP_HOST unset to run without
# email: invite/reset links are then shown to the admin in the dashboard instead of
# mailed, and self-serve "forgot password" tells users to ask an admin.
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USERNAME=
# SMTP_PASSWORD=
# SMTP_FROM=handler@example.com
# SMTP_STARTTLS=true # STARTTLS on port 587 (the common setup)
# SMTP_SSL=false # implicit TLS on port 465 instead
# Base URL the emailed links point at, e.g. https://handler.example.com. Falls back to
# each request's own origin when unset (right for the same-origin bundled UI).
# PUBLIC_BASE_URL=
# Optional higher-trust token gating PUT /shared/context/:key.
# Falls back to AUTH_TOKEN if unset.
# SHARED_CONTEXT_WRITE_TOKEN=
+46
View File
@@ -6,6 +6,52 @@ the image workflows publish (plus `latest` from every push to `main`).
## [Unreleased]
### Added — user accounts: email sign-in, invites, resets, per-user separation
- **Email + password accounts** replace "know the API key" for humans. First run shows
a setup form and the **first account created is the admin**; every later account is
**invited by an admin** through a one-shot set-password link. Passwords are scrypt
(stdlib, self-describing hashes); sessions are opaque bearer tokens stored only as
SHA-256 with a configurable TTL.
- **Password reset by email** (`POST /auth/forgot` → short-lived link, silent about
account existence) via plain SMTP (`SMTP_*` settings). **Email is optional**: with
SMTP unset, invite/reset links are shown to the admin in the dashboard to hand over
out-of-band. Spending a link revokes the account's existing sessions.
- **Per-user separation of projects, skills, and tools.** Projects, skills, MCP
connectors, plugins, and model backends gain an owner; users see **shared + their
own** (foreign resources 404 — existence isn't leaked), owners operate their own
projects end-to-end without admin, shared (unowned) rows stay admin-managed and
visible to all. Launches materialize only the project owner's skills/connectors, and
private model backends can't be picked for someone else's spawns or schedules.
Deleting a user reassigns their resources to shared; admins can reassign owners.
- **Users page** in the dashboard (admin-only): invite, admin/disable toggles, reset
links, delete. Sign-in page gains first-run setup, forgot-password, and a raw
API-token fallback; `/reset` is the public landing page for invite/reset links.
- **Admin safety rails**: the last active admin can't be demoted/disabled/deleted; no
self-deletion. `AUTH_TOKEN`/`ADMIN_TOKEN`/`SHARED_CONTEXT_WRITE_TOKEN` keep their
exact historical semantics for scripts/CI and break-glass.
- 24 new tests (auth flows + separation matrix; 397 total).
### Database (user accounts)
- Migration **`0016_user_accounts`**: new `users`, `auth_sessions`, `auth_tokens`
tables plus a nullable `owner_user_id` on `projects`, `claude_skills`,
`claude_connectors`, `claude_plugins`, `claude_models`. Purely additive; existing
rows have no owner (= shared) so an upgraded deployment behaves exactly as before
until accounts are created.
### Deployment notes (user accounts rollout)
1. Apply migrations as usual (the API container runs them on start).
2. Optionally set `SMTP_HOST`/`SMTP_PORT`/`SMTP_USERNAME`/`SMTP_PASSWORD`/`SMTP_FROM`
(+ `SMTP_STARTTLS`/`SMTP_SSL`) and `PUBLIC_BASE_URL` for emailed links; without
them, invite/reset links appear in the dashboard instead.
3. Open the dashboard and create the first account — it becomes the admin. Existing
`AUTH_TOKEN`-based scripts keep working unchanged; the token can be rotated or
dropped once accounts exist (keep one as break-glass if you like).
4. New TTL knobs (optional): `SESSION_TTL_DAYS=30`, `RESET_TOKEN_TTL_HOURS=2`,
`INVITE_TOKEN_TTL_HOURS=168`.
### Added — the pi harness for local models ([#29](https://github.com/0xWheatyz/handler/pull/29))
- **`harness` on model backends** (`claude` | `pi`, default `claude`). A backend row can
+65 -3
View File
@@ -126,9 +126,12 @@ Configuration is entirely environment-driven (see [`.env.example`](.env.example)
| Variable | Purpose | Default |
|---|---|---|
| `DATABASE_URL` | `sqlite:////abs/path.db` or `postgresql+psycopg://…` | `sqlite:///./handler.db` |
| `AUTH_TOKEN` | Global bearer token gating every API route | *(required for the API)* |
| `AUTH_TOKEN` | Legacy/machine bearer token (scripts, CI, break-glass) — humans sign in with email + password instead ([user accounts](#user-accounts--sign-in)) | unset → env-token auth off |
| `SHARED_CONTEXT_WRITE_TOKEN` | Higher-trust token gating `PUT /shared/context/:key` | falls back to `AUTH_TOKEN` |
| `ADMIN_TOKEN` | Gates the web control surface (enqueue commands, project/host CRUD, credential edits) | falls back to `AUTH_TOKEN` |
| `ADMIN_TOKEN` | Admin-level env token (enqueue commands, project/host CRUD, credential edits) | falls back to `AUTH_TOKEN` |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_STARTTLS` / `SMTP_SSL` | Outbound email for invite + password-reset links | unset → links shown to the admin instead of mailed |
| `PUBLIC_BASE_URL` | Base URL emailed links point at | unset → the request's own origin |
| `SESSION_TTL_DAYS` / `RESET_TOKEN_TTL_HOURS` / `INVITE_TOKEN_TTL_HOURS` | Session and one-shot-link lifetimes | `30` / `2` / `168` |
| `WEBHOOK_URL` | Generic target for the `Notification` hook (ntfy, Slack, …) | unset → no-op |
| `SEARXNG_URL` / `BRAVE_SEARCH_API_KEY` | Provider for the agents' `web_search` tool (pi harness) | unset → DuckDuckGo fallback |
| `HANDLER_SECRET_KEY` | Fernet key encrypting git-server tokens + SSH keys at rest (set the same value on API and control) | unset → secret store disabled |
@@ -204,6 +207,56 @@ docker compose run --rm control handler list
docker compose run --rm control handler spawn --project leeworks-api --name junior --task "…"
```
## User accounts & sign-in
Humans no longer need to know an API key. The dashboard signs in with **email +
password**, and the accounts model is deliberately small-team-shaped:
- **First run**: with zero accounts, the sign-in page becomes a setup form. The first
account created **is the admin**. (`POST /auth/setup` refuses once any account exists.)
- **Everyone else is invited by an admin** (Users page / `POST /auth/users`): creating a
user mints a one-shot **invite link** through which the invitee sets their own
password. With SMTP configured the link is emailed; either way it is shown to the
admin, so email is optional infrastructure, not a requirement.
- **Password reset by email**: "Forgot password?" mails a short-lived reset link
(`POST /auth/forgot` — silent about whether the address exists). Without SMTP, an
admin mints a reset link from the Users page instead. Spending a link revokes every
existing session for that account.
- **Sessions** are opaque bearer tokens (only their SHA-256 is stored), sent exactly
like the old token: `Authorization: Bearer …`. `POST /auth/logout` revokes one;
changing a password revokes the rest.
- **Admin safety rails**: the last active admin can't be demoted, disabled, or deleted;
you can't delete your own account.
### Per-user separation
Every project, skill, MCP connector, plugin, and model backend is either **owned** by
one user or **shared** (no owner). The rules, everywhere:
- A user sees **shared + their own** — another user's resources don't exist for them
(listings filter, direct lookups 404, so existence isn't leaked).
- Creating a resource makes you its owner; owners manage their own resources without
admin help (spawn/kill agents, schedules, approvals, sync, memory notes — everything
project-nested follows the project's owner).
- **Shared resources are admin-managed** and behave exactly like the pre-accounts world:
visible to all, editable by admins. Legacy rows all land here on upgrade, so nothing
changes until people start owning things.
- At **launch**, an agent gets only what its project's owner can see: their skills +
connectors + the shared set. One user's tools never reach another user's agents, and
a private model backend can't be selected for someone else's spawn or schedule.
- Deleting a user **reassigns their resources to shared** (never orphans or deletes
work); an admin can also reassign a project's owner explicitly (`PATCH /projects/:p`).
- Global infrastructure stays admin-only: git servers, the Claude account login,
permission overrides, global memory notes, and user management itself.
### Legacy env tokens
`AUTH_TOKEN` / `ADMIN_TOKEN` / `SHARED_CONTEXT_WRITE_TOKEN` keep working with their
historical semantics (see-everything machine credentials; the admin token passes admin
gates). They're the right tool for scripts and CI — and the break-glass if every admin
is locked out. Resources they create are shared. The dashboard's sign-in page keeps a
"Use an API token" fallback for token-only deployments.
## Web management
The dashboard (and the API under it) manages everything — git credentials & hosts,
@@ -350,10 +403,19 @@ agent's identity and `DATABASE_URL` injected into its environment. `--role`
## API reference
All routes require `Authorization: Bearer <AUTH_TOKEN>`. `GET /health` is unauthenticated.
All routes require `Authorization: Bearer <token>` — a user session token from
`POST /auth/login` or a legacy env token. `GET /health`, `GET /auth/status`, and the
account bootstrap routes (`setup`/`login`/`forgot`/`reset`) are unauthenticated.
| Method & path | Purpose |
|---|---|
| `GET /auth/status` | `{initialized, smtp_configured}` — drives the setup-vs-signin page |
| `POST /auth/setup` | Create the first account (becomes the admin) |
| `POST /auth/login` · `POST /auth/logout` | Session lifecycle (opaque bearer, hash-stored) |
| `GET /auth/me` · `POST /auth/change-password` | Who am I / rotate my password |
| `POST /auth/forgot` · `POST /auth/reset` | Email reset link / spend a reset or invite link |
| `GET`/`POST /auth/users` · `PATCH`/`DELETE /auth/users/:id` | Admin user management (invite links) |
| `POST /auth/users/:id/reset-link` | Admin-minted reset/invite link (the no-SMTP path) |
| `GET /projects` · `POST /projects` | List / register projects |
| `GET /projects/:p/agents` · `POST …` | List / register agents (project-scoped) |
| `GET /projects/:p/agents/:name/checkmark` | The agent's current-state checkmark |