Skip to content

Commit 5cb63cb

Browse files
authored
feat: support browser UI base paths (#1071)
Adds generic support for serving the Roborev browser application below a configured URL path prefix. The prefix is applied consistently across HTTP routing, redirects, runtime metadata, CLI deep links, embedded assets, API and event-stream requests, and client-side history. Internal application routes remain distinct from already-prefixed external API base URLs, including when a route overlaps the configured prefix. Root-mounted behavior remains the default. Remote browser access requires a dedicated origin. A base path provides routing, and cookie path scoping reduces incidental transmission, but neither isolates Roborev from other applications on the same origin. Also adds a host-local browser token-file option, with fail-closed validation and mutual exclusion from the inline token. Base paths reject values that URL parsers could reinterpret, including percent escapes, backslashes, control characters, and surrounding whitespace. Reverse proxies can preserve the prefix while keeping the browser listener loopback-bound and streaming responses unbuffered. Production template injection rejects missing or duplicate markers instead of silently serving root-relative URLs. The release check exercises the generated Vite output with a non-root prefix. The full Go suite, browser suite, repository hooks, and production web build pass. Co-authored-by: Wes McKinney <wesm@users.noreply.github.com>
1 parent 850a076 commit 5cb63cb

41 files changed

Lines changed: 1033 additions & 85 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ web-restore:
5353
web-release-check: web-embed
5454
@set -e; trap '$(MAKE) web-restore' EXIT; \
5555
ROBOREV_RUN_WEB_RELEASE_CHECK=1 CGO_ENABLED=0 \
56-
go test ./internal/web -run '^TestEmbeddedReleaseDistribution$$' -count=1
56+
go test ./internal/web -run '^TestEmbeddedRelease' -count=1
5757

5858
release-snapshot-check:
5959
@set -e; trap 'cd web && bun run assets:restore' EXIT; \

cmd/roborev/daemon_cmd.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,11 @@ func discoverWebUIURL(discover func() (*daemon.RuntimeInfo, error)) string {
9797
if err != nil || runtimeInfo == nil || runtimeInfo.WebOrigin == "" {
9898
return ""
9999
}
100-
return runtimeInfo.WebOrigin
100+
webURL, err := browserRootURL(runtimeInfo.WebOrigin, runtimeInfo.WebBasePath)
101+
if err != nil {
102+
return ""
103+
}
104+
return webURL
101105
}
102106

103107
func displayWebUIURL(webURL string) string {

cmd/roborev/daemon_cmd_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,27 @@ func TestDaemonRestartShowsPublicWebUIURL(t *testing.T) {
9898
assert.Equal(t, "Daemon restarted\nWeb UI: https://reviews.example.com\n", output)
9999
}
100100

101+
func TestDaemonRestartShowsPrefixedWebUIURL(t *testing.T) {
102+
withDaemonCommandDependencies(t,
103+
func() error { return nil },
104+
func() error { return nil },
105+
func() (*daemon.RuntimeInfo, error) {
106+
return &daemon.RuntimeInfo{
107+
WebOrigin: "https://reviews.example.com",
108+
WebBasePath: "/roborev-ci",
109+
}, nil
110+
},
111+
)
112+
113+
output := captureStdout(t, func() {
114+
cmd := daemonCmd()
115+
cmd.SetArgs([]string{"restart"})
116+
require.NoError(t, cmd.Execute())
117+
})
118+
119+
assert.Equal(t, "Daemon restarted\nWeb UI: https://reviews.example.com/roborev-ci/\n", output)
120+
}
121+
101122
func TestDaemonRestartShowsUnavailableWhenBrowserIsDisabled(t *testing.T) {
102123
withDaemonCommandDependencies(t,
103124
func() error { return nil },

cmd/roborev/status_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,28 @@ func TestStatusCmdJSONIncludesWebUIURL(t *testing.T) {
9191
assert.Equal(t, "https://reviews.example.com", parsed.WebURL)
9292
}
9393

94+
func TestStatusCmdJSONIncludesPrefixedWebUIURL(t *testing.T) {
95+
md := NewMockDaemon(t, MockRefineHooks{})
96+
defer md.Close()
97+
98+
withStatusWebRuntime(t, func() (*daemon.RuntimeInfo, error) {
99+
return &daemon.RuntimeInfo{
100+
WebOrigin: "https://reviews.example.com",
101+
WebBasePath: "/roborev-ci",
102+
}, nil
103+
})
104+
105+
output := captureStdout(t, func() {
106+
cmd := statusCmd()
107+
cmd.SetArgs([]string{"--json"})
108+
require.NoError(t, cmd.Execute())
109+
})
110+
111+
var parsed statusJSONOutput
112+
require.NoError(t, json.Unmarshal([]byte(output), &parsed))
113+
assert.Equal(t, "https://reviews.example.com/roborev-ci/", parsed.WebURL)
114+
}
115+
94116
func TestDaemonStatusUsesSharedStatusOutput(t *testing.T) {
95117
md := NewMockDaemon(t, MockRefineHooks{})
96118
defer md.Close()

cmd/roborev/ui_cmd.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/spf13/cobra"
1212

13+
"go.kenn.io/roborev/internal/config"
1314
"go.kenn.io/roborev/internal/daemon"
1415
)
1516

@@ -36,7 +37,7 @@ func uiCmd() *cobra.Command {
3637
if runtimeInfo.WebOrigin == "" {
3738
return fmt.Errorf("the daemon browser listener is disabled")
3839
}
39-
target, err := uiURL(runtimeInfo.WebOrigin, args)
40+
target, err := uiURL(runtimeInfo.WebOrigin, runtimeInfo.WebBasePath, args)
4041
if err != nil {
4142
return err
4243
}
@@ -101,7 +102,15 @@ func validateUIArgs(_ *cobra.Command, args []string) error {
101102
return nil
102103
}
103104

104-
func uiURL(origin string, args []string) (string, error) {
105+
func uiURL(origin, basePath string, args []string) (string, error) {
106+
path := "/reviews"
107+
if len(args) == 1 {
108+
path += "/" + args[0]
109+
}
110+
return browserURL(origin, basePath, path)
111+
}
112+
113+
func browserURL(origin, basePath, internalPath string) (string, error) {
105114
parsed, err := url.Parse(origin)
106115
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
107116
return "", fmt.Errorf("daemon published an invalid browser origin")
@@ -111,9 +120,27 @@ func uiURL(origin string, args []string) (string, error) {
111120
default:
112121
return "", fmt.Errorf("daemon published an invalid browser origin")
113122
}
114-
parsed.Path = "/reviews"
115-
if len(args) == 1 {
116-
parsed.Path += "/" + args[0]
123+
normalizedBasePath, err := config.NormalizeWebBasePath(basePath)
124+
if err != nil {
125+
return "", fmt.Errorf("daemon published an invalid browser base path: %w", err)
117126
}
127+
parsed.Path = normalizedBasePath + internalPath
128+
parsed.RawPath = ""
118129
return parsed.String(), nil
119130
}
131+
132+
func browserRootURL(origin, basePath string) (string, error) {
133+
if basePath == "" {
134+
parsed, err := url.Parse(origin)
135+
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
136+
return "", fmt.Errorf("daemon published an invalid browser origin")
137+
}
138+
switch strings.ToLower(parsed.Scheme) {
139+
case "http", "https":
140+
default:
141+
return "", fmt.Errorf("daemon published an invalid browser origin")
142+
}
143+
return parsed.String(), nil
144+
}
145+
return browserURL(origin, basePath, "/")
146+
}

cmd/roborev/ui_cmd_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,25 @@ func TestUICmdOpensReviewRoutes(t *testing.T) {
4444
}
4545
}
4646

47+
func TestUICmdOpensPrefixedReviewRoutes(t *testing.T) {
48+
var opened string
49+
withUICommandDependencies(t,
50+
func() error { return nil },
51+
func() (*daemon.RuntimeInfo, error) {
52+
return &daemon.RuntimeInfo{
53+
WebOrigin: "https://reviews.example.com",
54+
WebBasePath: "/roborev-ci",
55+
}, nil
56+
},
57+
func(target string) error { opened = target; return nil },
58+
)
59+
60+
cmd := uiCmd()
61+
cmd.SetArgs([]string{"42"})
62+
require.NoError(t, cmd.Execute())
63+
assert.Equal(t, "https://reviews.example.com/roborev-ci/reviews/42", opened)
64+
}
65+
4766
func TestUICmdRejectsInvalidJobIDs(t *testing.T) {
4867
for _, args := range [][]string{{"0"}, {"-1"}, {"nope"}, {"1", "2"}} {
4968
cmd := uiCmd()

docs/commands.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,12 @@ completes.
154154
`roborev show` displays review comments after the review output when comments
155155
exist, matching the layout in the TUI review detail view.
156156

157-
`roborev ui` starts the daemon when needed, reads the browser origin from the
158-
live daemon runtime, and opens `/reviews`. An optional positive numeric job ID
159-
opens `/reviews/<job-id>`. Job IDs are local to that daemon's SQLite database,
160-
so a numeric deep link is not portable to another machine even when review data
161-
is synchronized. Authentication tokens are never placed in the launch URL. The
157+
`roborev ui` starts the daemon when needed, reads the browser origin and path
158+
prefix from the live daemon runtime, and opens `/reviews` below that prefix when
159+
one is configured. An optional positive numeric job ID opens `/reviews/<job-id>`
160+
below the same prefix. Job IDs are local to that daemon's SQLite database, so a
161+
numeric deep link is not portable to another machine even when review data is
162+
synchronized. Authentication tokens are never placed in the launch URL. The
162163
browser listener is enabled on loopback by default, so an installed release
163164
needs no additional configuration for local use: run `roborev ui` and the
164165
application displays the reviews from the same SQLite database used by the CLI

docs/configuration.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -764,8 +764,10 @@ column_borders = true # Show separators between TUI columns
764764
| `server_addr` | string | 127.0.0.1:7373 | Daemon listen address. Use `unix://` for Unix domain socket (see [Unix Domain Socket](#unix-domain-socket)) | No |
765765
| `web.enabled` | bool | true | Serve the embedded browser application on a separate listener | No |
766766
| `web.listen` | string | 127.0.0.1:0 | Loopback browser listener address. Port 0 selects an available ephemeral port | No |
767-
| `web.public_origin` | string | - | Exact HTTPS origin exposed by a reverse proxy | No |
767+
| `web.public_origin` | string | - | Exact dedicated HTTPS origin exposed by a reverse proxy | No |
768+
| `web.base_path` | string | - | Optional canonical routing prefix, without a trailing slash; not a same-origin security boundary | No |
768769
| `web.auth_token` | string | - | Base64url-encoded 32-byte random token exchanged for a process-local browser session | No |
770+
| `web.auth_token_file` | string | - | Host-local file containing the browser token; mutually exclusive with `web.auth_token` | No |
769771
| `max_workers` | int | 4 | Number of parallel review workers | No |
770772
| `job_timeout_minutes` | int | 30 | Per-job timeout in minutes | Yes |
771773
| `hook_timeout_seconds` | int | `3` (`30` on Windows) | Post-commit hook request timeout, in seconds. Raise it on Windows or large repos where the daemon's enqueue git calls are slow. Zero or negative values are ignored and fall back to the platform default | Yes |
@@ -838,16 +840,34 @@ Paste that command's output as `auth_token`:
838840
enabled = true
839841
listen = "127.0.0.1:7374"
840842
public_origin = "https://reviews.example.com"
841-
auth_token = "paste-the-generated-token-here"
843+
base_path = "/reviews"
844+
auth_token_file = "/etc/roborev/web-auth-token"
842845
```
843846

847+
`public_origin` must be an exact scheme-and-authority origin with no path and
848+
must be dedicated to Roborev-controlled content. Serve sibling applications from
849+
separate origins. Set `base_path` separately when the proxy mounts the browser
850+
application below a URL prefix; it must start with `/`, have no trailing slash,
851+
query, fragment, percent escape, backslash, control character, surrounding
852+
whitespace, or path traversal. The token file must contain exactly one
853+
base64url-encoded 32-byte token, optionally followed by one terminal newline. It
854+
is mutually exclusive with `auth_token` and is read when the daemon starts, so
855+
the token bytes do not need to be stored in the configuration file.
856+
844857
The proxy must preserve the public `Host`, set conventional forwarding headers,
845858
and avoid buffering `/api/stream/events` and streamed `/api/job/output`
846859
responses. The public origin must match the browser origin exactly and must use
847860
HTTPS for remote access. Roborev rejects non-loopback browser listener addresses
848861
so credentials are never sent over a plaintext network hop. The CLI API remains
849862
private on its original listener.
850863

864+
The browser session cookie uses `/` when `base_path` is empty and
865+
`base_path + "/"` when a prefix is configured. That path scope reduces
866+
incidental cookie transmission, but it is not an authorization boundary: scripts
867+
on the same origin can still make requests below the prefix. `base_path`
868+
provides routing only, so do not host sibling applications on the Roborev
869+
origin.
870+
851871
The browser exchanges the daemon token for an HTTP-only cookie and tab-scoped
852872
credentials. The token is entered after the public shell opens and is never
853873
retained by the application. Sessions are process-local, so every daemon restart

docs/web-ui.md

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ Open a particular review with its local job ID:
2929
roborev ui 42
3030
```
3131

32-
This opens `/reviews/42`. Job IDs belong to one daemon's SQLite database, so a
32+
This opens `/reviews/42`, or the same route below `web.base_path` when a path
33+
prefix is configured. Job IDs belong to one daemon's SQLite database, so a
3334
numeric review URL is not portable between machines.
3435

3536
The application has two workspaces:
@@ -87,9 +88,9 @@ a different daemon.
8788
## Analytics
8889

8990
Open **Analytics** in the application shell, or navigate directly to
90-
`/analytics`. Filters are encoded in the URL, so a time range and project,
91-
source, agent, model, or bucket selection can be bookmarked and shared with
92-
another user of the same daemon.
91+
`/analytics` (below `web.base_path` when configured). Filters are encoded in the
92+
URL, so a time range and project, source, agent, model, or bucket selection can
93+
be bookmarked and shared with another user of the same daemon.
9394

9495
Project analytics use the display names shown elsewhere in Roborev. Repositories
9596
with the same display name are intentionally grouped together.
@@ -145,19 +146,30 @@ Choose a fixed loopback port. Generate the required 32-byte base64url token:
145146
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
146147
```
147148

148-
Paste the generated value into `~/.roborev/config.toml`:
149+
Store the generated value in a host-local file readable by the Roborev daemon,
150+
with an optional single trailing newline, and configure that file in
151+
`~/.roborev/config.toml`:
149152

150153
```toml
151154
[web]
152155
enabled = true
153156
listen = "127.0.0.1:7374"
154157
public_origin = "https://reviews.example.com"
155-
auth_token = "paste-the-generated-token-here"
158+
base_path = "/reviews"
159+
auth_token_file = "/etc/roborev/web-auth-token"
156160
```
157161

158162
`public_origin` must be the exact origin users open, without a path or trailing
159-
slash. Protect the config file because it contains the login token. Restart the
160-
daemon after changing any `[web]` setting:
163+
slash, and that origin must serve only Roborev-controlled content. Serve sibling
164+
applications from separate origins. `base_path` is an optional canonical path
165+
prefix: it starts with `/`, has no trailing slash, and must be preserved by the
166+
reverse proxy. It cannot contain a percent escape, backslash, control character,
167+
or surrounding whitespace. The browser session cookie is scoped to that prefix
168+
to reduce incidental transmission, but same-origin scripts can still make
169+
requests below it. The prefix provides routing, not isolation. `auth_token_file`
170+
and `auth_token` are mutually exclusive; the token file must contain exactly one
171+
token. Protect the token file and restart the daemon after changing any `[web]`
172+
setting:
161173

162174
```bash
163175
roborev daemon restart
@@ -177,9 +189,10 @@ then run:
177189
tailscale serve --bg http://127.0.0.1:7374
178190
```
179191

180-
Use the HTTPS origin printed by `tailscale serve` as `web.public_origin`, then
181-
restart Roborev. Open that origin on another device in the tailnet and enter the
182-
configured `web.auth_token` when prompted.
192+
For this root-mounted example, use the HTTPS origin printed by `tailscale serve`
193+
as `web.public_origin`, then restart Roborev. Open that origin on another device
194+
in the tailnet and enter the configured token when prompted. A path-mounted
195+
deployment needs a reverse proxy that preserves the configured `base_path`.
183196

184197
Do not use Tailscale Funnel for this setup: Serve keeps access inside the
185198
tailnet. Tailnet policy remains the network-level access boundary, while the

0 commit comments

Comments
 (0)