carlos / platform Public

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>
Paul Campbell 0d4b2ef9aa342bb8ed2c3cdeb2ce37d7baeba879 parent ee0012b
4 files changed, +52 −0
  • internal/console/ladder.go +6 −0
  • internal/console/ladder_test.go +26 −0
  • internal/console/templates/ladder.html +1 −0
  • internal/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))
}