Authorize: getting a token

An agent or a CLI gets its scoped capability token through the platform's browser loop (tosijs-platform#6): the client starts a request with a label (virta's is machine × repo) and the caveats it wants (roles, collections, methods), a human approves it in a browser, and the client exchanges a proof for the token. This module is that protocol, ported from the platform's reference script, with no I/O beyond fetch so it runs in Bun, Node, or a browser. The loopback listener and the token file live in bin/login.ts, which uses it.

Three things matter, and they are the same three the reference names:

  1. The verifier never leaves the process. Only its SHA-256 is sent (PKCE). A consent URL, a browser history entry, or a stolen request id is useless on its own.
  2. The token arrives only at the exchange, over TLS, to whoever holds the verifier. It never passes through the browser.
  3. Loopback mode binds 127.0.0.1 and checks the request id coming back is the one it started, so a phished approval redirects nowhere useful. Poll mode has no such binding — the warning it prints is not decorative: only approve a URL you generated, on a machine you control.
POST /authorize?action=start    { label, caveats, codeChallenge, mode, redirectPort?, ttlMs? }
                                → { status: 'started', requestId, consentUrl, expiresAt, pollIntervalMs }
GET  /authorize?request=<id>    the consent page (a human, signed in)
POST /authorize?action=exchange { requestId, verifier }
                                → { status: 'pending', pollIntervalMs } | { status: 'ready', id, secret, label, caveats, expiresAt }
                                | { status: 'denied' } | { status: 'refused', … }

The token itself is tsp_…, sent as Authorization: Bearer. It attenuates the approver: effective rights are their live roles ∩ the caveats, it cannot mint another token, and it lives at most ninety days.