carlos / platform Public

feat(console): API account resolution goes symmetric; exact=sqid carries -account-id

apiAccountApp resolved a sqid before a name, so a segment that named one
account's sqid and another's name silently picked the sqid — the day a
colliding account appears, a command whose wording never changed starts
acting on a different tenant. It now resolves through matchAccounts
(resolve.go), the same symmetric, membership-scoped match /a/{seg} uses in
the browser, and refuses two candidates with a 409 that names both and the
flag that settles it.

?exact=sqid is that flag's transport: it matches the segment as a sqid
only, so the reference reached for to disambiguate can never itself be
ambiguous. It never widens access — a non-member's probe meets the same
byte-identical http.NotFound either way, exact or not.

TestAPIAccountSqidBeatsName is gone, replaced by
TestAPIAccountSqidNameCollisionRefused: its scenario is exactly the
collision, and the answer we want for it has inverted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGdTfzwT492ysLPDaWfpvJ
Paul Campbell b8e031feeb09bd64c7617d637f0788a0d5480bad parent ed6f7a3
2 files changed, +119 −41
  • internal/console/apicli.go +36 −30
  • internal/console/apicli_test.go +83 −11
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 9ab77f6da..9d5a4d479 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -32,12 +32,19 @@ import (
// memberships — resolution and membership proof are deliberately the same
// lookup, so an unknown account and someone else's account are the same 404.
//
-// Resolution follows the same doctrine as the CLI's own resolveAccount
-// (accounts.go): an exact SQID match wins outright, and only when none
-// matches does a NAME match apply, case-insensitively (EqualFold) — and an
-// ambiguous name (two of the caller's own accounts sharing it) is refused
-// rather than guessed at, because picking the wrong account here would list
-// or promote against the wrong tenant.
+// Resolution is SYMMETRIC (matchAccounts, resolve.go): the segment matches
+// as a sqid or as a name — case-insensitively — with no precedence between
+// the two spaces, exactly as the browser's /a/{seg} resolves it. A sqid
+// match no longer wins outright over a name match, because precedence is how
+// a reference silently changes meaning the day a colliding account appears:
+// the caller who typed a name would start acting on someone else's tenant
+// without the wording of their command changing at all. Two candidates are
+// refused (409) rather than guessed at.
+//
+// `?exact=sqid` is the transport for the CLI's -account-id flag: it matches
+// the segment as a sqid ONLY, so the reference a person reaches for to
+// settle an ambiguity can never itself be ambiguous. It never widens access
+// — a non-member's probe meets the same 404 either way.
func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) {
addr, ok := s.cliUser(w, r)
if !ok {
@@ -50,35 +57,34 @@ func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (string,
jsonError(w, http.StatusInternalServerError, "could not load accounts")
return "", nil, "", false
}
- var acct *Account
- for i := range accts {
- if accts[i].Sqid == seg {
- acct = &accts[i]
- break
- }
- }
- if acct == nil {
- var matches []*Account
+ var matches []*Account
+ if r.URL.Query().Get("exact") == "sqid" {
+ // -account-id: the reference that never guesses. Match the segment
+ // as a sqid only — a name, however unique, does not answer.
for i := range accts {
- if strings.EqualFold(accts[i].Name, seg) {
+ if accts[i].Sqid == seg {
matches = append(matches, &accts[i])
+ break
}
}
- switch len(matches) {
- case 1:
- acct = matches[0]
- case 0:
- // acct stays nil — the same 404 an unknown or someone-else's
- // account gets, below.
- default:
- sqids := make([]string, len(matches))
- for i, m := range matches {
- sqids[i] = m.Sqid
- }
- jsonError(w, http.StatusConflict,
- fmt.Sprintf("account name %q is ambiguous between %s — use a sqid", seg, strings.Join(sqids, ", ")))
- return "", nil, "", false
+ } else {
+ matches = matchAccounts(accts, seg)
+ }
+ var acct *Account
+ switch len(matches) {
+ case 1:
+ acct = matches[0]
+ case 0:
+ // acct stays nil — the same 404 an unknown or someone-else's
+ // account gets, below.
+ default:
+ alts := make([]string, len(matches))
+ for i, m := range matches {
+ alts[i] = fmt.Sprintf("%s (%q)", m.Sqid, m.Name)
}
+ jsonError(w, http.StatusConflict,
+ fmt.Sprintf("account %q is ambiguous — pass -account-id with one of: %s", seg, strings.Join(alts, ", ")))
+ return "", nil, "", false
}
if acct == nil {
http.NotFound(w, r)
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index 60afc5b60..464f0c472 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -289,13 +289,22 @@ func TestAPIChannelsRefusalMatrix(t *testing.T) {
if bodyA != "404 page not found\n" {
t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", bodyA)
}
+
+ // (c) the same probe carrying ?exact=sqid — the -account-id transport is
+ // not a second, chattier door: a non-member meets the identical refusal
+ // whether they name the account exactly or loosely.
+ statusC, bodyC := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels?exact=sqid", eve)
+ if statusC != http.StatusNotFound || bodyC != bodyA {
+ t.Fatalf("exact=sqid member-of-nothing = %d %q, want the identical 404 %q", statusC, bodyC, bodyA)
+ }
}
-// TestAPIAccountSqidBeatsName: a SQID match wins outright, even when the
-// segment given also happens to equal ANOTHER of the caller's accounts'
-// NAME. Picking that other account here would list (or later, act on) the
-// wrong tenant.
-func TestAPIAccountSqidBeatsName(t *testing.T) {
+// TestAPIAccountSqidNameCollisionRefused: resolution is SYMMETRIC — a
+// segment matching one account's SQID and another's NAME is ambiguous, not a
+// sqid win. Precedence is how a reference silently changes meaning the day a
+// colliding account appears; here the API refuses and names the flag that
+// settles it, and -account-id (?exact=sqid) then answers unambiguously.
+func TestAPIAccountSqidNameCollisionRefused(t *testing.T) {
fx := newConsoleFixture(t)
now := time.Now()
// bac's NAME is literally "bab" — the sqid of the ops account. Alice
@@ -309,19 +318,28 @@ func TestAPIAccountSqidBeatsName(t *testing.T) {
if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
t.Fatal(err)
}
- token := cliToken(t, fx, "alice@keymail.dev", "code-sqid-beats-name")
+ token := cliToken(t, fx, "alice@keymail.dev", "code-sqid-name-collision")
- // "bab" as the account segment must resolve to the SQID match (the ops
- // account, which has titogo) and not the NAME match (bac, which doesn't).
status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token)
+ if status != http.StatusConflict {
+ t.Fatalf("collision channels = %d %v, want 409 (bab is both a sqid and a name here)", status, out)
+ }
+ msg := str(t, out, "error")
+ if !strings.Contains(msg, "bab") || !strings.Contains(msg, "bac") || !strings.Contains(msg, "-account-id") {
+ t.Fatalf("collision error = %q, want both candidates and -account-id named", msg)
+ }
+
+ // -account-id settles it: as a sqid, "bab" is the ops account alone.
+ status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels?exact=sqid", token)
if status != http.StatusOK {
- t.Fatalf("channels = %d %v, want 200 (sqid match, not bac's name match)", status, out)
+ t.Fatalf("exact=sqid channels = %d %v, want 200 (the ops account, which holds titogo)", status, out)
}
}
// TestAPIAccountAmbiguousNameRefused: two of the caller's own accounts
-// sharing a name is refused with 409 and both candidate sqids named, never
-// silently resolved to one of them.
+// sharing a name is refused with 409 and both candidates named — never
+// silently resolved to one of them — and the refusal names -account-id,
+// which is the only thing the caller can do about it.
func TestAPIAccountAmbiguousNameRefused(t *testing.T) {
fx := newConsoleFixture(t)
now := time.Now()
@@ -342,6 +360,60 @@ func TestAPIAccountAmbiguousNameRefused(t *testing.T) {
if !strings.Contains(msg, sqidA) || !strings.Contains(msg, sqidB) {
t.Fatalf("ambiguous-name error = %q, want both %q and %q named", msg, sqidA, sqidB)
}
+ if !strings.Contains(msg, "-account-id") {
+ t.Fatalf("ambiguous-name error = %q, want it to name the -account-id flag", msg)
+ }
+}
+
+// TestAPIAccountExactSqid: the ?exact=sqid contract the CLI's -account-id
+// flag rides on. It matches the segment as a SQID only — a name, however
+// unique to this caller, never answers — so the reference a person reaches
+// for when the pretty one is ambiguous can never itself become ambiguous.
+func TestAPIAccountExactSqid(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ sqidA, sqidB := domains.Sqid(2), domains.Sqid(3)
+ // Two of alice's own accounts named "amadan"; only the first holds web.
+ if err := fx.st.CreateAccount(2, sqidA, "amadan", "alice@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.CreateAccount(3, sqidB, "amadan", "alice@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(2, "web", "alice@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "alice@keymail.dev", "code-exact-sqid")
+
+ // The bare name is ambiguous — and says so, in the words a caller can act on.
+ status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/amadan/web/channels", token)
+ if status != http.StatusConflict {
+ t.Fatalf("ambiguous amadan = %d %v, want 409", status, out)
+ }
+ msg := str(t, out, "error")
+ if !strings.Contains(msg, sqidA) || !strings.Contains(msg, sqidB) || !strings.Contains(msg, "-account-id") {
+ t.Fatalf("ambiguous error = %q, want both sqids and -account-id named", msg)
+ }
+
+ // The sqid, exactly, resolves to the account that holds the app.
+ status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/"+sqidA+"/web/channels?exact=sqid", token)
+ if status != http.StatusOK {
+ t.Fatalf("exact=sqid %s = %d %v, want 200", sqidA, status, out)
+ }
+
+ // A NAME under exact=sqid does not match at all — the same 404 an unknown
+ // account gets, byte-identical to http.NotFound's own output.
+ st, body := getRaw(t, fx.ts.URL+"/api/cli/apps/amadan/web/channels?exact=sqid", token)
+ if st != http.StatusNotFound || body != "404 page not found\n" {
+ t.Fatalf("exact=sqid with a name = %d %q, want the stdlib 404", st, body)
+ }
+
+ // The caller's OTHER account, named exactly, simply hasn't claimed web:
+ // the identical 404, so exact mode leaks nothing either.
+ st, body2 := getRaw(t, fx.ts.URL+"/api/cli/apps/"+sqidB+"/web/channels?exact=sqid", token)
+ if st != http.StatusNotFound || body2 != body {
+ t.Fatalf("exact=sqid unclaimed app = %d %q, want the identical 404 %q", st, body2, body)
+ }
}
// TestAPIAccountNameMatchIsCaseInsensitive: a unique name match applies