Skip to content

Add the hosted frontend adapter for external auth mode - #483

Open
alex-clickhouse wants to merge 4 commits into
alex/external-mode-01-authfrom
alex/external-mode-02-web
Open

alex-clickhouse wants to merge 4 commits into
alex/external-mode-01-authfrom
alex/external-mode-02-web

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

What

Add the hosted web adapter for external authentication mode (#482). The gateway owns browser authentication and agent admission; Nerve reads the verified actor and attributes work to that person. Stacked on #482; this PR changes only web/.

Behavior

  • Discover the mode through GET /api/auth/status without a bearer before reading identity or opening a WebSocket. A stale local token cannot override the gateway cookie. Missing or unknown modes retain compatibility with local backends.
  • In hosted mode, send no local bearer or ?token=, and add X-Nerve-CSRF: 1 to unsafe requests, including uploads. Local requests retain their token authentication.
  • Hide local login, setup, accounts, session-expiry prompts, and Logout in both navigation layouts. Hosted account controls belong to the control plane; the agent UI does not end either session.
  • Save drafts, unsent new-chat IDs, and read state under the verified principal. Rehydrate the matching state before showing the app. Another principal on the same origin gets their own state, and an older tab continues writing to its original principal's storage. Unowned legacy drafts are not adopted by a hosted principal.
  • Save composer and queued-message text before gateway re-entry stops the app. A 401 login_required starts re-entry; other 401s probe the gateway session first to avoid backend-authentication login loops.
  • Check the gateway session immediately after a socket closes and before every new upgrade. Require a valid response for the current principal. A changed principal saves the previous person's work and starts a fresh page; an unavailable check retries without opening a socket or flushing queued messages.
  • Show dedicated screens for denied access, archived agents, and temporary gateway failures. Gateway error bodies also establish hosted mode when startup status cannot be read.
  • Use one URL helper for sockets, images, and downloads. Actor labels use display name, login name, the system name, or a short actor ID.

Validation

  • Frontend suite: 26 files, 481 tests pass.
  • Production build (tsc -b && vite build) passes.
  • Lint has the same 148 findings as the previous PR head, with no new findings.
  • Regression coverage uses the real client and stores with controlled gateway responses for consecutive stale-token page loads, local-token startup, Alice/Bob/Alice state restoration, writes from older tabs, and queued-message preservation across principal changes. Socket tests also cover changes during the retry delay and obsolete probe responses. Navigation tests verify hosted account/logout controls are absent while local controls remain.

Limits

  • Composer attachments remain in memory and do not survive a gateway login redirect.
  • If the initial page navigation receives gateway JSON while the VM starts, the app cannot render its unavailable screen; that page belongs to the gateway.
  • The separate hosted-repository two-user gate is still pinned to 926d9eb6. It passed for that revision and has not been rerun for these changes.

@alex-clickhouse
alex-clickhouse added this pull request to stack #407 October 5, 2026 07:30
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review October 5, 2026 09:21
alex-clickhouse and others added 4 commits October 5, 2026 11:25
A hosted actor comes from the gateway and has no local account, so it
has no login name. The label "Unnamed account" is wrong for it and
does not tell two such actors apart.

actorName() uses the display name, then the login name, then "Nerve"
for the system actor, then the first eight characters of the actor id.
ActorLabel gives the id also when the actor directory does not have the
actor yet. The title still shows the full id.

The two tests that asserted the "Unnamed account" fallback assert the
short id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In external mode, Nerve runs behind the gateway. The gateway signs the
browser in with its own session cookie and gives Nerve the actor. The
web UI must not use the local login, setup, accounts or token paths
there, because they do not exist and a stale bearer gives 401.

GET /api/auth/status reports the mode. A missing or unknown mode is
local, so the UI works with an older backend. The new module
api/hosted.ts holds the hosted parts, so the tests that mock api/client
with a fixed export list stay valid.

In hosted mode:
- Startup always reads /api/auth/me and does no passwordless login.
  The login page, the setup page, the session-expired overlay, the
  /accounts and /setup routes and the Accounts nav item do not show.
- Requests send no Authorization header, do not read or write the
  local token, and send X-Nerve-CSRF: 1 on unsafe methods.
- A 401 writes the composer text and the queued WebSocket messages to
  the drafts, then goes to /_nerve/login with return_to.
- A gateway 403 access_denied, 410 or 503 agent_archived, and 503 or
  502 unavailable or backend_unavailable show their own screens. The
  first two stop the app and its WebSocket.
- Before each WebSocket retry, the socket asks GET /_nerve/session.
  The gateway clears its session cookie when it refuses an upgrade, so
  the check must run before the next upgrade to see 403 and not 401.
- Logout purges the account state, posts /_nerve/logout and goes to /.

API errors are an ApiError with status and reason. The message keeps
the "NNN: body" format that errorDetail parses. Image, file and
WebSocket URLs get ?token= only in local mode and only with a token,
so no URL gets token=null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 401 in hosted mode always started a gateway login. When Nerve itself
refuses a request while the gateway session is valid, for example for
an actor header that it cannot read, the login comes back to the same
401 and the browser loops through the control-plane login.

The gateway answers login_required when a request has no session. Only
that 401 signs in again at once. Any other 401 asks /_nerve/session: a
401 there signs in again, 403 and 410 show their screens, and a valid
session shows the unavailable screen. Both fetch paths use this rule.

A gateway error body ({"reason", "requestId"}) also proves that the
page runs behind the gateway. When the status call fails with such a
body, for example while the agent VM starts, the mode becomes external
and the matching screen shows with its retry, not the local login
page. A failure without a gateway body keeps the local behavior. Only
the gateway writes this body shape; Nerve puts error details below
"detail".

The gateway answers 503 agent_starting, agent_paused and
agent_unavailable and 504 backend_timeout for an agent that cannot
answer at this time. They show the unavailable screen. 503
agent_archived keeps the archived screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alex-clickhouse
alex-clickhouse force-pushed the alex/external-mode-02-web branch from 4f8e287 to 659a734 Compare October 5, 2026 09:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant