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:
- 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.
- The token arrives only at the exchange, over TLS, to whoever holds the verifier. It never passes through the browser.
- 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.