Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion docs/accounts.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Accounts and actor identity

Nerve uses local accounts for login and stable actor IDs for attribution.
Nerve uses local accounts for login and stable actor IDs for attribution. In
[external mode](#external-mode), a gateway names the person behind each request
and Nerve does not use local accounts.

| Table | Purpose |
|---|---|
Expand Down Expand Up @@ -116,6 +118,48 @@ Disabling an account blocks its next HTTP or MCP request and any new WebSocket
connection, and closes its open WebSocket connections. Autonomous work, including cron jobs and background agents, uses the
system actor rather than a human account.

### External mode

With `NERVE_AUTH_MODE=external`, a gateway in front of Nerve signs people in
and names the person behind each request. See
[Authentication mode](config.md#authentication-mode).

| Credential | Acts as |
|---|---|
| `X-Nerve-Actor-Context` header | The human actor that the header names |
| Login session | Refused with `401` |
| Nerve CLI and internal API token | The system actor |
| Backend and external MCP token | The system actor |

The header is a compact JWS. Nerve reads `principal_id` and
`profile.display_name` from its payload. It does not check the signature or
any other claim. `principal_id` must be a UUID, and Nerve uses its lowercase
form as the actor ID. `profile.display_name` must be a string, `null`, or
absent. A header that Nerve cannot read, a header that names the system actor,
and a request with more than one header give `401`. When the header is
present, Nerve does not read `Authorization`, the `nerve_token` cookie or
`?token=`. The gateway also sends the header on the WebSocket upgrade request.
The connection keeps the actor of that request until it closes.

Nerve adds an `actor_refs` row of kind `human` when it sees a principal for the
first time, and writes the display name again when it changes. The row has no
`accounts` row. Nerve does not check the access of the person; the gateway
does.

In external mode:

- `POST /api/auth/login` returns `404`. The account routes (`/api/accounts`)
and the setup claim (`/api/setup/claim`) are not available.
- Startup creates no account and deletes a setup token from an earlier local
mode. It still makes the signing secret, because system and MCP tokens need
it.
- Accounts and history from an earlier local mode stay in `nerve.db`.

> **Warning:** Nerve trusts the header. Use external mode only when the
> gateway is the only caller that can reach Nerve. A caller that can send the
> header can act as any person, also as the actor of an account from an earlier
> local mode.

## Attribution

Sessions store who created them in `created_by_actor_id`. User messages store
Expand Down
15 changes: 13 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Treat the returned token as opaque.
| Valid credentials for a disabled account | `401` |
| Setup is not complete (`login` is `setup`) | `409` |
| Authentication is not ready | `503` |
| External mode (`mode` is `external`) | `404` |

#### Authenticated requests

Expand All @@ -36,24 +37,29 @@ or as `?token=` (for `<img src>` and downloads, which cannot set headers).
Every request resolves its token to an account actor or the system principal.
Unknown and disabled accounts fail with `401`; unavailable startup identity
state fails with `503`. See [Accounts and identity](accounts.md) for token
types and legacy-session compatibility.
types and legacy-session compatibility. In external mode, the gateway names the
person in the `X-Nerve-Actor-Context` header and session tokens are refused; see
[External mode](accounts.md#external-mode).

When a session is more than halfway to expiry, the response includes a refreshed
token in `X-Nerve-Token`. Replace the current token with it. The header also
upgrades sessions created before per-account login and is exposed through CORS.

#### `GET /api/auth/status`
Return the required login fields. Authentication is not required.
Return the authentication mode and the required login fields. Authentication is
not required.

```json
Response: {
"mode": "local",
"auth_required": true,
"login": "password"
}
```

| Field | Meaning |
|---|---|
| `mode` | `local` or `external`; see [Authentication mode](config.md#authentication-mode) |
| `login` | `setup`, `none`, `password`, or `username_password` |
| `auth_required` | Compatibility field; equivalent to `login != "none"` |

Expand All @@ -64,6 +70,11 @@ passwordless installation.
The response does not expose usernames or the account count. Before
authentication is ready, it returns the fail-closed `username_password` state.

In `external` mode, the gateway signs people in and local login is not
available. The response does not read local login state and always returns
`auth_required: true` and `login: "username_password"`. Use `mode` to decide
whether to show a login form.

#### `GET /api/auth/check`
Verify current authentication.

Expand Down
23 changes: 23 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -1741,6 +1741,29 @@ Nerve automatically discovers MCP servers from Claude Code's enabled plugins. An
| `auth.password_hash` | string | - | Deprecated compatibility setting. Manage passwords from the Accounts page. If neither this setting nor the sole account has a password, anyone who can reach the gateway can act as the owner. See [Accounts and identity](accounts.md) |
| `auth.jwt_secret` | string | - | JWT signing secret. When unset, Nerve generates one and stores it in `nerve.db`. Changing it requires a restart and signs users out. See [Accounts and identity](accounts.md) |

### Authentication mode

The environment variable `NERVE_AUTH_MODE` sets how Nerve finds the person
behind a request. It is not a configuration key. Nerve reads it once, when the
server starts. A configuration reload does not change it; a restart does.

| Value | Behavior |
|---|---|
| `local` | Default, also when the variable is unset or empty. Local accounts, login and session tokens. |
| `external` | A gateway in front of Nerve names the person behind each request in the `X-Nerve-Actor-Context` header. Local login, account management and the setup page are not available. See [External mode](accounts.md#external-mode). |

Any other value stops `nerve start`, `nerve restart` and the server with an
error. `nerve restart` checks the value before it stops the running daemon.

`nerve doctor` shows the mode that `NERVE_AUTH_MODE` sets in the shell that
runs `nerve doctor`. It does not ask the running server, so the mode of a
daemon that started with a different environment can be different.

> **Warning:** In external mode, Nerve trusts the `X-Nerve-Actor-Context`
> header and does not check its signature. A caller that can send a request to
> Nerve can act as any person. Use external mode only when the gateway is the
> only caller that can reach Nerve.

## API Keys (config.local.yaml)

| Key | Type | Description |
Expand Down
54 changes: 51 additions & 3 deletions nerve/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,11 +318,28 @@ def init(ctx: click.Context, if_needed: bool, non_interactive: bool, inside_dock
)


def _refuse_unknown_auth_mode() -> None:
"""Stop the command when ``NERVE_AUTH_MODE`` has an unknown value.

The server reads the variable when it starts and refuses an unknown
value. ``start`` and ``restart`` check it first, so that they show the
error, and do not stop the running daemon or start one that stops at once.
"""
from nerve.config import ConfigError
from nerve.gateway.auth import auth_mode_from_env

try:
auth_mode_from_env()
except ConfigError as e:
raise click.ClickException(str(e)) from e


@main.command()
@click.option("--foreground", "-f", is_flag=True, help="Run in foreground (don't daemonize)")
@click.pass_context
def start(ctx: click.Context, foreground: bool) -> None:
"""Start the Nerve server."""
_refuse_unknown_auth_mode()
config_dir = Path(ctx.obj["config_dir"])
config = ctx.obj["config"]

Expand Down Expand Up @@ -495,6 +512,8 @@ def restart(ctx: click.Context, resume_ids: tuple[str, ...]) -> None:
the restart: their ids are written to the resume queue now, and the fresh
daemon re-drives each interrupted turn on startup.
"""
_refuse_unknown_auth_mode()

# Enroll ids before anything else so the queue file is on disk — and
# survives even a hard kill — by the time the new instance reads it.
# Append mode lets concurrent enrollments from different sessions coexist.
Expand Down Expand Up @@ -1336,8 +1355,32 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s
# the row), so judging by the row alone told operators that an *active*
# password did nothing — and removing it on that advice would have opened
# the instance.
#
# In external mode, the gateway names people and local accounts are not
# used, so the account checks do not apply. The doctor does not ask the
# running server for its mode: it reads NERVE_AUTH_MODE in its own shell,
# and the environment of the daemon can be different.
from nerve.config import ConfigError
from nerve.db.accounts import inspect_bootstrap_state, read_setup_required
from nerve.gateway.auth import source_authenticates
from nerve.gateway.auth import (
AUTH_MODE_ENV,
AUTH_MODE_EXTERNAL,
auth_mode_from_env,
source_authenticates,
)

mode_source = f"from {AUTH_MODE_ENV} in this shell, not from the running server"
try:
mode = auth_mode_from_env()
except ConfigError as e:
mode = None
errors.append(
f"[ERR] Auth mode ({mode_source}): {e} The server does not start "
"with this value."
)
else:
lines.append(f"[OK] Auth mode: {mode} ({mode_source})")
external = mode == AUTH_MODE_EXTERNAL

configured = bool(config.auth.password_hash)
identity_state = inspect_bootstrap_state(paths.db_path())
Expand All @@ -1346,7 +1389,12 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s
source for source in (sources or [])
if source_authenticates(source, configured_password=configured)
]
if sources is None:
if external:
lines.append(
"[--] Accounts: not used in external mode; the gateway names the "
"person behind each request"
)
elif sources is None:
lines.append("[--] Accounts: nerve.db not created yet (first start will)")
elif not sources:
warnings.append("[WARN] No local account yet — the next start creates one")
Expand All @@ -1367,7 +1415,7 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s
lines.append(
f"[OK] Accounts: {len(sources)} ({len(usable)} with a password)"
)
if configured and sources is not None:
if configured and sources is not None and not external:
reading = [source for source in sources if source != "local"]
if reading:
lines.append(
Expand Down
24 changes: 24 additions & 0 deletions nerve/db/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,30 @@ async def update_actor_profile(
)
return await self.get_actor_ref(actor_id)

async def upsert_external_actor(
self, actor_id: str, display_name: str | None,
) -> None:
"""Add a human actor that the gateway names, or write its new name.

``_external_actor_names`` holds the last name written for each ID in
this process. When the name is the same, no statement runs, so a
normal request does not take the SQLite write lock.

Raises :class:`sqlite3.IntegrityError` for the system actor's ID: the
trigger ``system_actor_cannot_be_replaced`` aborts the insert.
"""
written = self._external_actor_names
if actor_id in written and written[actor_id] == display_name:
return
await self._write(
"""INSERT INTO actor_refs (id, kind, display_name, created_at)
VALUES (?, 'human', ?, ?)
ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name
WHERE actor_refs.display_name IS NOT excluded.display_name""",
(actor_id, display_name, _now()),
)
written[actor_id] = display_name

# -- accounts ------------------------------------------------------------

async def get_account(self, account_id: str) -> dict | None:
Expand Down
3 changes: 3 additions & 0 deletions nerve/db/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,8 @@ def __init__(self, db_path: Path, workspace: Path | None = None):
# a caller or test can tune them before connect() (e.g. busy_timeout=0).
self._pragmas: dict[str, object] = dict(_DEFAULT_PRAGMAS)
self._system_actor: Actor | None = None
# Actor ID to the last display name upsert_external_actor() wrote.
self._external_actor_names: dict[str, str | None] = {}
# The state-file modes connect() found after its repair. The identity
# bootstrap reads this before it stores a signing secret in the
# database (nerve.migrate._refuse_insecure_secret_storage).
Expand Down Expand Up @@ -513,6 +515,7 @@ async def close(self) -> None:
await self._db.close()
self._db = None
self._system_actor = None
self._external_actor_names.clear()

@property
def db(self) -> aiosqlite.Connection:
Expand Down
Loading
Loading