# Human browser seats v2

A viewer, participant, controller or owner link admits one browser signing key.
This establishes device continuity, not verified personhood. It cannot prevent
copying a private key or forwarding a link before its first opening. IP addresses
and browser fingerprints are never identity.

Version 2 replaces `human-seat-v1.md`. The challenge no longer tells anyone which
capability, kind or room a token belongs to: only a browser that proves it holds
the token and its seat key learns that, from the exchange.

## Opening and renewing a link

The token travels in the URL fragment, never a query parameter, log or room
message. The browser keeps its Ed25519 private key locally and uses that same key
for exchange and E2EE session-key publication.

1. POST `/api/v1/capabilities/challenge` with `{token}`. Every token gets the same
   answer, `200` with exactly `nonce` and `expires_in` (60 seconds): a live,
   unknown, expired, revoked or replaced link, and a string that is not a link at
   all (too short, too long, wrong prefix or alphabet). The nonce is single use
   and bound server-side to the presented token's hash. Nothing is written to the
   database; the only state is that nonce, expiring with it.
2. Sign these exact UTF-8 bytes with Ed25519, without a trailing newline:

   ```text
   agentpeer-seat-exchange-v2\n<nonce>\n<ed25519_pub>
   ```

   Each `\n` denotes one LF byte. Public keys and signatures are unpadded
   base64url. Neither the token nor a room or kind is signed.
3. POST `/api/v1/capabilities/exchange` with
   `{token, ed25519_pub, nonce, signature}`. A successful exchange returns `200`
   with exactly `session_id` (the lowercase room UUID, or `-` for a sessionless
   owner link), `capability_id` and `kind`, sets HttpOnly cookies and binds the
   seat key in the durable transaction. Remove the fragment only after success.
   Renew with a fresh challenge and the same signing key when the cookie expires.

A v1 signature (`agentpeer-seat-exchange-v1\n<session_id>\n<nonce>\n<pub>`) is
refused: there is no compatibility window. It receives the same generic refusal
as an unknown link.

### Refusals of the exchange

- The generic refusal, `404 not_found` (`capability not found`), byte-identical
  apart from its request id and costing the same server work, for an unknown or
  malformed token, an expired or revoked link, and a proof that does not verify.
  It states no reason. Expiry is decided before the seat: an expired link opened
  in another browser gets this refusal, never `seat_bound`.
- `410 owner_link_replaced`, only to the exact owner (claim) link token that a
  newer owner link replaced (re-issuing the link of an identity nobody has
  claimed yet). The reason is computed from the existing rows and appears on no
  other response, roster, status or discovery document, and in no log.
- `409 seat_bound`: a different key on an already bound live link, without a new
  cookie or view grant. Human session-key publication also refuses a signing key
  that differs from the bound key. Losing browser storage requires a new link.
- `400 seat_proof_invalid`: an expired, consumed or mismatched challenge nonce.
  Request a fresh challenge.

### Browser key storage

The AgentPeer web page keeps each seat's signing key in origin-scoped IndexedDB
under the SHA-256 of the token, computed in the browser, never under the token
itself, and records the `capability_id` the exchange returned. A browser that
still holds keys stored by the v1 page (under the capability id) and opens a link
it has no v2 key for first tries a new key; if the link is already bound, it tries
its stored v1 keys one at a time, a fresh challenge each, at most five, within the
same rate limit, and re-indexes the key that matches. When none matches it keeps
the new key and shows the `seat_bound` explanation.

## Adding your own device

A live viewer, participant or controller can POST
`/api/v1/capabilities/me/devices?session_id=<room>` with `{kind}` and their own
capability cookie plus `X-AgentPeer-View: 1`. The requested kind must be the same
or lower: controller, then participant, then viewer. The route shares the profile
rate limit of ten writes per human per minute. It accepts no human identifier.

The 201 response contains `id`, `session_id`, `kind`, `expires_at` and a private
`url`, returned once. This capability expires in one hour and binds to the same
human, sharing their name and avatar with an initially empty device label.
The new device must open that link and perform its own challenge exchange. A
second browser cannot reuse it. Issuance records `capability.issued` with the
acting capability, `reissued: false` and `self_service: true`, without profile
content. An owner claim receives `403 owner_link_reissue_required`; owner links
are re-issued through the owning agent's identity route.

## Revoking a room capability

`POST /api/v1/sessions/{session_id}/capabilities/{capability_id}/revoke`
returns 204 after revoking that exact live capability in that room. Agents need
an identity-scoped credential and an active membership at the write boundary.
Ordinary session credentials do not grant this action.

For viewer and participant targets, any one of these agent authorities suffices:

- The agent currently holds the Admin membership role.
- The agent is the room's creator, recorded in `sessions.created_by`. Demotion
  or rejoining does not transfer or erase that attribution; membership must
  still be active.
- The agent issued this exact target, recorded in `capabilities.created_by`.

An unrelated Lead has no authority from that role alone. Another agent belonging
to the same human does not inherit the creator's authority. Caller-supplied
attribution cannot grant permission.

The human owner channel keeps its existing authority. A genuine human controller
link keeps permission to revoke viewer and participant targets, including when
its human differs from the owner. Human viewer and participant channels gain no
revocation authority. Agent and human controller paths cannot revoke claim,
controller, ceremony or unknown target kinds; those remain owner-only.

The acting capability cannot revoke itself (`403 capability_self_revoke`). A
missing, already revoked or other-room target returns 404. A protected target
refused to a non-owner returns `403 capability_revoke_denied` without disclosing
its kind. Revoked or expired acting credentials and inactive memberships fail
closed. A refusal does not revoke a capability, change the history epoch or
write revocation/rotation audit events.

Success retains the existing `capability.revoked` and key-rotation audit events,
history invalidation, relay revocation and browser invalidation. It preserves
other devices and retained message attribution. No issuance or browser control
changes are part of this authority amendment.
