Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/ci-web.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: CI Web

on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
paths:
- 'tsworkspace/**'
- '.mise.toml'
- '.github/workflows/ci-web.yml'

concurrency:
group: ci-web-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
package_json_file: tsworkspace/package.json

- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 26
cache: pnpm
cache-dependency-path: tsworkspace/pnpm-lock.yaml
Comment on lines +30 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check that the pinned action versions and Node 26 are valid/available.
# Verify action versions via GitHub API
gh api repos/actions/checkout/git/refs/tags/v7.0.0 --jq '.object.sha' 2>/dev/null || echo "checkout v7.0.0 not found"
gh api repos/actions/setup-node/git/refs/tags/v6.4.0 --jq '.object.sha' 2>/dev/null || echo "setup-node v6.4.0 not found"
gh api repos/pnpm/action-setup/git/refs/tags/v6.0.9 --jq '.object.sha' 2>/dev/null || echo "pnpm/action-setup v6.0.9 not found"

Repository: TrogonStack/trogonai

Length of output: 281


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the workflow context around the referenced lines.
file=".github/workflows/ci-web.yml"
wc -l "$file"
sed -n '1,120p' "$file" | cat -n

# Resolve the three action tags to their underlying commit/type.
for repo_tag in \
  "actions/checkout:v7.0.0" \
  "actions/setup-node:v6.4.0" \
  "pnpm/action-setup:v6.0.9"
do
  repo="${repo_tag%%:*}"
  tag="${repo_tag##*:}"
  echo "== $repo $tag =="
  gh api "repos/$repo/git/refs/tags/$tag" --jq '{type: .object.type, sha: .object.sha}'
done

Repository: TrogonStack/trogonai

Length of output: 2570


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether the pinned SHAs themselves correspond to real commits.
for sha in \
  9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 \
  48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e \
  008330803749db0355799c700092d9a85fd074e9 \
  0ebf47130e4866e96fce0953f49152a61190b271
do
  echo "== $sha =="
  gh api "repos/actions/checkout/commits/$sha" --jq '.sha' 2>/dev/null || \
  gh api "repos/actions/setup-node/commits/$sha" --jq '.sha' 2>/dev/null || \
  gh api "repos/pnpm/action-setup/commits/$sha" --jq '.sha' 2>/dev/null || \
  echo "not found"
done

Repository: TrogonStack/trogonai

Length of output: 1523


🌐 Web query:

GitHub Actions setup-node Node 26 ubuntu-latest availability 2026

💡 Result:

As of July 8, 2026, you can use Node.js 26 in GitHub Actions workflows via the setup-node action [1][2]. The setup-node action does not require a hardcoded list of supported versions to function; it supports any version of Node.js that is available from the official Node.js distribution (nodejs.org/dist) or the actions/node-versions repository [1][2][3]. Because Node.js 26 is a valid, officially released version (with version 26.5.0 being released today) [4][5], you can specify it in your workflow configuration [1][2]. To use it, you can define the version in your workflow YAML file: - uses: actions/setup-node@v6 with: node-version: '26' Alternatively, you can specify a precise version (e.g., '26.5.0') or use semantic versioning ranges (e.g., '26.x') [1][2][3]. If you do not specify a version, the action will use the default Node.js version installed on the runner, which may vary depending on the runner image updates [1][6][3]. For consistent results, it is recommended to explicitly specify '26' or a more specific version string in the node-version input [1][6][3].

Citations:


Pin pnpm/action-setup to the commit behind v6.0.9. 0ebf47130e4866e96fce0953f49152a61190b271 isn’t a commit ref, so this step will fail to load. The actions/checkout, actions/setup-node, and node-version: 26 entries are fine.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci-web.yml around lines 30 - 40, The Setup pnpm step is
pinned to a ref that is not a valid commit, so the action cannot be loaded.
Update the pnpm/action-setup reference to the actual commit for v6.0.9 in the
ci-web workflow, keeping the existing Setup Node, actions/setup-node, and
node-version settings unchanged.


- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: tsworkspace

- name: Format check
run: pnpm fmt:check
working-directory: tsworkspace

- name: Lint
run: pnpm lint
working-directory: tsworkspace

- name: Typecheck
run: pnpm typecheck
working-directory: tsworkspace

- name: Build
run: pnpm build
working-directory: tsworkspace

- name: Test
run: pnpm test
working-directory: tsworkspace

- name: Primitive layer policy
run: pnpm check:primitives
working-directory: tsworkspace
14 changes: 11 additions & 3 deletions .mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,27 @@ run = "docker compose -f devops/docker/compose/compose.yml up -d"

[tasks.build]
description = "Build all"
depends = ["build:rust", "build:docs"]
depends = ["build:rust", "build:docs", "build:web"]

[tasks."build:docs"]
dir = "docs"
run = "pnpm docs:build"

