carlos / platform Public

console: fleet page tags — chips + owner edit form

fleetPageBox gains a Tags []string field (loadFleetPage splits
registry.FleetBox.Tags on ","); the Boxes table renders each tag as a
.chip span, em-dash for an untagged box. A new owner-only inline form
posts to POST /a/{sqid}/fleets/{fleet}/tags (label, tags, csrf),
handleFleetTagBoxPage mirroring handleFleetRotateBoxPage's
gate/flash/redirect skeleton (ownerFleet + CSRF, byte-identical
refusal to rotate's for a non-owner or bad CSRF). Grammar
(registry.ValidBoxTags) is checked before the label lookup and before
any write — reject, don't sanitize — with the same user-facing copy
Task 2's API door uses. Audit entry carries the fleet as scope (unlike
the CLI door's empty scope): fleet-tag-box, "tagged <fleet>/<label>
[<tags>] (console)".

Added "join": strings.Join to the template FuncMap so the form's
text input pre-fills with the box's current tags.

Tests: chip rendering (tagged + untagged box, owner and member views),
owner update + redirect + flash, member gets the same 404 rotate
gives a non-owner, CSRF refusal, and bad grammar flashing the error
with nothing written.

go build ./..., go vet ./..., go test ./... -count=1 all clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 7097a6bd6d0419985373d8c982f88d1183e3134f parent 214ab0c
4 files changed, +284 −1
  • internal/console/fleets.go +57 −0
  • internal/console/fleets_test.go +213 −0
  • internal/console/server.go +6 −0
  • internal/console/templates/fleetdetail.html +8 −1
diff --git a/internal/console/fleets.go b/internal/console/fleets.go
index b15dc9df9..fb10e8dd9 100644
--- a/internal/console/fleets.go
+++ b/internal/console/fleets.go
@@ -697,6 +697,10 @@ type fleetPageBox struct {
// never-joined box shows no numbers at all, ever (mirrors HostRows).
Tunnel string
Metrics *status.BoxMetrics
+ // Tags is registry.FleetBox.Tags (2026-08-10, Task 3) split on "," — nil
+ // for an untagged box, never a slice holding one empty string, so the
+ // template's {{if not .Tags}} placeholder check is a plain nil test.
+ Tags []string
}
// fleetPagePlacedApp is one app placed on this fleet: its name and the
@@ -813,6 +817,9 @@ func (s *Server) loadFleetPage(ctx context.Context, reg *registry.Registry, acct
boxes = make([]fleetPageBox, 0, len(regBoxes))
for _, b := range regBoxes {
row := fleetPageBox{Label: b.Label, Status: b.Status}
+ if b.Tags != "" {
+ row.Tags = strings.Split(b.Tags, ",")
+ }
// Join key is the label, per the Task 6 identity ruling in this
// function's doc comment — an orphan heartbeat under some OTHER
// label (a box heartbeating under a hashed pseudonym) simply never
@@ -985,3 +992,53 @@ func (s *Server) handleFleetDetachBoxPage(w http.ResponseWriter, r *http.Request
s.audit(acct, fleet.Name, addr, "fleet-detach-box", "detached "+fleet.Name+"/"+label+" (console)")
back("ok", "Detached "+fleet.Name+"/"+label+".")
}
+
+// handleFleetTagBoxPage answers the owner's "Tag" form: the same full-replace
+// SetFleetBoxTags write handleAPIFleetTagBox makes, gate/flash/redirect
+// skeleton copied from handleFleetRotateBoxPage/handleFleetDetachBoxPage —
+// ownerFleet then CSRF, then an ordinary redirect-with-flash outcome (no
+// secret in the body, so there is no reason for this to render directly the
+// way rotate's response does).
+//
+// Grammar (registry.ValidBoxTags) is checked here, before the registry write
+// ever runs and before the label is even looked up — reject, don't sanitize,
+// the same order and the same user-facing copy handleAPIFleetTagBox already
+// uses, so a bad-grammar submission never touches the row regardless of
+// whether the named box exists.
+func (s *Server) handleFleetTagBoxPage(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, fleet, reg, ok := s.ownerFleet(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, fleetPageURL(acct.Sqid, fleet.Name), http.StatusSeeOther)
+ }
+ tags := strings.TrimSpace(r.FormValue("tags"))
+ if !registry.ValidBoxTags(tags) {
+ back("err", "invalid tags — comma-separated [a-z0-9-] (hyphens interior), max 8")
+ return
+ }
+ label := strings.TrimSpace(r.FormValue("label"))
+ box, err := findFleetBox(reg, fleet.Name, label)
+ if err != nil {
+ log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
+ back("err", "Could not read the box.")
+ return
+ }
+ if box == nil {
+ back("err", "No such box.")
+ return
+ }
+ if err := reg.SetFleetBoxTags(fleet.Name, label, tags); err != nil {
+ log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
+ back("err", "Could not tag the box — try again.")
+ return
+ }
+ s.audit(acct, fleet.Name, addr, "fleet-tag-box", "tagged "+fleet.Name+"/"+label+" ["+tags+"] (console)")
+ back("ok", "Tagged "+fleet.Name+"/"+label+".")
+}
diff --git a/internal/console/fleets_test.go b/internal/console/fleets_test.go
index 28bda9805..93b4bf4a2 100644
--- a/internal/console/fleets_test.go
+++ b/internal/console/fleets_test.go
@@ -953,3 +953,216 @@ func TestFleetPageRotateShowsTokenOnceWithWarning(t *testing.T) {
t.Fatalf("no 64-hex token in the rotate response hashes to the stored TokenHash %s:\n%s", boxes[0].TokenHash, body)
}
}
+
+// TestFleetPageTagsChipsRender: a box carrying tags renders each one as its
+// own chip, both on the owner's page and a member's — chips are read-only
+// display, not a mutation surface, so they belong to the same body a member
+// already sees the rest of the row in (TestFleetPageOwnerControlsMemberReadOnly).
+// An untagged box renders the em-dash placeholder instead of an empty cell.
+func TestFleetPageTagsChipsRender(t *testing.T) {
+ fx, token := fleetFixture(t, "code-fleetpage-tags-chips")
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", st, body)
+ }
+ for _, label := range []string{"pi-1", "pi-2"} {
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
+ map[string]string{"label": label}, token); st != http.StatusOK {
+ t.Fatalf("add-box %s = %d %v", label, st, body)
+ }
+ }
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes/pi-1/tags",
+ map[string]string{"tags": "office,free"}, token); st != http.StatusOK {
+ t.Fatalf("tag-box = %d %v", st, body)
+ }
+
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-fleetpage-tags-chips-browser")
+ st, _, body := get(t, c, fx.u("/fleets/acme"))
+ if st != http.StatusOK {
+ t.Fatalf("GET /fleets/acme = %d, want 200:\n%s", st, body)
+ }
+ for _, want := range []string{`<span class="chip">office</span>`, `<span class="chip">free</span>`} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("tagged box missing chip %q in:\n%s", want, body)
+ }
+ }
+ // pi-2 carries no tags: an em-dash, never an empty cell nor pi-1's chips
+ // leaking onto its row.
+ if !strings.Contains(body, "pi-2") {
+ t.Fatalf("untagged box pi-2 missing from the page:\n%s", body)
+ }
+
+ // A member sees the same chips (read-only display, no gate on it) but
+ // none of the tag form's own markup.
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ member := newBrowser(t)
+ signUp(t, fx, member, "code-fleetpage-tags-chips-member")
+ memberStatus, _, memberBody := get(t, member, fx.u("/fleets/acme"))
+ if memberStatus != http.StatusOK {
+ t.Fatalf("member GET /fleets/acme = %d, want 200:\n%s", memberStatus, memberBody)
+ }
+ for _, want := range []string{`<span class="chip">office</span>`, `<span class="chip">free</span>`} {
+ if !strings.Contains(memberBody, want) {
+ t.Fatalf("member view missing chip %q in:\n%s", want, memberBody)
+ }
+ }
+ if strings.Contains(memberBody, "/fleets/acme/tags") {
+ t.Fatalf("member fleet page renders the tag mutation form it must not have:\n%s", memberBody)
+ }
+}
+
+// TestFleetPageTagBoxOwnerUpdatesAndRedirects: the owner's Tag form performs
+// a full-replace write (same as handleAPIFleetTagBox) and redirects back to
+// the fleet page with an ordinary ok flash — no secret involved, unlike
+// rotate, so there is no reason for this to render directly the way
+// handleFleetRotateBoxPage's response does.
+func TestFleetPageTagBoxOwnerUpdatesAndRedirects(t *testing.T) {
+ fx, token := fleetFixture(t, "code-fleetpage-tags-owner")
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", st, body)
+ }
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
+ map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
+ t.Fatalf("add-box = %d %v", st, body)
+ }
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-fleetpage-tags-owner-browser")
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, _ := postForm(t, c, fx.u("/fleets/acme/tags"),
+ url.Values{"csrf": {csrf}, "label": {"pi-1"}, "tags": {"office,free"}})
+ if status != http.StatusSeeOther || loc != fx.path("/fleets/acme") {
+ t.Fatalf("tag-box = %d -> %q, want 303 -> %q", status, loc, fx.path("/fleets/acme"))
+ }
+
+ reg := openTestReg(t, fx)
+ boxes, err := reg.ListFleetBoxes("acme")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(boxes) != 1 || boxes[0].Tags != "office,free" {
+ t.Fatalf("registry boxes = %+v, want tags office,free", boxes)
+ }
+
+ _, _, body := get(t, c, fx.u("/fleets/acme"))
+ if !strings.Contains(body, "Tagged acme/pi-1.") {
+ t.Fatalf("no tag flash after redirect:\n%s", body)
+ }
+ if !strings.Contains(body, `<span class="chip">office</span>`) {
+ t.Fatalf("new tags did not render as chips after the redirect:\n%s", body)
+ }
+}
+
+// TestFleetPageTagBoxMemberGetsSameRefusalAsRotate: a plain member posting
+// the tag form gets the exact same refusal ownerFleet already gives rotate
+// and detach — the gate is s.owner (role != RoleOwner -> 404, matching a
+// non-member's genuinely-unrouted 404), never a distinguishable 403.
+func TestFleetPageTagBoxMemberGetsSameRefusalAsRotate(t *testing.T) {
+ fx, token := fleetFixture(t, "code-fleetpage-tags-member")
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", st, body)
+ }
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
+ map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
+ t.Fatalf("add-box = %d %v", st, body)
+ }
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-fleetpage-tags-member-browser")
+ csrf := sessionToken(t, fx, c)
+
+ tagStatus, _, tagBody := postForm(t, c, fx.u("/fleets/acme/tags"),
+ url.Values{"csrf": {csrf}, "label": {"pi-1"}, "tags": {"office"}})
+ rotStatus, _, rotBody := postForm(t, c, fx.u("/fleets/acme/rotate"),
+ url.Values{"csrf": {csrf}, "label": {"pi-1"}})
+ if tagStatus != http.StatusNotFound {
+ t.Fatalf("member tag-box = %d, want 404", tagStatus)
+ }
+ if tagStatus != rotStatus || tagBody != rotBody {
+ t.Fatalf("member tag-box refusal (%d %q) not byte-identical to rotate's (%d %q)",
+ tagStatus, tagBody, rotStatus, rotBody)
+ }
+
+ reg := openTestReg(t, fx)
+ boxes, err := reg.ListFleetBoxes("acme")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if boxes[0].Tags != "" {
+ t.Fatalf("member's refused tag POST still wrote tags: %+v", boxes)
+ }
+}
+
+// TestFleetPageTagBoxWithoutCSRFForbidden: the tag form is CSRF-gated like
+// every other mutation on this console — the same 403 rotate's CSRF check
+// gives (TestFleetPageRotateWithoutCSRFForbidden).
+func TestFleetPageTagBoxWithoutCSRFForbidden(t *testing.T) {
+ fx, token := fleetFixture(t, "code-fleetpage-tags-csrf")
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", st, body)
+ }
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
+ map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
+ t.Fatalf("add-box = %d %v", st, body)
+ }
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-fleetpage-tags-csrf-browser")
+ st, _, _ := postForm(t, c, fx.u("/fleets/acme/tags"), url.Values{"csrf": {"wrong"}, "label": {"pi-1"}, "tags": {"office"}})
+ if st != http.StatusForbidden {
+ t.Fatalf("tag-box with a bad csrf = %d, want 403", st)
+ }
+}
+
+// TestFleetPageTagBoxBadGrammarFlashesErrorNothingWritten: reject, don't
+// sanitize — invalid grammar (uppercase, here) never reaches SetFleetBoxTags,
+// the same "checked before the write, not after" posture
+// handleAPIFleetTagBox already takes, and the flash copy matches Task 2's
+// API error text verbatim.
+func TestFleetPageTagBoxBadGrammarFlashesErrorNothingWritten(t *testing.T) {
+ fx, token := fleetFixture(t, "code-fleetpage-tags-badgrammar")
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", st, body)
+ }
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
+ map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
+ t.Fatalf("add-box = %d %v", st, body)
+ }
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes/pi-1/tags",
+ map[string]string{"tags": "office"}, token); st != http.StatusOK {
+ t.Fatalf("seed tag-box = %d %v", st, body)
+ }
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-fleetpage-tags-badgrammar-browser")
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, _ := postForm(t, c, fx.u("/fleets/acme/tags"),
+ url.Values{"csrf": {csrf}, "label": {"pi-1"}, "tags": {"Office"}})
+ if status != http.StatusSeeOther || loc != fx.path("/fleets/acme") {
+ t.Fatalf("bad-grammar tag-box = %d -> %q, want 303 -> %q", status, loc, fx.path("/fleets/acme"))
+ }
+
+ _, _, body := get(t, c, fx.u("/fleets/acme"))
+ if !strings.Contains(body, "invalid tags — comma-separated [a-z0-9-] (hyphens interior), max 8") {
+ t.Fatalf("bad-grammar tag-box did not flash the expected error:\n%s", body)
+ }
+
+ reg := openTestReg(t, fx)
+ boxes, err := reg.ListFleetBoxes("acme")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if boxes[0].Tags != "office" {
+ t.Fatalf("registry tags after rejected write = %q, want unchanged office", boxes[0].Tags)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index b23e8f31c..4f3462d0e 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -133,6 +133,11 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{
// set, the licence, and why template.HTML is safe there and nowhere
// else. Chrome is drawn; emoji stay content.
"icon": icon,
+ // join renders fleetPageBox.Tags back into the tag form's editable
+ // text input, comma-separated — the exact grammar SetFleetBoxTags
+ // expects back, so an owner opening the form sees the same string they'd
+ // type to leave it unchanged.
+ "join": strings.Join,
}).ParseFS(tmplFS, "templates/*.html"))
const (
@@ -340,6 +345,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /a/{sqid}/fleets/{fleet}", s.handleFleetPage)
s.mux.HandleFunc("POST /a/{sqid}/fleets/{fleet}/rotate", s.handleFleetRotateBoxPage)
s.mux.HandleFunc("POST /a/{sqid}/fleets/{fleet}/detach", s.handleFleetDetachBoxPage)
+ s.mux.HandleFunc("POST /a/{sqid}/fleets/{fleet}/tags", s.handleFleetTagBoxPage)
s.mux.HandleFunc("POST /a/{sqid}/invite", s.handleInvite)
s.mux.HandleFunc("POST /a/{sqid}/invite/revoke", s.handleRevokeInvite)
// Owner-only account administration, every one of them behind passkey
diff --git a/internal/console/templates/fleetdetail.html b/internal/console/templates/fleetdetail.html
index ba8fc9fda..613fa78a8 100644
--- a/internal/console/templates/fleetdetail.html
+++ b/internal/console/templates/fleetdetail.html
@@ -23,7 +23,7 @@
<div class="tablewrap">
<table>
<tr>
- <th>Label</th><th>Status</th><th>Tunnel</th>
+ <th>Label</th><th>Status</th><th>Tags</th><th>Tunnel</th>
<th>Memory free</th><th>Load</th><th>Disk free</th><th>Release</th>
{{if .IsOwner}}<th></th>{{end}}
</tr>
@@ -31,6 +31,7 @@
<tr>
<td>{{.Label}}</td>
<td>{{.Status}}</td>
+ <td>{{range .Tags}}<span class="chip">{{.}}</span> {{end}}{{if not .Tags}}—{{end}}</td>
{{if .Reachable}}
<td>{{emptyOrDash .Tunnel}}</td>
<td>{{if .Metrics}}{{bytesOrDash .Metrics.MemAvailBytes}} / {{bytesOrDash .Metrics.MemTotalBytes}}{{else}}—{{end}}</td>
@@ -54,6 +55,12 @@
<input type="hidden" name="label" value="{{.Label}}">
<button type="submit" class="linkish danger">Detach</button>
</form>
+ <form method="post" action="{{$.Base}}/fleets/{{$.FleetDetail.Name}}/tags">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="label" value="{{.Label}}">
+ <input type="text" name="tags" value="{{join .Tags ","}}" size="14" placeholder="office,free">
+ <button type="submit" class="linkish">Tag</button>
+ </form>
</td>
{{end}}
</tr>