plan: account slugs — six tasks, symmetric resolution everywhere
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGdTfzwT492ysLPDaWfpvJ
1 file changed,
+914
−0
docs/superpowers/plans/2026-08-04-account-slugs.md+914 −0
diff --git a/docs/superpowers/plans/2026-08-04-account-slugs.md b/docs/superpowers/plans/2026-08-04-account-slugs.md| new file mode 100644 |
| index 000000000..b0ce27f07 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-04-account-slugs.md |
| @@ -0,0 +1,914 @@ |
| +# Account Slugs Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Make account names resolve wherever sqids do — `/a/amadan/` in the console, `-account amadan` in the CLI — via symmetric membership-scoped resolution, with ambiguity surfaced (picker page / error naming `-account-id`), per `docs/superpowers/specs/2026-08-04-account-slugs-design.md`. |
| + |
| +**Architecture:** No storage change. A name validator in `internal/domains`; symmetric resolution in the console's `s.account` gate and `apiAccountApp`; a pure `pickAccount` core in the CLI's `resolveAccount`; a `preferredSeg` helper so generated links are pretty while form actions stay on sqid paths. |
| + |
| +**Tech Stack:** Go stdlib only (net/http, html/template, database/sql); no new dependencies. |
| + |
| +## Global Constraints |
| + |
| +- Build/test/vet: `go build ./... && go vet ./... && go test ./... -count=1` — all 14 packages, from the repo root, before every commit. |
| +- Sandbox: prefix go commands with `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; "read-only file system" from the toolchain means run with sandbox disabled. |
| +- `gofmt -l` must be clean for every file you touch. |
| +- **Wire shapes are additive** — no manifest/registry/console schema change is permitted by this plan; if a task seems to need one, stop and say so. |
| +- **Console security-gate order is fixed**: session → CSRF → member/owner (byte-identical 404) → `requireFreshAuth` LAST. A non-member must get the byte-identical `http.NotFound` body for names exactly as for sqids. |
| +- **Production channel pins** (`internal/release/laddercheck_test.go`) must stay green and untouched. |
| +- Commit after each task with the trailer: |
| + `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` and |
| + `Claude-Session: https://claude.ai/code/session_01QGdTfzwT492ysLPDaWfpvJ` |
| +- Model policy (dispatcher): Task 1 → **Haiku**; Tasks 4, 6 → **Sonnet**; Tasks 2, 3, 5 → **Opus** (they touch the 404 gate and the deploy-target chooser). **Opus reviews every task.** |
| + |
| +--- |
| + |
| +### Task 1: `domains.ValidAccountName` (model: Haiku) |
| + |
| +**Files:** |
| +- Create: `internal/domains/slugname.go` |
| +- Test: `internal/domains/slugname_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `func ValidAccountName(name string) bool` in package `domains` — later tasks (console picker/preferred-segment, creation-edge validation) call exactly this. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/domains/slugname_test.go`: |
| + |
| +```go |
| +package domains |
| + |
| +import "testing" |
| + |
| +func TestValidAccountName(t *testing.T) { |
| + valid := []string{"amadan", "a", "kass-2", "ops", "a-b-c", "0day", "x9"} |
| + invalid := []string{ |
| + "", "Amadan", "-lead", "trail-", "two words", "paul@keymail.dev", |
| + "dots.no", "unicode-é", "UPPER", |
| + // 64 chars — one past the limit. |
| + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", |
| + } |
| + for _, name := range valid { |
| + if !ValidAccountName(name) { |
| + t.Errorf("ValidAccountName(%q) = false, want true", name) |
| + } |
| + } |
| + for _, name := range invalid { |
| + if ValidAccountName(name) { |
| + t.Errorf("ValidAccountName(%q) = true, want false", name) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run it, expect FAIL** — `go test ./internal/domains/ -run TestValidAccountName -count=1` fails with "undefined: ValidAccountName". |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`internal/domains/slugname.go`: |
| + |
| +```go |
| +package domains |
| + |
| +import "regexp" |
| + |
| +// accountNameRe pins what an account NAME may be so it can live in a URL |
| +// path segment and a CLI flag: 1–63 characters of lowercase a-z, 0-9 and |
| +// hyphen, no leading or trailing hyphen. Names are still deliberately |
| +// non-unique (identity is the sqid); this constrains shape, not ownership. |
| +var accountNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) |
| + |
| +// ValidAccountName reports whether name is usable as an account name. |
| +// Applied at the edges where a human TYPES a name (console new-account |
| +// form, POST /auth/cli/accounts, `carlos accounts create`) — never at |
| +// auto-mint, where the personal account is named by keymail address and a |
| +// refusal would strand a signed-in user. |
| +func ValidAccountName(name string) bool { return accountNameRe.MatchString(name) } |
| +``` |
| + |
| +- [ ] **Step 4: Run it, expect PASS**, then the full suite. |
| +- [ ] **Step 5: Commit** — `feat(domains): ValidAccountName pins the slug shape account names must take` |
| + |
| +--- |
| + |
| +### Task 2: Console symmetric resolution + picker page (model: Opus) |
| + |
| +**Files:** |
| +- Modify: `internal/console/server.go` (`s.account`, ~line 525–560; `page` struct ~line 281) |
| +- Create: `internal/console/templates/picker.html` |
| +- Create: `internal/console/resolve.go` (`matchAccounts`) |
| +- Test: `internal/console/server_account_test.go` (extend), `internal/console/resolve_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `store.AccountsFor(addr) ([]Account, error)` (fills `Role`), `page` fields, `s.render`. |
| +- Produces: `func matchAccounts(accts []Account, seg string) []*Account` — Task 3 mirrors its semantics; Task 4 relies on `s.account`'s resolved account being membership-scoped. New `page` fields `PickerSeg string`, `PickerAccounts []Account`. |
| + |
| +**Semantics to implement** (from spec §2): `/a/{seg}` resolves against the viewer's memberships, matching `seg` as sqid OR name (case-insensitive). One match → proceed as today. Zero → byte-identical `http.NotFound`. Several → GET renders the picker (200); any other method gets `http.NotFound` (forms post to sqid paths, so an ambiguous non-GET has no legitimate author). Signed-out still redirects to `/`. |
| + |
| +- [ ] **Step 1: Write failing tests** |
| + |
| +`internal/console/resolve_test.go`: |
| + |
| +```go |
| +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 |
| + } |
| + } |
| + } |
| +} |
| +``` |
| + |
| +Extend `internal/console/server_account_test.go` (use the existing fixture helpers — `newConsoleFixture`, `newBrowser`, `signUp`, `get`, `postForm`, `testAccountID`, `accountSqid`; read the fixture first to learn the fixture account's name): |
| + |
| +```go |
| +// 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") |
| + } |
| +} |
| + |
| +// 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. |
| +func TestAmbiguousNameRendersPicker(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-1") |
| + 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{"address": {"x@keymail.dev"}}); st != http.StatusNotFound { |
| + t.Errorf("POST to ambiguous segment = %d, want 404", st) |
| + } |
| +} |
| + |
| +// 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) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run, expect FAIL** — `matchAccounts` undefined; name URLs 404. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`internal/console/resolve.go`: |
| + |
| +```go |
| +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. |
| +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 |
| +} |
| +``` |
| + |
| +Rewrite `s.account` in `internal/console/server.go` (keeping its comment's contract, updated): |
| + |
| +```go |
| +// 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. |
| +// 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. |
| +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 |
| + } |
| + accts, err := s.store.AccountsFor(addr) |
| + if err != nil { |
| + log.Printf("console: account lookup: %v", err) |
| + http.NotFound(w, r) |
| + return "", "", nil, "", false |
| + } |
| + 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 |
| + 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 |
| + } |
| +} |
| +``` |
| + |
| +Note: `role` now comes from `AccountsFor`'s `m.Role` — verify `AccountsFor` fills it (it selects `m.role`; it does). The old `AccountBySqid`+`Role` pair is no longer called here; leave those store methods alone (other callers exist). |
| + |
| +Add to `page` struct (near the account chrome block): |
| + |
| +```go |
| + PickerSeg string // the ambiguous segment the picker is disambiguating |
| + PickerAccounts []Account // every account of the viewer's that segment matches |
| +``` |
| + |
| +`internal/console/templates/picker.html` (match the house template style — `{{define}}`, `top`/`bottom`): |
| + |
| +```html |
| +{{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> |
| +<table> |
| + <thead><tr><th>Name</th><th>Sqid</th><th>Your role</th><th>Created</th></tr></thead> |
| + <tbody> |
| + {{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}} |
| + </tbody> |
| +</table> |
| +{{template "bottom" .}}{{end}} |
| +``` |
| + |
| +- [ ] **Step 4: Run the new tests, then the whole console package, then the full suite.** `TestNonMemberGetsIdenticalNotFound` and every existing `/a/<sqid>` test must pass unchanged. |
| +- [ ] **Step 5: Commit** — `feat(console): /a/{seg} resolves names symmetrically; ambiguity renders a picker, never a guess` |
| + |
| +--- |
| + |
| +### Task 3: Console API symmetric resolution + `exact=sqid` (model: Opus) |
| + |
| +**Files:** |
| +- Modify: `internal/console/apicli.go` (`apiAccountApp`, lines ~30–100) |
| +- Test: alongside the existing API-CLI tests (grep `apiAccountApp\|/api/cli` in `internal/console/*_test.go` and extend that file; if none exists, add `internal/console/apicli_test.go` using the console fixture + a CLI token the way the existing authcli tests mint one) |
| + |
| +**Interfaces:** |
| +- Consumes: `store.AccountsFor`, `jsonError`, `s.cliUser`. |
| +- Produces: the query contract Task 5's client relies on — `?exact=sqid` on any `/api/cli/apps/{account}/{app}/...` URL makes the `{account}` segment match as a sqid only; the ambiguity refusal is HTTP 409 whose message contains `-account-id`. |
| + |
| +- [ ] **Step 1: Write failing tests** — through the HTTP surface (`GET /api/cli/apps/{account}/{app}/channels` is the simplest authed route): |
| + - caller with two accounts named `amadan` (one holding app `web`): request with segment `amadan` → **409**, body mentions both sqids and the string `-account-id`. |
| + - same setup, segment `<sqidA>?exact=sqid` → 200 (or the route's normal answer for an app that exists there). |
| + - caller whose account named `bah` collides with another membership's sqid `bah`: segment `bah` → **409** (symmetric — today's code would silently pick the sqid match). |
| + - `?exact=sqid` with a segment that is a NAME (`amadan`) → 404 (never name-matches). |
| + - non-member account segment (name or sqid) → 404, `http.NotFound` shape. |
| + |
| +- [ ] **Step 2: Run, expect FAIL** (the name/sqid collision silently resolves today; `exact` is ignored). |
| + |
| +- [ ] **Step 3: Implement** — replace the resolution block of `apiAccountApp`: |
| + |
| +```go |
| + seg := r.PathValue("account") |
| + app := r.PathValue("app") |
| + accts, err := s.store.AccountsFor(addr) |
| + if err != nil { |
| + jsonError(w, http.StatusInternalServerError, "could not load accounts") |
| + return "", nil, "", false |
| + } |
| + 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 accts[i].Sqid == seg { |
| + matches = append(matches, &accts[i]) |
| + break |
| + } |
| + } |
| + } 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 |
| + } |
| +``` |
| + |
| +Update the function's doc comment: resolution is now symmetric (no sqid precedence), and `exact=sqid` is the `-account-id` transport. Keep everything from the `if acct == nil` 404 onward unchanged. |
| + |
| +- [ ] **Step 4: Run new tests + console package + full suite.** |
| +- [ ] **Step 5: Commit** — `feat(console): API account resolution goes symmetric; exact=sqid carries -account-id` |
| + |
| +---### Task 4: Preferred segments — links pretty, actions canonical (model: Sonnet) |
| + |
| +**Files:** |
| +- Modify: `internal/console/server.go` (`accountPage` ~1068, `redirectToCurrentAccount` ~430, `page` struct — add `Nav`; every `Base:` assignment gets a `Nav:` sibling — grep `Base:`) |
| +- Modify: `internal/console/store.go` (`Account` struct — add view-only `Seg` field) |
| +- Modify: `internal/console/resolve.go` (add `preferredSeg`) |
| +- Modify templates: `layout.html` (lines 229, 241), `apps.html` (line 12), `newaccount.html` (line 17) |
| +- Test: `internal/console/resolve_test.go`, `internal/console/server_account_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `matchAccounts` (Task 2), `domains.ValidAccountName` (Task 1). |
| +- Produces: `func preferredSeg(accts []Account, a *Account) string`; `page.Nav string` (link base, pretty); `Account.Seg string` (switcher hrefs). **Form actions keep using `page.Base` (sqid) — do not touch any `action=` attribute.** |
| + |
| +- [ ] **Step 1: Write failing tests** |
| + |
| +`internal/console/resolve_test.go`: |
| + |
| +```go |
| +func TestPreferredSeg(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: "paul@keymail.dev"}, |
| + {ID: 5, Sqid: "bag", Name: "bah"}, // collides with #2's sqid |
| + } |
| + cases := []struct { |
| + id int64 |
| + want string |
| + }{ |
| + {1, "ops"}, // unique slug-shaped name → the name |
| + {2, "bah"}, // name shared with #3 → sqid |
| + {3, "bad"}, // same |
| + {4, "baf"}, // address-named (auto-mint) → sqid |
| + {5, "bag"}, // name equals #2's sqid → sqid |
| + } |
| + for _, c := range cases { |
| + var a *Account |
| + for i := range accts { |
| + if accts[i].ID == c.id { |
| + a = &accts[i] |
| + } |
| + } |
| + if got := preferredSeg(accts, a); got != c.want { |
| + t.Errorf("preferredSeg(#%d) = %q, want %q", c.id, got, c.want) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +`server_account_test.go` — landing and chrome: |
| + |
| +```go |
| +// Landing prefers the readable URL: a viewer whose account has a unique |
| +// slug-shaped name lands on /a/<name>/, and the page's nav links use it, |
| +// while its forms keep posting to the sqid path. |
| +func TestLandingPrefersNameSegment(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.setUser("solo@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-solo") |
| + id := testAccountID() |
| + sqid := accountSqid(id) |
| + if err := fx.st.CreateAccount(id, sqid, "amadan", "solo@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st, _, _ := get(t, c, fx.ts.URL+"/a/"+sqid+"/"); st != http.StatusOK { |
| + t.Fatal("visit to set MRU") |
| + } |
| + st, loc, _ := get(t, c, fx.ts.URL+"/") |
| + if st != http.StatusFound || loc != "/a/amadan/" { |
| + t.Fatalf("landing = %d -> %q, want 302 -> /a/amadan/", st, loc) |
| + } |
| + _, _, body := get(t, c, fx.ts.URL+"/a/amadan/") |
| + if !strings.Contains(body, `href="/a/amadan/"`) { |
| + t.Errorf("nav links should use the name segment") |
| + } |
| + if !strings.Contains(body, `action="/a/`+sqid+`/`) { |
| + t.Errorf("form actions must stay on the sqid path") |
| + } |
| +} |
| +``` |
| + |
| +(If the fixture's own account name is slug-shaped and unique for `paul@keymail.dev`, existing assertions like `loc != "/a/"+sqid+"/"` in `TestLandingRedirectsToLastAccount` may now see the name — run the suite and update any such assertion to the preferred segment, stating why in the diff.) |
| + |
| +- [ ] **Step 2: Run, expect FAIL.** |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`internal/console/resolve.go`: |
| + |
| +```go |
| +// preferredSeg is the path segment the console GENERATES for an account: |
| +// its name, when the name is slug-shaped and resolves uniquely among the |
| +// viewer's own accounts; the sqid otherwise. Links and redirects use it — |
| +// forms always post to the sqid path (page.Base), because a mutation must |
| +// name its target exactly and prettiness is for links. |
| +func preferredSeg(accts []Account, a *Account) string { |
| + if !domains.ValidAccountName(a.Name) { |
| + return a.Sqid |
| + } |
| + for i := range accts { |
| + if accts[i].ID == a.ID { |
| + continue |
| + } |
| + if strings.EqualFold(accts[i].Name, a.Name) || accts[i].Sqid == a.Name { |
| + return a.Sqid |
| + } |
| + } |
| + return a.Name |
| +} |
| +``` |
| + |
| +(import `github.com/carlosframework/platform/internal/domains` — the console already depends on it.) |
| + |
| +`store.go` `Account` struct — add: |
| + |
| +```go |
| + // Seg is the path segment the console generates for this account in the |
| + // viewer's chrome (preferredSeg) — display state, never stored. |
| + Seg string |
| +``` |
| + |
| +`accountPage` — after the existing `p.Accounts` fallback block: |
| + |
| +```go |
| + for i := range p.Accounts { |
| + p.Accounts[i].Seg = preferredSeg(p.Accounts, &p.Accounts[i]) |
| + } |
| + p.Nav = "/a/" + url.PathEscape(preferredSeg(p.Accounts, acct)) |
| +``` |
| + |
| +`page` struct — next to `Base`: |
| + |
| +```go |
| + Nav string // "/a/<name-or-sqid>" — what LINKS use; forms keep posting to Base |
| +``` |
| + |
| +Every other `Base:` assignment (grep — e.g. the `/accounts/new` page builder) gets `Nav` set the same way; where the accounts list isn't loaded, set `Nav` equal to `Base` rather than loading it. |
| + |
| +`redirectToCurrentAccount` — before the redirect: |
| + |
| +```go |
| + seg := sqid |
| + if accts, err := s.store.AccountsFor(addr); err == nil { |
| + for i := range accts { |
| + if accts[i].Sqid == sqid { |
| + seg = preferredSeg(accts, &accts[i]) |
| + break |
| + } |
| + } |
| + } |
| + http.Redirect(w, r, "/a/"+url.PathEscape(seg)+"/", code) |
| +``` |
| + |
| +Templates — hrefs only: |
| +- `layout.html:229`: `<a href="/a/{{.Seg}}/">…{{.Name}}</a>` |
| +- `layout.html:241`: `<a href="{{.Nav}}/">Apps</a>` |
| +- `apps.html:12`: `<a href="{{$.Nav}}/apps/{{.App}}">{{.App}}</a>` |
| +- `newaccount.html:17`: `<a href="{{.Nav}}/">Back to {{.Account.Name}}</a>` |
| + |
| +Redirects **after POST** (ownership.go's `/a/"+acct.Sqid+"/`, `handleNewAccount`'s) stay on sqid paths — do not change them. |
| + |
| +- [ ] **Step 4: Run console package, then full suite; fix any landing assertions per Step 1's note.** |
| +- [ ] **Step 5: Commit** — `feat(console): links and landings prefer the account name; forms stay on sqid paths` |
| + |
| +--- |
| + |
| +### Task 5: CLI — symmetric `resolveAccount`, `-account-id`, API-mode threading (model: Opus) |
| + |
| +**Files:** |
| +- Modify: `accounts.go` (`resolveAccount` ~line 622 — extract pure `pickAccount`) |
| +- Modify: `cli.go` (`accountFlags` ~738 and its call sites ~816, 984, 1052, 1108; the manual `-account` registrations at ~69/130 and ~267/345; `ledgerAccountFlag` ~1237 and its call sites) |
| +- Modify: `apimode.go` (`apiPath` ~169; the `apiSession` methods that take an `account string`) |
| +- Test: `accounts_resolve_test.go` (new, package main) |
| + |
| +**Interfaces:** |
| +- Consumes: `registry.Account{ID, Sqid, Name}`, `release.ValidAccount`, the `exact=sqid` query contract from Task 3. |
| +- Produces: |
| + - `func pickAccount(accts []registry.Account, want string) (string, error)` — pure resolution core. |
| + - `func resolveAccount(regPath, want, wantID string) string` — new third parameter. |
| + - `func accountFlags(fs *flag.FlagSet) (raw, rawID *string, resolve func() string)`. |
| + - `func apiAccount(raw, rawID *string) apiAcct` and `type apiAcct struct { Seg string; Exact bool }` — every `apiSession` method that took `account string` takes `apiAcct`; `apiPath(a apiAcct, app, tail string)` appends `?exact=sqid` when `a.Exact` (verify no existing `tail` carries a query string first). |
| + |
| +- [ ] **Step 1: Write failing tests** |
| + |
| +`accounts_resolve_test.go`: |
| + |
| +```go |
| +package main |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func TestPickAccount(t *testing.T) { |
| + accts := []registry.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 collides with #2's sqid |
| + } |
| + if got, err := pickAccount(accts, "bab"); err != nil || got != "bab" { |
| + t.Errorf("sqid: %q, %v", got, err) |
| + } |
| + if got, err := pickAccount(accts, "ops"); err != nil || got != "bab" { |
| + t.Errorf("unique name: %q, %v", got, err) |
| + } |
| + if got, err := pickAccount(accts, "OPS"); err != nil || got != "bab" { |
| + t.Errorf("case-insensitive name: %q, %v", got, err) |
| + } |
| + // A sqid nobody has materialized passes through — a fresh box may not |
| + // have reconciled, and CI has no registry at all. |
| + if got, err := pickAccount(accts, "zzz"); err != nil || got != "zzz" { |
| + t.Errorf("passthrough sqid: %q, %v", got, err) |
| + } |
| + if _, err := pickAccount(accts, "not-a-sqid-or-name"); err == nil { |
| + t.Error("unresolvable non-sqid name must error") |
| + } |
| + // The two ambiguity shapes: a shared name, and a name/sqid collision. |
| + // Both must refuse, and the fix-up named must be -account-id. |
| + for _, want := range []string{"amadan", "bah"} { |
| + _, err := pickAccount(accts, want) |
| + if err == nil || !strings.Contains(err.Error(), "-account-id") { |
| + t.Errorf("pickAccount(%q) = %v, want ambiguity error naming -account-id", want, err) |
| + } |
| + } |
| + if _, err := pickAccount(accts, "bah"); err == nil || |
| + !strings.Contains(err.Error(), "bah") || !strings.Contains(err.Error(), "baf") { |
| + t.Error("name/sqid conflict error must list both candidates") |
| + } |
| +} |
| + |
| +func TestApiAccountExact(t *testing.T) { |
| + acct, id := "ops", "bah" |
| + a := apiAccount(&acct, &id) |
| + if a.Seg != "bah" || !a.Exact { |
| + t.Errorf("apiAccount with -account-id = %+v, want {bah true}", a) |
| + } |
| + id = "" |
| + a = apiAccount(&acct, &id) |
| + if a.Seg != "ops" || a.Exact { |
| + t.Errorf("apiAccount without -account-id = %+v, want {ops false}", a) |
| + } |
| + if p := apiPath(apiAcct{Seg: "bah", Exact: true}, "web", "/channels"); !strings.HasSuffix(p, "?exact=sqid") { |
| + t.Errorf("apiPath exact = %q, want ?exact=sqid suffix", p) |
| + } |
| + if p := apiPath(apiAcct{Seg: "ops"}, "web", "/channels"); strings.Contains(p, "exact") { |
| + t.Errorf("apiPath non-exact = %q, must carry no exact param", p) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run, expect FAIL** (`pickAccount`, `apiAccount`, `apiAcct` undefined). |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`accounts.go` — split `resolveAccount`: |
| + |
| +```go |
| +// pickAccount is resolveAccount's core, symmetric over the two identity |
| +// spaces: want matches an account by sqid OR by name (case-insensitively), |
| +// with NO precedence between them. Precedence is how a script's -account |
| +// silently changes target the day a colliding account materializes; a |
| +// conflict here is loud instead, and names -account-id, the reference that |
| +// never guesses. A sqid-shaped want that matches nothing passes through (a |
| +// fresh box may not have reconciled; CI has no registry at all). |
| +func pickAccount(accts []registry.Account, want string) (string, error) { |
| + var candidates []registry.Account |
| + for _, a := range accts { |
| + if a.Sqid == want || strings.EqualFold(a.Name, want) { |
| + candidates = append(candidates, a) |
| + } |
| + } |
| + switch len(candidates) { |
| + case 1: |
| + return candidates[0].Sqid, nil |
| + case 0: |
| + if release.ValidAccount(want) { |
| + return want, nil |
| + } |
| + return "", fmt.Errorf("no account %q here — `carlos accounts list` shows what this box knows", want) |
| + default: |
| + alts := make([]string, len(candidates)) |
| + for i, a := range candidates { |
| + alts[i] = fmt.Sprintf("%s (%q)", a.Sqid, a.Name) |
| + } |
| + return "", fmt.Errorf("account %q is ambiguous — pass -account-id with one of: %s", |
| + want, strings.Join(alts, ", ")) |
| + } |
| +} |
| +``` |
| + |
| +`resolveAccount` keeps its shell (empty/"ops" shortcut, registry-open with its existing no-registry fallback and messages) and gains the id short-circuit; the matching loops are replaced by one `pickAccount` call whose error goes to `log.Fatalf`: |
| + |
| +```go |
| +func resolveAccount(regPath, want, wantID string) string { |
| + if wantID = strings.TrimSpace(wantID); wantID != "" { |
| + if !release.ValidAccount(wantID) { |
| + log.Fatalf("-account-id %q is not a sqid — it is matched exactly, never as a name", wantID) |
| + } |
| + return wantID |
| + } |
| + // ... existing empty/"ops" shortcut and registry-open fallback unchanged ... |
| + sqid, err := pickAccount(accts, want) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + return sqid |
| +} |
| +``` |
| + |
| +`cli.go` `accountFlags`: |
| + |
| +```go |
| +func accountFlags(fs *flag.FlagSet) (raw, rawID *string, resolve func() string) { |
| + acct := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + acctID := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account; the flag ambiguity errors tell you to use") |
| + regPath := fs.String("registry", defaultRegistry(), "registry database (resolves -account)") |
| + return acct, acctID, func() string { return resolveAccount(*regPath, *acct, *acctID) } |
| +} |
| +``` |
| + |
| +Every `accountFlags` call site becomes `acctRaw, acctIDRaw, acctOf := accountFlags(fs)`; API-mode branches build `apiAccount(acctRaw, acctIDRaw)` instead of `*acctRaw` (this changes `runShipAPI`'s signature — pass both pointers). The two manual `-account` registrations (`cli.go` ~69 and ~267) each gain their own `fs.String("account-id", "", …)` threaded into their `resolveAccount` calls (the ~267 route-filter keeps its `"all"` semantics: `all` never reaches `resolveAccount`, so nothing changes there). `ledgerAccountFlag` grows into: |
| + |
| +```go |
| +func ledgerAccountFlags(fs *flag.FlagSet) (raw, rawID *string) { |
| + return fs.String("account", "ops", "account (sqid or name) the app belongs to — resolved by the console"), |
| + fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| +} |
| +``` |
| + |
| +with its call sites building `apiAccount(raw, rawID)`. |
| + |
| +`apimode.go`: |
| + |
| +```go |
| +// apiAcct is one resolved -account/-account-id pair, API-mode: the segment |
| +// the console will resolve, and whether it must match as a sqid only. |
| +type apiAcct struct { |
| + Seg string |
| + Exact bool |
| +} |
| + |
| +// apiAccount is resolveAccount's API-mode counterpart. Names resolve |
| +// SERVER-side against the caller's memberships, so -account travels |
| +// verbatim; -account-id travels as the segment plus exact=sqid, so the |
| +// console never name-matches it. |
| +func apiAccount(raw, rawID *string) apiAcct { |
| + if id := strings.TrimSpace(*rawID); id != "" { |
| + if !release.ValidAccount(id) { |
| + log.Fatalf("-account-id %q is not a sqid — it is matched exactly, never as a name", id) |
| + } |
| + return apiAcct{Seg: id, Exact: true} |
| + } |
| + return apiAcct{Seg: *raw} |
| +} |
| +``` |
| + |
| +`apiPath` takes the pair (update its comment: account still resolves server-side): |
| + |
| +```go |
| +func apiPath(a apiAcct, app, tail string) string { |
| + p := "/api/cli/apps/" + url.PathEscape(a.Seg) + "/" + url.PathEscape(app) + tail |
| + if a.Exact { |
| + p += "?exact=sqid" |
| + } |
| + return p |
| +} |
| +``` |
| + |
| +Change each `apiSession` method (`promote`, `rollback`, `channels`, `ship`, the five `ledger*`) from `account string` to `a apiAcct`, passing it through to `apiPath`. First verify no call passes a `tail` containing `?`; if one does, switch `apiPath` to `url.Values` instead of string concat and say so in the commit message. |
| + |
| +- [ ] **Step 4: Run new tests, then the full suite** — `apimode_test.go` exercises this client surface end-to-end and must stay green (it will need its call sites updated for the new signatures; behavior assertions stay). |
| +- [ ] **Step 5: Commit** — `cli: -account resolves symmetrically and loudly; -account-id is the reference that never guesses` |
| + |
| +--- |
| + |
| +### Task 6: Validate names at the three creation edges (model: Sonnet) |
| + |
| +**Files:** |
| +- Modify: `internal/console/server.go` (`handleNewAccount` ~633; `mintAccount` comment ~500) |
| +- Modify: `internal/console/authcli.go` (`handleCLINewAccount` ~686) |
| +- Modify: `accounts.go` (`runAccountsCreate` ~79) |
| +- Modify: `internal/console/templates/newaccount.html` (the name input) |
| +- Test: `internal/console/server_account_test.go` (or the file housing existing new-account tests — grep `accounts/new`) |
| + |
| +**Interfaces:** |
| +- Consumes: `domains.ValidAccountName` (Task 1). |
| +- Produces: nothing new — this closes the front doors. |
| + |
| +- [ ] **Step 1: Write failing tests** |
| + |
| +```go |
| +// The three edges where a human types a name all refuse a non-slug; the |
| +// auto-mint path (address-named personal accounts) must NOT be validated — |
| +// a mint that can refuse strands a signed-in user. |
| +func TestNewAccountNameValidated(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-1") |
| + before, err := fx.st.AccountsFor("paul@keymail.dev") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"name": {"Paul's Stuff!"}}) |
| + if st != http.StatusSeeOther || loc != "/accounts/new" { |
| + t.Fatalf("bad name = %d -> %q, want 303 back to the form", st, loc) |
| + } |
| + after, err := fx.st.AccountsFor("paul@keymail.dev") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(after) != len(before) { |
| + t.Fatal("a rejected name must not create an account") |
| + } |
| + if st, _, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"name": {"amadan"}}); st != http.StatusSeeOther { |
| + t.Fatalf("good name = %d, want 303 into the new account", st) |
| + } |
| +} |
| +``` |
| + |
| +(Adapt the CSRF plumbing to how existing `/accounts/new` tests post the form — copy their shape. Add the equivalent 400 assertion for `POST /auth/cli/accounts` in the file that already tests `handleCLINewAccount`, posting `{"name":"Paul's Stuff!"}` with a valid CLI token and expecting `http.StatusBadRequest`.) |
| + |
| +- [ ] **Step 2: Run, expect FAIL** (free-text names are accepted today). |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`handleNewAccount` — replace the `name == "" || len(name) > 64` check: |
| + |
| +```go |
| + name := strings.TrimSpace(r.FormValue("name")) |
| + if !domains.ValidAccountName(name) { |
| + s.setFlash(w, "err", "Account names appear in URLs: 1–63 characters of a-z, 0-9 and hyphen, no leading or trailing hyphen.") |
| + http.Redirect(w, r, "/accounts/new", http.StatusSeeOther) |
| + return |
| + } |
| +``` |
| + |
| +`handleCLINewAccount` — same predicate: |
| + |
| +```go |
| + name := strings.TrimSpace(req.Name) |
| + if !domains.ValidAccountName(name) { |
| + jsonError(w, http.StatusBadRequest, |
| + "an account name appears in URLs and -account: 1-63 characters of a-z, 0-9 and hyphen, no leading or trailing hyphen") |
| + return |
| + } |
| +``` |
| + |
| +`runAccountsCreate` — after the existing empty-name usage check: |
| + |
| +```go |
| + if !domains.ValidAccountName(strings.TrimSpace(*name)) { |
| + log.Fatalf("accounts create: %q is not a usable account name — 1-63 characters of a-z, 0-9\n"+ |
| + "and hyphen, no leading or trailing hyphen. The name appears in console URLs and\n"+ |
| + "resolves -account.", *name) |
| + } |
| +``` |
| + |
| +Also update the `-name` flag's help text (`"account name (free text, need not be unique)"` → `"account name (a-z, 0-9, hyphen; need not be unique)"`). |
| + |
| +`mintAccount` — add one line to its comment: |
| + |
| +```go |
| +// The name is NOT validated here: auto-mint names personal accounts by |
| +// keymail address, and landing must never refuse to mint (spec §1). |
| +``` |
| + |
| +`newaccount.html` — on the name `<input>`, add `pattern="[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"` and a hint line under it: `a-z, 0-9 and hyphens — it appears in your URLs`. Adjust any copy that says names are free text. |
| + |
| +- [ ] **Step 4: Run the console package and full suite** — `TestLandingMintsPersonalAccountWhenNoMemberships` (auto-mint) must stay green; update any existing test that created accounts through these handlers with non-slug names. |
| +- [ ] **Step 5: Commit** — `feat: account names are validated at the three doors a human types one through` |
| + |
| +--- |
| + |
| +### Task 7: Verification sweep (dispatcher, with Opus review of the whole diff) |
| + |
| +- [ ] `go build ./... && go vet ./... && go test ./... -count=1` — all packages, zero failures. |
| +- [ ] `gofmt -l .` — no touched file listed (the three pre-existing unformatted files elsewhere are left alone). |
| +- [ ] `git log --oneline main..HEAD` — one commit per task, plan and spec committed. |
| +- [ ] Opus reviews the full branch diff against the spec (superpowers:requesting-code-review), with explicit attention to: byte-identical 404 preserved for names; no `action=` attribute moved off `Base`; no storage shape changed; `laddercheck_test.go` untouched. |