carlos / platform Public

feat(console): /a/{seg} resolves names symmetrically; ambiguity renders a picker, never a guess

/a/{seg} now resolves the segment as a sqid OR a name, with no precedence
between the two spaces — precedence is how a link silently changes meaning
the day a colliding account appears. Resolution runs against the viewer's
OWN memberships (AccountsFor), so resolution and membership proof are the
same lookup: an unknown account, someone else's account and a name that
resolves to nothing all still answer with the byte-identical http.NotFound
an unrouted URL gets.

Several matches are loud rather than guessed: a GET renders the new picker
page (200) listing every candidate by sqid, name, role and creation date;
any other method gets the same 404, because forms post to sqid paths and an
ambiguous mutation has no legitimate author.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGdTfzwT492ysLPDaWfpvJ
Paul Campbell ed6f7a3dbd8ad0252cd28fe80216c89aa5efc373 parent 3f7726a
5 files changed, +241 −18
  • internal/console/resolve.go +23 −0
  • internal/console/resolve_test.go +61 −0
  • internal/console/server.go +38 −18
  • internal/console/server_account_test.go +97 −0
  • internal/console/templates/picker.html +22 −0
diff --git a/internal/console/resolve.go b/internal/console/resolve.go
new file mode 100644
index 000000000..ca3451c33
--- /dev/null
+++ b/internal/console/resolve.go
@@ -0,0 +1,23 @@
+package console
+
+import "strings"
+
+// matchAccounts resolves one path/flag segment against the accounts the
+// VIEWER belongs to, matching it as a sqid or a name — symmetrically, with
+// no precedence between the two spaces. Precedence is how a reference
+// silently changes meaning the day a colliding account appears; a multi-
+// match is returned whole so the caller can be loud instead (picker page,
+// CLI error). Names match case-insensitively; an account whose name equals
+// its own sqid still counts once.
+//
+// The returned pointers address accts, so each match carries whatever
+// AccountsFor filled in — the viewer's Role included.
+func matchAccounts(accts []Account, seg string) []*Account {
+ var out []*Account
+ for i := range accts {
+ if accts[i].Sqid == seg || strings.EqualFold(accts[i].Name, seg) {
+ out = append(out, &accts[i])
+ }
+ }
+ return out
+}
diff --git a/internal/console/resolve_test.go b/internal/console/resolve_test.go
new file mode 100644
index 000000000..1e0a3c35f
--- /dev/null
+++ b/internal/console/resolve_test.go
@@ -0,0 +1,61 @@
+package console
+
+import "testing"
+
+func TestMatchAccounts(t *testing.T) {
+ accts := []Account{
+ {ID: 1, Sqid: "bab", Name: "ops"},
+ {ID: 2, Sqid: "bah", Name: "amadan"},
+ {ID: 3, Sqid: "bad", Name: "amadan"},
+ {ID: 4, Sqid: "baf", Name: "bah"}, // name colliding with #2's sqid
+ {ID: 5, Sqid: "bag", Name: "Mixed"},
+ }
+ cases := []struct {
+ seg string
+ want []int64 // matched account IDs, in accts order
+ }{
+ {"bab", []int64{1}}, // sqid only
+ {"ops", []int64{1}}, // name only
+ {"amadan", []int64{2, 3}}, // two accounts share the name
+ {"bah", []int64{2, 4}}, // sqid of one, name of another
+ {"mixed", []int64{5}}, // names match case-insensitively
+ {"zzz", nil}, // nothing
+ }
+ for _, c := range cases {
+ got := matchAccounts(accts, c.seg)
+ var ids []int64
+ for _, a := range got {
+ ids = append(ids, a.ID)
+ }
+ if len(ids) != len(c.want) {
+ t.Errorf("matchAccounts(%q) = %v, want %v", c.seg, ids, c.want)
+ continue
+ }
+ for i := range ids {
+ if ids[i] != c.want[i] {
+ t.Errorf("matchAccounts(%q) = %v, want %v", c.seg, ids, c.want)
+ break
+ }
+ }
+ }
+}
+
+// An account whose NAME is its own sqid is one account, not two matches —
+// otherwise the picker would ask a viewer to disambiguate between an account
+// and itself.
+func TestMatchAccountsCountsSelfCollisionOnce(t *testing.T) {
+ accts := []Account{{ID: 1, Sqid: "bab", Name: "bab"}}
+ if got := matchAccounts(accts, "bab"); len(got) != 1 || got[0].ID != 1 {
+ t.Errorf("matchAccounts(self-collision) = %+v, want the one account", got)
+ }
+}
+
+// The returned pointers address the CALLER's slice, so a match carries the
+// Role AccountsFor filled in — s.account returns it as the viewer's role.
+func TestMatchAccountsKeepsRole(t *testing.T) {
+ accts := []Account{{ID: 7, Sqid: "bab", Name: "ops", Role: RoleOwner}}
+ got := matchAccounts(accts, "ops")
+ if len(got) != 1 || got[0].Role != RoleOwner {
+ t.Errorf("matchAccounts dropped the role: %+v", got)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 5e44aa521..593a720c2 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -308,6 +308,11 @@ type page struct {
Members []Account // account home: memberships (Name carries the address)
Invites []Invite // account home, owners only: pending invitations
+ // The picker: /a/{seg}/ where seg matches more than one of the viewer's
+ // accounts, as a name, a sqid, or one of each.
+ PickerSeg string // the ambiguous segment the picker is disambiguating
+ PickerAccounts []Account // every account of the viewer's that segment matches
+
Fleet *status.Fleet // /fleet: nil means no snapshot published yet
// /fleet/metrics: the operator dashboard (Task 11), both from LoadMetrics
@@ -522,39 +527,54 @@ func (s *Server) mintAccount(ctx context.Context, name, ownerAddr string) (*Acco
return &Account{ID: id, Sqid: sqid, Name: name, Role: RoleOwner, CreatedAt: now}, nil
}
-// account is the gate on every /a/{sqid}/… route: a session, an account that
-// exists, and a membership in it. All three failures answer with the
-// BYTE-IDENTICAL http.NotFound an unrouted URL gets — account existence is
-// not probeable, matching /fleet's operator gate. Signed-out users are sent
-// to the front page instead, which reveals nothing either way because it
-// happens for every sqid, real or not.
+// account is the gate on every /a/{seg}/… route: a session, and a segment
+// that resolves — as a sqid OR a name, symmetrically — to an account the
+// viewer belongs to. Resolution and membership proof are deliberately the
+// same lookup (the membership set IS the resolution scope), so an unknown
+// account, someone else's account, and a name that resolves to nothing all
+// answer with the BYTE-IDENTICAL http.NotFound an unrouted URL gets —
+// account existence is not probeable, matching /fleet's operator gate.
+// Several matches (a shared name, or a name colliding with a sqid) render
+// the picker on GET and the same 404 otherwise — forms always post to sqid
+// paths, so an ambiguous mutation has no legitimate author.
+//
+// Signed-out users are sent to the front page instead, which reveals nothing
+// either way because it happens for every segment, real or not.
func (s *Server) account(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, role string, ok bool) {
addr, raw, sok := s.session(r)
if !sok {
http.Redirect(w, r, "/", http.StatusFound)
return "", "", nil, "", false
}
- a, err := s.store.AccountBySqid(r.PathValue("sqid"))
+ accts, err := s.store.AccountsFor(addr)
if err != nil {
log.Printf("console: account lookup: %v", err)
http.NotFound(w, r)
return "", "", nil, "", false
}
- if a == nil {
+ seg := r.PathValue("sqid")
+ matches := matchAccounts(accts, seg)
+ switch len(matches) {
+ case 1:
+ a := matches[0]
+ return addr, raw, a, a.Role, true
+ case 0:
http.NotFound(w, r)
return "", "", nil, "", false
- }
- role, err = s.store.Role(a.ID, addr)
- if err != nil {
- log.Printf("console: role lookup: %v", err)
- http.NotFound(w, r)
- return "", "", nil, "", false
- }
- if role == "" {
- http.NotFound(w, r)
+ default:
+ if r.Method != http.MethodGet {
+ http.NotFound(w, r)
+ return "", "", nil, "", false
+ }
+ p := s.basePage(r)
+ p.Title = "Which " + seg + "?"
+ p.PickerSeg = seg
+ for _, m := range matches {
+ p.PickerAccounts = append(p.PickerAccounts, *m)
+ }
+ s.render(w, http.StatusOK, "picker", p)
return "", "", nil, "", false
}
- return addr, raw, a, role, true
}
// owner is account plus the owner-only gate. A member who probes an
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go
index f0969ea6c..52560ac6d 100644
--- a/internal/console/server_account_test.go
+++ b/internal/console/server_account_test.go
@@ -41,6 +41,26 @@ func TestNonMemberGetsIdenticalNotFound(t *testing.T) {
}
}
+// A member can address an account by its name; the page is the same page
+// its sqid serves.
+func TestAccountNameResolvesForMember(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-1")
+ id := testAccountID()
+ sqid := accountSqid(id)
+ if err := fx.st.CreateAccount(id, sqid, "amadan", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ st, _, byName := get(t, c, fx.ts.URL+"/a/amadan/")
+ if st != http.StatusOK {
+ t.Fatalf("GET /a/amadan/ = %d, want 200", st)
+ }
+ if !strings.Contains(byName, "amadan") {
+ t.Fatalf("name-resolved page does not mention the account")
+ }
+}
+
func TestLandingRedirectsToLastAccount(t *testing.T) {
fx := newConsoleFixture(t)
c := newBrowser(t)
@@ -67,6 +87,83 @@ func TestLandingRedirectsToLastAccount(t *testing.T) {
// A user with no memberships at all gets a personal account minted on the
// spot — with a bucket manifest, so every box materializes the same identity.
+// A name the viewer cannot resolve — someone ELSE's account — answers with
+// the byte-identical 404 an unknown page gets, exactly as sqids do.
+func TestAccountNameNotProbeable(t *testing.T) {
+ fx := newConsoleFixture(t)
+ id := testAccountID()
+ if err := fx.st.CreateAccount(id, accountSqid(id), "secretco", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("bob@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-bob")
+ st, _, body := get(t, c, fx.ts.URL+"/a/secretco/")
+ _, _, ghost := get(t, c, fx.ts.URL+"/definitely-not-a-page")
+ if st != http.StatusNotFound || body != ghost {
+ t.Fatalf("non-member name lookup: status %d, body match %v — want the byte-identical 404", st, body == ghost)
+ }
+}
+
+// Two memberships sharing a name: GET renders the picker naming both sqids;
+// a POST to the ambiguous segment is refused with the same 404 — even with a
+// good CSRF token and an owner behind it, so the refusal is resolution's and
+// not some other gate's.
+func TestAmbiguousNameRendersPicker(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-1")
+ csrf := sessionToken(t, fx, c)
+ idA, idB := testAccountID(), testAccountID()
+ sqidA, sqidB := accountSqid(idA), accountSqid(idB)
+ for _, x := range []struct {
+ id int64
+ sqid string
+ }{{idA, sqidA}, {idB, sqidB}} {
+ if err := fx.st.CreateAccount(x.id, x.sqid, "amadan", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ st, _, body := get(t, c, fx.ts.URL+"/a/amadan/")
+ if st != http.StatusOK {
+ t.Fatalf("ambiguous GET = %d, want 200 picker", st)
+ }
+ if !strings.Contains(body, "/a/"+sqidA+"/") || !strings.Contains(body, "/a/"+sqidB+"/") {
+ t.Fatalf("picker must link both candidates by sqid:\n%s", body)
+ }
+ if st, _, _ := postForm(t, c, fx.ts.URL+"/a/amadan/invite",
+ url.Values{"csrf": {csrf}, "address": {"x@keymail.dev"}}); st != http.StatusNotFound {
+ t.Errorf("POST to ambiguous segment = %d, want 404", st)
+ }
+ // And the refused POST did nothing: no invite in either account.
+ for _, id := range []int64{idA, idB} {
+ if pending, _ := fx.st.PendingInvites(id); len(pending) != 0 {
+ t.Errorf("ambiguous POST still invited into account %d: %+v", id, pending)
+ }
+ }
+}
+
+// A name that equals another membership's SQID is ambiguous the same way —
+// symmetric resolution never silently prefers one space over the other.
+func TestNameSqidCollisionRendersPicker(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-1")
+ idA := testAccountID()
+ sqidA := accountSqid(idA)
+ if err := fx.st.CreateAccount(idA, sqidA, "amadan", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ idB := testAccountID()
+ if err := fx.st.CreateAccount(idB, accountSqid(idB), sqidA, "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ st, _, body := get(t, c, fx.ts.URL+"/a/"+sqidA+"/")
+ if st != http.StatusOK || !strings.Contains(body, "/a/"+accountSqid(idB)+"/") {
+ t.Fatalf("sqid-vs-name collision: status %d — want the picker listing both", st)
+ }
+}
+
func TestLandingMintsPersonalAccountWhenNoMemberships(t *testing.T) {
fx := newConsoleFixture(t)
fx.setUser("newbie@keymail.dev")
diff --git a/internal/console/templates/picker.html b/internal/console/templates/picker.html
new file mode 100644
index 000000000..5b019687b
--- /dev/null
+++ b/internal/console/templates/picker.html
@@ -0,0 +1,22 @@
+{{define "picker"}}{{template "top" .}}
+<h1>Which “{{.PickerSeg}}”?</h1>
+<p class="lede">More than one of your accounts answers to
+<code>{{.PickerSeg}}</code>. Pick the one you meant — a sqid link is
+permanent and never asks this question.</p>
+<div class="tablewrap">
+<table>
+ <tr><th>Name</th><th>Sqid</th><th>Your role</th><th>Created</th></tr>
+ {{range .PickerAccounts}}
+ <tr>
+ <td><a href="/a/{{.Sqid}}/">{{.Name}}</a></td>
+ <td><code>/a/{{.Sqid}}/</code></td>
+ <td>{{.Role}}</td>
+ <td>{{.CreatedAt.Format "2006-01-02"}}</td>
+ </tr>
+ {{end}}
+</table>
+</div>
+<p class="small">Account names are deliberately not unique, so a name can mean
+more than one thing to you. The short id in the URL is the identity, and it
+only ever means one account.</p>
+{{template "bottom" .}}{{end}}