[tasks."build:web"]
dir = "tsworkspace"
run = "pnpm build"

[tasks."check:web"]
dir = "tsworkspace"
run = "pnpm fmt:check && pnpm lint && pnpm typecheck"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mise web check incomplete

Medium Severity

The new check:web task only runs format, lint, and typecheck, while ci-web also runs build, tests, and check:primitives. Because check now depends on check:web, mise check can pass locally while the web workflow would still fail on build, test, or Radix policy violations.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 09235ee. Configure here.


[tasks."build:rust"]
dir = "rsworkspace"
run = "cargo build"

[tasks.check]
description = "Check Rust"
depends = ["check:rust"]
description = "Check workspaces"
depends = ["check:rust", "check:web"]

[tasks."check:rust"]
dir = "rsworkspace"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@

- [`docs/`](docs/) - Documentation website and contribution workflow.
- [`rsworkspace/`](rsworkspace/) - Rust components and crate documentation.
- [`tsworkspace/`](tsworkspace/) - TypeScript workspace, including the console webapp.
172 changes: 172 additions & 0 deletions docs/adr/0018-connectrpc-gateway-for-browser-product-surfaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
---
number: "0018"
slug: connectrpc-gateway-for-browser-product-surfaces
status: accepted
date: 2026-07-08
---

# ADR 0018: ConnectRPC Gateway for Browser Product Surfaces

## Context

The platform is getting its first product-facing web application, the operator
console ([ADR 0019](./0019-console-webapp-stack.md)). A browser has to call
first-party platform services, and every existing first-party RPC path lives on
the NATS backbone: protobuf services bind to NATS micro
([ADR 0016](./0016-protobuf-rpc-over-nats-micro-binding.md)), the JSON-RPC
family binds to NATS subjects
([ADR 0011](./0011-jsonrpc-over-nats-binding.md)), and every signed request on
the mesh carries the AAuth NATS PoP envelope
([ADR 0017](./0017-aauth-agent-authentication.md)).

NATS itself does not keep a browser off the backbone. `nats-server` ships a
WebSocket listener, `nats.ws` runs in browsers, and NATS has real
connection-level authentication: passwords, tokens, NKey challenge-response,
decentralized user JWTs, and auth callout, which can mint an ephemeral,
subject-scoped NATS user from an external credential such as a web session. A
browser connection to the backbone is technically achievable.

Connection authentication is not the boundary that matters here. First-party
requests on the mesh carry the AAuth PoP envelope: each request is signed with
a `cnf.jwk`-bound private key, with nonce and content-digest bookkeeping. A
browser tab cannot provide that custody, because any key the page's scripts
can use, an injected script can use and exfiltrate. Putting browsers on the
bus would also make the internal subject namespace an internet-facing surface
whose authorization story is NATS subject permissions rather than a reviewed
method allowlist, and it would require a JavaScript reimplementation of the
ADR 0011/0016/0017 bindings that must track the Rust implementations forever.

[ADR 0003](./0003-ai-protocol-transport-taxonomy.md) already selects the API
style for this situation. Its boundary selection order names
"browser-compatible HTTP access" as a trigger for a first-party service API and
prefers ConnectRPC for that surface. What ADR 0003 does not decide is where the
boundary lives, who holds which credentials, and how a ConnectRPC method
reaches a NATS micro endpoint. Without one rule, each product surface would
invent its own bridge, its own token custody, and its own error mapping, the
same per-call-site drift ADR 0011 and ADR 0016 exist to prevent.

## Decision

### 1. Product web surfaces reach the mesh only through a gateway

A browser product surface talks to a first-party gateway service exposing
ConnectRPC over HTTPS. The gateway is a gateway in the ADR 0003 sense: a
production edge component that accepts external traffic and routes it inward,
containing a bridge onto the backbone.

The ConnectRPC surface is generated from the same `.proto` sources that define
the backbone services ([ADR 0009](./0009-protocol-buffers-wire-contracts.md)).
Browser clients are generated with `protobuf-es` and `connect-es` from the
same Buf pipeline that generates the Rust code. There is no hand-written HTTP
client, no parallel OpenAPI document, and no GraphQL layer; the exceptions ADR
0003 allows for OpenAPI remain exceptions and are not triggered by a
first-party browser surface.

Browsers do not connect to NATS directly, even though the WebSocket listener
and auth callout would make a scoped connection possible. Connection-level
NATS auth cannot substitute for the per-request AAuth PoP envelope, and the
envelope is exactly what a page cannot sign safely. Browser NATS access, if a
narrow read-only case ever justifies it, requires its own decision and does
not weaken this default.

### 2. The gateway is the credential boundary

The human operator authenticates to the gateway with an OAuth 2.0
Authorization Code + PKCE flow. The gateway completes the code exchange, holds
the resulting tokens server-side, and issues an HttpOnly, SameSite session
cookie to the browser. The browser never receives an AAuth token, a signing
key, or a NATS credential of any kind.

