From eeb8259ba46ef34cf91cb946ce2395d69fc44a12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:38:32 +0000 Subject: [PATCH] Document user accounts: README section, config table, changelog, .env.example Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR --- .env.example | 25 ++++++++++++++++++- CHANGELOG.md | 46 +++++++++++++++++++++++++++++++++++ README.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 135 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index ab22cfe..91524d3 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3c066..ed43519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 499e3cd..d6e6087 100644 --- a/README.md +++ b/README.md @@ -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 `. `GET /health` is unauthenticated. +All routes require `Authorization: Bearer ` — 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 |