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
5 files changed,
+241
−18
internal/console/resolve.go+23 −0internal/console/resolve_test.go+61 −0internal/console/server.go+38 −18internal/console/server_account_test.go+97 −0internal/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}} |