Browser auth is cookie-based, and token-in-page patterns are prohibited: no
access, refresh, ID, or session token in `localStorage`, `sessionStorage`,
IndexedDB, JavaScript-readable cookies, or long-lived JavaScript memory, and
no `Authorization: Bearer` header minted by page code. The session cookie is
an opaque identifier, not a JWT; session state lives in the gateway. Anything
JavaScript can read, an injected script can exfiltrate; an HttpOnly cookie
limits an XSS to riding the live session, which is the strictly smaller
failure. Cookie-based auth carries CSRF obligations, which the gateway owns:
`SameSite` on the cookie, strict `Origin` checking on every state-changing
request, and CORS locked to the product surface's origin.

On the mesh, the gateway is an agent under ADR 0017: it holds its own
`aa-agent+jwt` and signing key, signs every backbone request with the Trogon
NATS PoP binding, and, when acting on behalf of an authenticated operator,
presents the operator-linked `aa-auth+jwt` in `AAuth-Auth-Token` alongside its
own agent token. Person-linked authorization therefore rides the same
mechanism every other agent on the mesh uses; the gateway adds no parallel
identity scheme.

Auth layering is explicit. NATS auth callout (`a2a-auth-callout`) is
connection admission: it decides which clients may attach to the backbone and
scopes their subject permissions, and the gateway attaches under that
admission like any other mesh client. The AAuth PoP envelope is request
authentication, and the policy tiers are authorization. Connection admission
does not substitute for either layer above it, which is why a browser
admitted through auth callout would still be unable to make signed
first-party calls.

### 3. The bridge is mechanical and holds no business logic

ADR 0016 binds protobuf method names to NATS subjects deterministically, so
the gateway maps traffic without per-method invention:

- A unary Connect RPC becomes one NATS request-reply on the bound subject.
- A server-streaming Connect RPC bridges a NATS subscription into one Connect
stream, scoped to the operator's session.
- Error mapping is canonical: the NATS micro error channel carries the
gRPC-idiom status semantics ADR 0016 defines, and Connect uses the same
canonical status codes, so the gateway translates status and message without
inventing an error vocabulary.

The gateway performs translation, session handling, authorization screening,
and streaming fan-out. It does not aggregate, decide, or own domain rules.
When a handler needs business logic, that logic belongs in a platform service
behind the backbone, and the gateway exposes that service's method instead.

### 4. Exposure is explicit and default-closed

Being an ADR 0016 service does not make a method browser-reachable. The
gateway exposes an explicit allowlist of services and methods; everything else
on the backbone is unreachable from the browser surface. Adding a method to a
product surface is a reviewed gateway change, not a side effect of deploying a
backbone service.

### 5. One gateway workload per product surface family

A gateway is one operated workload in the ADR 0003 combined-binary sense: one
deployment unit, one telemetry identity, one security boundary. It may also
serve the static assets of its product surface when the assets share
ownership and release cadence, keeping a product surface deployable as one
service. Distinct product surfaces with different audiences, trust levels, or
release cadences get their own gateway workload rather than sharing one
allowlist and session model.

## Consequences

- The repository gains a new workload class, the product surface gateway. The
console gateway is its first instance.
- Browser code consumes generated Connect clients, so the `.proto` sources
remain the single wire contract from browser to backbone service.
- Trace context propagates end to end under
[ADR 0008](./0008-opentelemetry-observability.md): the browser sends
`traceparent` on every Connect call and the gateway continues that trace
onto its NATS micro requests.
- Live updates in a browser surface are Connect server streams fed by NATS
subscriptions in the gateway; product surfaces do not open their own NATS
connections.
- The gateway's session store and PoP signing make it stateful in the same
ways the A2A gateway already is; replay-store and multi-node caveats from
ADR 0017 apply to it equally.
- A future non-browser consumer that needs an explicit API surface (partner
integration, external tooling) can reuse the same ConnectRPC surface
without a new decision, because ADR 0003 already prefers ConnectRPC there.

## References

- [ADR 0003: AI Protocol Transport Taxonomy](./0003-ai-protocol-transport-taxonomy.md)
- [ADR 0009: Protocol Buffers Wire Contracts](./0009-protocol-buffers-wire-contracts.md)
- [ADR 0011: JSON-RPC over NATS Binding](./0011-jsonrpc-over-nats-binding.md)
- [ADR 0016: Protocol Buffers RPC over NATS micro Binding](./0016-protobuf-rpc-over-nats-micro-binding.md)
- [ADR 0017: AAuth Agent Authentication over a Trogon NATS PoP Binding](./0017-aauth-agent-authentication.md)
- [ConnectRPC protocol reference](https://connectrpc.com/docs/protocol/)
- [Protobuf-ES](https://github.com/bufbuild/protobuf-es)
Loading
Loading