Skip to content

Commit 27e2607

Browse files
authored
feat(console): bootstrap console webapp workspace and stack decisions (#476)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 5ee00cd commit 27e2607

33 files changed

Lines changed: 6711 additions & 3 deletions

.github/workflows/ci-web.yml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
name: CI Web
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
types: [opened, synchronize, reopened]
8+
branches: [main]
9+
paths:
10+
- 'tsworkspace/**'
11+
- '.mise.toml'
12+
- '.github/workflows/ci-web.yml'
13+
14+
concurrency:
15+
group: ci-web-${{ github.ref }}
16+
cancel-in-progress: true
17+
18+
permissions:
19+
contents: read
20+
21+
jobs:
22+
verify:
23+
runs-on: ubuntu-latest
24+
steps:
25+
- name: Checkout
26+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
27+
with:
28+
persist-credentials: false
29+
30+
- name: Setup pnpm
31+
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
32+
with:
33+
package_json_file: tsworkspace/package.json
34+
35+
- name: Setup Node
36+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
37+
with:
38+
node-version: 26
39+
cache: pnpm
40+
cache-dependency-path: tsworkspace/pnpm-lock.yaml
41+
42+
- name: Install dependencies
43+
run: pnpm install --frozen-lockfile
44+
working-directory: tsworkspace
45+
46+
- name: Format check
47+
run: pnpm fmt:check
48+
working-directory: tsworkspace
49+
50+
- name: Lint
51+
run: pnpm lint
52+
working-directory: tsworkspace
53+
54+
- name: Typecheck
55+
run: pnpm typecheck
56+
working-directory: tsworkspace
57+
58+
- name: Build
59+
run: pnpm build
60+
working-directory: tsworkspace
61+
62+
- name: Test
63+
run: pnpm test
64+
working-directory: tsworkspace
65+
66+
- name: Primitive layer policy
67+
run: pnpm check:primitives
68+
working-directory: tsworkspace

.mise.toml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,27 @@ run = "docker compose -f devops/docker/compose/compose.yml up -d"
2020

2121
[tasks.build]
2222
description = "Build all"
23-
depends = ["build:rust", "build:docs"]
23+
depends = ["build:rust", "build:docs", "build:web"]
2424

2525
[tasks."build:docs"]
2626
dir = "docs"
2727
run = "pnpm docs:build"
2828

29+
[tasks."build:web"]
30+
dir = "tsworkspace"
31+
run = "pnpm build"
32+
33+
[tasks."check:web"]
34+
dir = "tsworkspace"
35+
run = "pnpm fmt:check && pnpm lint && pnpm typecheck"
36+
2937
[tasks."build:rust"]
3038
dir = "rsworkspace"
3139
run = "cargo build"
3240

3341
[tasks.check]
34-
description = "Check Rust"
35-
depends = ["check:rust"]
42+
description = "Check workspaces"
43+
depends = ["check:rust", "check:web"]
3644

3745
[tasks."check:rust"]
3846
dir = "rsworkspace"

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@
2222

2323
- [`docs/`](docs/) - Documentation website and contribution workflow.
2424
- [`rsworkspace/`](rsworkspace/) - Rust components and crate documentation.
25+
- [`tsworkspace/`](tsworkspace/) - TypeScript workspace, including the console webapp.
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
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

Comments
 (0)