|
| 1 | +--- |
| 2 | +number: "0018" |
| 3 | +slug: connectrpc-gateway-for-browser-product-surfaces |
| 4 | +status: accepted |
| 5 | +date: 2026-07-08 |
| 6 | +--- |
| 7 | + |
| 8 | +# ADR 0018: ConnectRPC Gateway for Browser Product Surfaces |
| 9 | + |
| 10 | +## Context |
| 11 | + |
| 12 | +The platform is getting its first product-facing web application, the operator |
| 13 | +console ([ADR 0019](./0019-console-webapp-stack.md)). A browser has to call |
| 14 | +first-party platform services, and every existing first-party RPC path lives on |
| 15 | +the NATS backbone: protobuf services bind to NATS micro |
| 16 | +([ADR 0016](./0016-protobuf-rpc-over-nats-micro-binding.md)), the JSON-RPC |
| 17 | +family binds to NATS subjects |
| 18 | +([ADR 0011](./0011-jsonrpc-over-nats-binding.md)), and every signed request on |
| 19 | +the mesh carries the AAuth NATS PoP envelope |
| 20 | +([ADR 0017](./0017-aauth-agent-authentication.md)). |
| 21 | + |
| 22 | +NATS itself does not keep a browser off the backbone. `nats-server` ships a |
| 23 | +WebSocket listener, `nats.ws` runs in browsers, and NATS has real |
| 24 | +connection-level authentication: passwords, tokens, NKey challenge-response, |
| 25 | +decentralized user JWTs, and auth callout, which can mint an ephemeral, |
| 26 | +subject-scoped NATS user from an external credential such as a web session. A |
| 27 | +browser connection to the backbone is technically achievable. |
| 28 | + |
| 29 | +Connection authentication is not the boundary that matters here. First-party |
| 30 | +requests on the mesh carry the AAuth PoP envelope: each request is signed with |
| 31 | +a `cnf.jwk`-bound private key, with nonce and content-digest bookkeeping. A |
| 32 | +browser tab cannot provide that custody, because any key the page's scripts |
| 33 | +can use, an injected script can use and exfiltrate. Putting browsers on the |
| 34 | +bus would also make the internal subject namespace an internet-facing surface |
| 35 | +whose authorization story is NATS subject permissions rather than a reviewed |
| 36 | +method allowlist, and it would require a JavaScript reimplementation of the |
| 37 | +ADR 0011/0016/0017 bindings that must track the Rust implementations forever. |
| 38 | + |
| 39 | +[ADR 0003](./0003-ai-protocol-transport-taxonomy.md) already selects the API |
| 40 | +style for this situation. Its boundary selection order names |
| 41 | +"browser-compatible HTTP access" as a trigger for a first-party service API and |
| 42 | +prefers ConnectRPC for that surface. What ADR 0003 does not decide is where the |
| 43 | +boundary lives, who holds which credentials, and how a ConnectRPC method |
| 44 | +reaches a NATS micro endpoint. Without one rule, each product surface would |
| 45 | +invent its own bridge, its own token custody, and its own error mapping, the |
| 46 | +same per-call-site drift ADR 0011 and ADR 0016 exist to prevent. |
| 47 | + |
| 48 | +## Decision |
| 49 | + |
| 50 | +### 1. Product web surfaces reach the mesh only through a gateway |
| 51 | + |
| 52 | +A browser product surface talks to a first-party gateway service exposing |
| 53 | +ConnectRPC over HTTPS. The gateway is a gateway in the ADR 0003 sense: a |
| 54 | +production edge component that accepts external traffic and routes it inward, |
| 55 | +containing a bridge onto the backbone. |
| 56 | + |
| 57 | +The ConnectRPC surface is generated from the same `.proto` sources that define |
| 58 | +the backbone services ([ADR 0009](./0009-protocol-buffers-wire-contracts.md)). |
| 59 | +Browser clients are generated with `protobuf-es` and `connect-es` from the |
| 60 | +same Buf pipeline that generates the Rust code. There is no hand-written HTTP |
| 61 | +client, no parallel OpenAPI document, and no GraphQL layer; the exceptions ADR |
| 62 | +0003 allows for OpenAPI remain exceptions and are not triggered by a |
| 63 | +first-party browser surface. |
| 64 | + |
| 65 | +Browsers do not connect to NATS directly, even though the WebSocket listener |
| 66 | +and auth callout would make a scoped connection possible. Connection-level |
| 67 | +NATS auth cannot substitute for the per-request AAuth PoP envelope, and the |
| 68 | +envelope is exactly what a page cannot sign safely. Browser NATS access, if a |
| 69 | +narrow read-only case ever justifies it, requires its own decision and does |
| 70 | +not weaken this default. |
| 71 | + |
| 72 | +### 2. The gateway is the credential boundary |
| 73 | + |
| 74 | +The human operator authenticates to the gateway with an OAuth 2.0 |
| 75 | +Authorization Code + PKCE flow. The gateway completes the code exchange, holds |
| 76 | +the resulting tokens server-side, and issues an HttpOnly, SameSite session |
| 77 | +cookie to the browser. The browser never receives an AAuth token, a signing |
| 78 | +key, or a NATS credential of any kind. |
| 79 | + |
| 80 | +Browser auth is cookie-based, and token-in-page patterns are prohibited: no |
| 81 | +access, refresh, ID, or session token in `localStorage`, `sessionStorage`, |
| 82 | +IndexedDB, JavaScript-readable cookies, or long-lived JavaScript memory, and |
| 83 | +no `Authorization: Bearer` header minted by page code. The session cookie is |
| 84 | +an opaque identifier, not a JWT; session state lives in the gateway. Anything |
| 85 | +JavaScript can read, an injected script can exfiltrate; an HttpOnly cookie |
| 86 | +limits an XSS to riding the live session, which is the strictly smaller |
| 87 | +failure. Cookie-based auth carries CSRF obligations, which the gateway owns: |
| 88 | +`SameSite` on the cookie, strict `Origin` checking on every state-changing |
| 89 | +request, and CORS locked to the product surface's origin. |
| 90 | + |
| 91 | +On the mesh, the gateway is an agent under ADR 0017: it holds its own |
| 92 | +`aa-agent+jwt` and signing key, signs every backbone request with the Trogon |
| 93 | +NATS PoP binding, and, when acting on behalf of an authenticated operator, |
| 94 | +presents the operator-linked `aa-auth+jwt` in `AAuth-Auth-Token` alongside its |
| 95 | +own agent token. Person-linked authorization therefore rides the same |
| 96 | +mechanism every other agent on the mesh uses; the gateway adds no parallel |
| 97 | +identity scheme. |
| 98 | + |
| 99 | +Auth layering is explicit. NATS auth callout (`a2a-auth-callout`) is |
| 100 | +connection admission: it decides which clients may attach to the backbone and |
| 101 | +scopes their subject permissions, and the gateway attaches under that |
| 102 | +admission like any other mesh client. The AAuth PoP envelope is request |
| 103 | +authentication, and the policy tiers are authorization. Connection admission |
| 104 | +does not substitute for either layer above it, which is why a browser |
| 105 | +admitted through auth callout would still be unable to make signed |
| 106 | +first-party calls. |
| 107 | + |
| 108 | +### 3. The bridge is mechanical and holds no business logic |
| 109 | + |
| 110 | +ADR 0016 binds protobuf method names to NATS subjects deterministically, so |
| 111 | +the gateway maps traffic without per-method invention: |
| 112 | + |
| 113 | +- A unary Connect RPC becomes one NATS request-reply on the bound subject. |
| 114 | +- A server-streaming Connect RPC bridges a NATS subscription into one Connect |
| 115 | + stream, scoped to the operator's session. |
| 116 | +- Error mapping is canonical: the NATS micro error channel carries the |
| 117 | + gRPC-idiom status semantics ADR 0016 defines, and Connect uses the same |
| 118 | + canonical status codes, so the gateway translates status and message without |
| 119 | + inventing an error vocabulary. |
| 120 | + |
| 121 | +The gateway performs translation, session handling, authorization screening, |
| 122 | +and streaming fan-out. It does not aggregate, decide, or own domain rules. |
| 123 | +When a handler needs business logic, that logic belongs in a platform service |
| 124 | +behind the backbone, and the gateway exposes that service's method instead. |
| 125 | + |
| 126 | +### 4. Exposure is explicit and default-closed |
| 127 | + |
| 128 | +Being an ADR 0016 service does not make a method browser-reachable. The |
| 129 | +gateway exposes an explicit allowlist of services and methods; everything else |
| 130 | +on the backbone is unreachable from the browser surface. Adding a method to a |
| 131 | +product surface is a reviewed gateway change, not a side effect of deploying a |
| 132 | +backbone service. |
| 133 | + |
| 134 | +### 5. One gateway workload per product surface family |
| 135 | + |
| 136 | +A gateway is one operated workload in the ADR 0003 combined-binary sense: one |
| 137 | +deployment unit, one telemetry identity, one security boundary. It may also |
| 138 | +serve the static assets of its product surface when the assets share |
| 139 | +ownership and release cadence, keeping a product surface deployable as one |
| 140 | +service. Distinct product surfaces with different audiences, trust levels, or |
| 141 | +release cadences get their own gateway workload rather than sharing one |
| 142 | +allowlist and session model. |
| 143 | + |
| 144 | +## Consequences |
| 145 | + |
| 146 | +- The repository gains a new workload class, the product surface gateway. The |
| 147 | + console gateway is its first instance. |
| 148 | +- Browser code consumes generated Connect clients, so the `.proto` sources |
| 149 | + remain the single wire contract from browser to backbone service. |
| 150 | +- Trace context propagates end to end under |
| 151 | + [ADR 0008](./0008-opentelemetry-observability.md): the browser sends |
| 152 | + `traceparent` on every Connect call and the gateway continues that trace |
| 153 | + onto its NATS micro requests. |
| 154 | +- Live updates in a browser surface are Connect server streams fed by NATS |
| 155 | + subscriptions in the gateway; product surfaces do not open their own NATS |
| 156 | + connections. |
| 157 | +- The gateway's session store and PoP signing make it stateful in the same |
| 158 | + ways the A2A gateway already is; replay-store and multi-node caveats from |
| 159 | + ADR 0017 apply to it equally. |
| 160 | +- A future non-browser consumer that needs an explicit API surface (partner |
| 161 | + integration, external tooling) can reuse the same ConnectRPC surface |
| 162 | + without a new decision, because ADR 0003 already prefers ConnectRPC there. |
| 163 | + |
| 164 | +## References |
| 165 | + |
| 166 | +- [ADR 0003: AI Protocol Transport Taxonomy](./0003-ai-protocol-transport-taxonomy.md) |
| 167 | +- [ADR 0009: Protocol Buffers Wire Contracts](./0009-protocol-buffers-wire-contracts.md) |
| 168 | +- [ADR 0011: JSON-RPC over NATS Binding](./0011-jsonrpc-over-nats-binding.md) |
| 169 | +- [ADR 0016: Protocol Buffers RPC over NATS micro Binding](./0016-protobuf-rpc-over-nats-micro-binding.md) |
| 170 | +- [ADR 0017: AAuth Agent Authentication over a Trogon NATS PoP Binding](./0017-aauth-agent-authentication.md) |
| 171 | +- [ConnectRPC protocol reference](https://connectrpc.com/docs/protocol/) |
| 172 | +- [Protobuf-ES](https://github.com/bufbuild/protobuf-es) |
0 commit comments