console: read-only render of a route's granted capabilities
Plan Task 9's optional step, taken because it stayed genuinely cheap: a route's operator-granted tokens now sit beside its other per-route facts on the ladder — "granted: host-role, port:25", human-legible rather than the registry column's "host-role,port:25" — and a route with nothing granted renders no "granted:" line at all. No new route, no new handler, no gate reordering: RouteView (view.go) and LadderRoute (ladder.go) each gain one plain string field threaded from the existing registry read already behind handleApp's session -> membership -> account gate, and ladder.html gains one conditional line. Read-only by construction — there is no form, no POST, nothing here writes the caps column; that stays carlos route -grant/-revoke on the box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+52
−0
internal/console/ladder.go+6 −0internal/console/ladder_test.go+26 −0internal/console/templates/ladder.html+1 −0internal/console/view.go+19 −0
diff --git a/internal/console/ladder.go b/internal/console/ladder.go| index ceadd0ab7..6fc685c78 100644 |
| --- a/internal/console/ladder.go |
| +++ b/internal/console/ladder.go |
| @@ -87,6 +87,11 @@ type LadderRoute struct { |
| // ExpiredFor is how long past the TTL the channel is, formatted for a |
| // human ("9d", "6h") — valid only when Expired is true. |
| ExpiredFor string |
| + // Caps is the operator-granted capability tokens for this route, human |
| + // legible and already formatted by RouteView (view.go's humanizeCaps); |
| + // "" renders nothing (app-capabilities design §9's optional console |
| + // render — read-only, no new route, no gate touched). |
| + Caps string |
| } |
| // LadderStep is the move from one rung to the one above it. |
| @@ -135,6 +140,7 @@ func assembleLadder(channels []ChannelView, routes []RouteView, now time.Time) [ |
| ProdWired: prod[rt.Host], |
| Expired: age != "", |
| ExpiredFor: age, |
| + Caps: rt.Caps, |
| }) |
| } |
| entries := map[string][]LadderChannel{} |
diff --git a/internal/console/ladder_test.go b/internal/console/ladder_test.go| index 2194c2768..7d7049301 100644 |
| --- a/internal/console/ladder_test.go |
| +++ b/internal/console/ladder_test.go |
| @@ -334,6 +334,32 @@ func TestLadderShowsEachRoutesEnvironment(t *testing.T) { |
| } |
| } |
| +// The optional read-only capabilities render (app-capabilities design, plan |
| +// Task 9 step 3): a route's operator-granted tokens appear beside its other |
| +// per-route facts, human-legible ("host-role, port:25" rather than the |
| +// registry column's "host-role,port:25"), and a route with nothing granted |
| +// shows no "granted:" text at all — the empty case is silence, not an empty |
| +// chip. Read-only: this is a render of what `carlos route -grant` already |
| +// wrote, nothing here writes it. |
| +func TestLadderShowsGrantedCapabilities(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.addRoute(t, registry.Route{Host: "mx.kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| + Version: "v1", App: "kass", Channel: "stable", Caps: "host-role,port:25", Addr: "127.0.0.1:9013"}) |
| + |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.u("/apps/kass")) |
| + block := rungBlock(t, body, "stable") |
| + |
| + granted := routeLine(t, block, "mx.kass.carlosapp.dev") |
| + if !strings.Contains(granted, "granted: host-role, port:25") { |
| + t.Errorf("a route with granted capabilities does not render them:\n%s", granted) |
| + } |
| + ungranted := routeLine(t, block, "kass.carlosapp.dev") |
| + if strings.Contains(ungranted, "granted:") { |
| + t.Errorf("a route with nothing granted rendered a granted: line anyway:\n%s", ungranted) |
| + } |
| +} |
| + |
| // Visibility, not permission (spec §5): a member sees the same environment |
| // and prod-wired chrome an owner does. Only the promote control is |
| // owner-gated — see TestLadderPromoteControlIsOwnerOnly. |
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html| index 72448ae20..7916b6f40 100644 |
| --- a/internal/console/templates/ladder.html |
| +++ b/internal/console/templates/ladder.html |
| @@ -26,6 +26,7 @@ touching what any host is wired to.</p> |
| {{if .ProdWired}}<span class="chip chip-prod">{{icon "triangle-alert"}} canary in front of everybody</span> |
| {{else if .Canary}}<span class="chip chip-canary">{{icon "flask-conical"}} canary</span>{{end}} |
| {{if .Expired}}<span class="chip chip-expired">{{icon "hourglass"}} expired {{.ExpiredFor}}</span>{{end}} |
| + {{if .Caps}}<span class="small">granted: {{.Caps}}</span>{{end}} |
| </li> |
| {{end}} |
| </ul> |
diff --git a/internal/console/view.go b/internal/console/view.go| index 0e9c7054c..5182bc5ca 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -120,6 +120,24 @@ type RouteView struct { |
| // convention; see internal/appconfig.LoadMerged). Rendered as "default" |
| // rather than left blank, in ladder.html. |
| Environment string |
| + // Caps is the operator-granted capability tokens for this route on this |
| + // box (registry.Route.Caps: "" = nothing granted), rendered human-legible |
| + // ("host-role, port:25" rather than the storage column's "host-role,port:25"). |
| + // Read-only: grants are written by `carlos route -grant/-revoke` on the |
| + // box, never from the console (spec §7 "nothing here touches the |
| + // console's gates" — this adds no route, no handler, no write path). |
| + Caps string |
| +} |
| + |
| +// humanizeCaps renders a registry.Route.Caps column for a person: the |
| +// storage shape is comma-separated with no spaces (caps.Grants.String()'s |
| +// canonical form, chosen so a re-grant of the same set writes the same |
| +// bytes); a reader gets "host-role, port:25" instead. |
| +func humanizeCaps(caps string) string { |
| + if caps == "" { |
| + return "" |
| + } |
| + return strings.Join(strings.Split(caps, ","), ", ") |
| } |
| // ChannelView is one row of the channels table. |
| @@ -226,6 +244,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin |
| Version: rt.Version, Channel: ch, Hibernate: rt.Hibernate, |
| Account: release.RouteAccount(rt), |
| Environment: rt.Environment, |
| + Caps: humanizeCaps(rt.Caps), |
| }) |
| domainsByApp[rt.App] = append(domainsByApp[rt.App], domainView(rt, edgeIPs)) |
| } |