carlos / platform Public

cli: -account-id must be a SQID, not merely lowercase — "ops" was a phantom account

release.ValidAccount is ^[a-z]{3,16}$: it gates bucket path segments, so it
accepts every account NAME. The frozen sqid alphabet has no i, l or o. Under
the previous commit that gap was a silent write to nowhere — `carlos ship
-account-id ops` passed the shape check, wrote apps/ops/..., exited 0, and
served nothing, because the real ops account is Sqid(1) = "bab". `carlos add
-account-id ops` stamped the same phantom onto a registry row, where every
later adopt and edge lookup would key off it and find nothing.

domains.ValidSqid is the strict predicate, next to the frozen encoder it
describes (3-16 letters, all in the alphabet), with SqidAlphabet exported so
the CLI's "these letters only" message cannot drift from the truth.
release.ValidAccount is untouched — it answers a different question.

checkAccountID is the one gate both modes now share, so direct-bucket and API
mode cannot disagree about what a sqid is. It special-cases the trap by name:
"ops" is answered with `use -account ops, or -account-id bab` rather than a
bare "not a sqid" that leaves someone to go look the sqid up.

pickAccount's case-0 passthrough is deliberately unchanged: it reads -account,
where name-vs-sqid ambiguity is already settled by the candidate pool.

Also from the same review: -account-id reaches the hand-written usage strings
(runRoute's says in words that it supersedes "all"); apiAccount is called from
runShip rather than inside runShipAPI, which exists precisely so nothing
fatals past its 256MiB scratch-file defer; apiAccount trims -account the way
resolveAccount does, so `-account " ops "` agrees across modes; and the flag
help no longer reads as a cut-off sentence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGdTfzwT492ysLPDaWfpvJ
Paul Campbell dfdf50289071002c651095341eb4f30028e1e3fb parent d1e99b3
8 files changed, +299 −39
  • accounts.go +30 −4
  • accounts_resolve_test.go +50 −0
  • apimode.go +7 −8
  • apimode_test.go +5 −5
  • cli.go +31 −22
  • e2e_test.go +84 −0
  • internal/domains/sqid.go +36 −0
  • internal/domains/sqid_test.go +56 −0
diff --git a/accounts.go b/accounts.go
index d01afcfcf..2eb55ba8b 100644
--- a/accounts.go
+++ b/accounts.go
@@ -654,6 +654,35 @@ func pickAccount(accts []registry.Account, want string) (string, error) {
}
}
+// checkAccountID is the ONE gate -account-id passes through, shared by
+// resolveAccount (direct-bucket mode) and apiAccount (API mode) so the two
+// modes can never disagree about what a sqid is. It returns the trimmed id,
+// or "" when the flag was not given.
+//
+// The check is domains.ValidSqid, NOT release.ValidAccount. ValidAccount
+// accepts any lowercase word because it gates bucket path segments, and
+// every account NAME is one — so it accepts "ops", which the frozen encoder
+// (no i, l, o) can never emit for any id. Waved through here, `-account-id
+// ops` would have shipped to apps/ops/..., exited 0, and served nothing: the
+// real ops account is "bab". A name in -account-id is a mistake with no
+// honest reading, so it is refused by shape before anything else happens.
+func checkAccountID(wantID string) string {
+ wantID = strings.TrimSpace(wantID)
+ if wantID == "" || domains.ValidSqid(wantID) {
+ return wantID
+ }
+ // "ops" is THE trap: it is the default -account value, the one account
+ // name every operator types, and it is name-shaped in a way that reads
+ // exactly like a short sqid. Name its actual sqid rather than making
+ // someone go look it up.
+ if wantID == "ops" {
+ log.Fatalf(`-account-id takes a sqid; "ops" is a name — use -account ops, or -account-id %s`, release.OpsAccount)
+ }
+ log.Fatalf("-account-id %q is not a sqid (sqids use only the letters %s — no i, l, o) — "+
+ "it looks like a NAME; names go in -account", wantID, domains.SqidAlphabet)
+ return ""
+}
+
// resolveAccount turns a -account/-account-id pair into an account sqid.
// -account-id supersedes -account outright: it is a sqid, matched as a sqid
// only, never consulted against the registry at all — the reference that
@@ -662,10 +691,7 @@ func pickAccount(accts []registry.Account, want string) (string, error) {
// rather than guessed: picking the wrong account here would ship to the
// wrong tenant.
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)
- }
+ if wantID = checkAccountID(wantID); wantID != "" {
return wantID
}
want = strings.TrimSpace(want)
diff --git a/accounts_resolve_test.go b/accounts_resolve_test.go
index d11c728df..4464126ab 100644
--- a/accounts_resolve_test.go
+++ b/accounts_resolve_test.go
@@ -4,7 +4,9 @@ import (
"strings"
"testing"
+ "github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
)
func TestPickAccount(t *testing.T) {
@@ -45,6 +47,43 @@ func TestPickAccount(t *testing.T) {
}
}
+// TestAccountIDShape pins the gate BOTH modes share (checkAccountID, which
+// resolveAccount and apiAccount each call). The values are the ones that
+// matter: "ops" and "oil" pass release.ValidAccount (^[a-z]{3,16}$) and must
+// still be refused, because the frozen encoder has no i, l or o and so can
+// never emit them for any id — a phantom account, written to and served by
+// nobody. "bab" is real; "zzz" is unminted-but-shaped and must pass, since
+// -account-id is the flag you reach for on a box that hasn't reconciled.
+//
+// The refusals themselves are log.Fatal (os.Exit, unobservable in-process),
+// so they are pinned two ways instead: the predicate directly here, and the
+// whole `carlos add -account-id ops` invocation end to end in
+// TestAddAccountIDRefusesAName (e2e_test.go), which asserts the non-zero
+// exit and that no row was written.
+func TestAccountIDShape(t *testing.T) {
+ for _, want := range []string{"bab", "zzz", "baf"} {
+ if !domains.ValidSqid(want) {
+ t.Errorf("-account-id %q must be accepted — it is shaped like something Sqid emits", want)
+ }
+ // The accept path IS reachable in-process: only a refusal exits.
+ empty, id := "", want
+ if a := apiAccount(&empty, &id); a.Seg != want || !a.Exact {
+ t.Errorf("apiAccount(-account-id %q) = %+v, want {%s true}", want, a, want)
+ }
+ }
+ for _, bad := range []string{"ops", "oil", "ill", "ba", "BAB", "ba1", ""} {
+ if domains.ValidSqid(bad) {
+ t.Errorf("-account-id %q must be refused — it is a name, not a sqid", bad)
+ }
+ }
+ // The specific trap the reviewer caught: release.ValidAccount would have
+ // waved "ops" straight through. If this ever stops being true, the two
+ // predicates have converged and the gate is doing nothing.
+ if !release.ValidAccount("ops") {
+ t.Error("release.ValidAccount no longer accepts \"ops\" — this gate's whole reason to exist has moved")
+ }
+}
+
func TestApiAccountExact(t *testing.T) {
acct, id := "ops", "bah"
a := apiAccount(&acct, &id)
@@ -56,6 +95,17 @@ func TestApiAccountExact(t *testing.T) {
if a.Seg != "ops" || a.Exact {
t.Errorf("apiAccount without -account-id = %+v, want {ops false}", a)
}
+ // Both flags are trimmed the way resolveAccount trims them, so a value
+ // that picked up whitespace from a shell means the same thing in either
+ // mode — API mode used to send " ops " to the console verbatim.
+ padded, id := " ops ", " bah "
+ if a := apiAccount(&padded, &id); a.Seg != "bah" || !a.Exact {
+ t.Errorf("apiAccount(padded -account-id) = %+v, want {bah true}", a)
+ }
+ id = " "
+ if a := apiAccount(&padded, &id); a.Seg != "ops" || a.Exact {
+ t.Errorf("apiAccount(padded -account, blank -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)
}
diff --git a/apimode.go b/apimode.go
index c75aaea4f..f50e08cd7 100644
--- a/apimode.go
+++ b/apimode.go
@@ -22,7 +22,6 @@ import (
"errors"
"fmt"
"io"
- "log"
"mime/multipart"
"net/http"
"net/url"
@@ -33,7 +32,6 @@ import (
"time"
"github.com/carlosframework/platform/internal/ledger"
- "github.com/carlosframework/platform/internal/release"
)
// apiSession is the resolved API mode: which console, as whom. console and
@@ -179,15 +177,16 @@ type apiAcct struct {
// 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.
+// console never name-matches it. Both flags are trimmed exactly as
+// resolveAccount trims them, so `-account " ops "` means the same thing in
+// either mode, and -account-id passes the same checkAccountID gate — a name
+// there is refused on this side of the wire, not turned into a 404 the
+// console cannot explain.
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)
- }
+ if id := checkAccountID(*rawID); id != "" {
return apiAcct{Seg: id, Exact: true}
}
- return apiAcct{Seg: *raw}
+ return apiAcct{Seg: strings.TrimSpace(*raw)}
}
// apiPath builds one /api/cli/apps/{account}/{app}/... path. The segment is
diff --git a/apimode_test.go b/apimode_test.go
index de554ea5d..6365f8fe8 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -1082,7 +1082,7 @@ func TestShipAPIStaticNamesSiteTarGz(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, siteDir)
- if err := runShipAPI(sess, acctRaw, acctIDRaw, fs, "titesite", "abc1234", "static", ""); err != nil {
+ if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", ""); err != nil {
t.Fatalf("runShipAPI: %v", err)
}
@@ -1129,7 +1129,7 @@ func TestShipAPIStaticCleansUpTempFileOnServerError(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, siteDir)
- if err := runShipAPI(sess, acctRaw, acctIDRaw, fs, "titesite", "abc1234", "static", ""); err == nil {
+ if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", ""); err == nil {
t.Fatal("runShipAPI against a failing server must error")
}
@@ -1195,7 +1195,7 @@ func TestShipAPIMultiFileGoodBasenames(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, fileArm, fileAmd)
- if err := runShipAPI(sess, acctRaw, acctIDRaw, fs, "titogo", "abc1234", "binary", "linux-arm64"); err != nil {
+ if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64"); err != nil {
t.Fatalf("runShipAPI: %v", err)
}
@@ -1237,7 +1237,7 @@ func TestShipAPIMultiFileBadBasenameRefused(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, fileGood, fileBad)
- err := runShipAPI(sess, acctRaw, acctIDRaw, fs, "titogo", "abc1234", "binary", "linux-arm64")
+ err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64")
if err == nil {
t.Fatal("runShipAPI with a bad basename must error")
}
@@ -1272,7 +1272,7 @@ func TestShipAPIMissingArtifactRefusedBeforeDialing(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, missing)
- err := runShipAPI(sess, acctRaw, acctIDRaw, fs, "titogo", "abc1234", "binary", "linux-arm64")
+ err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64")
if err == nil {
t.Fatal("runShipAPI with a missing artifact path must error")
}
diff --git a/cli.go b/cli.go
index ff004dd54..a95989041 100644
--- a/cli.go
+++ b/cli.go
@@ -277,7 +277,8 @@ func runRoute(args []string) {
fs.Parse(args)
const usage = "usage: carlos route -host <host> [-channel <channel>] [-environment <name>] [-registry <db>] [-allow-version-change] [-dry-run]\n" +
- " or: carlos route -app <app> [-channel <channel>] [-environment <name>] [-account <sqid|name|all>] [-registry <db>] [-allow-version-change] [-dry-run]"
+ " or: carlos route -app <app> [-channel <channel>] [-environment <name>] [-account <sqid|name|all>] [-account-id <sqid>] [-registry <db>] [-allow-version-change] [-dry-run]\n" +
+ " (-account-id names ONE account by sqid and supersedes -account, \"all\" included)"
if *host == "" && *app == "" {
log.Fatal(usage)
}
@@ -749,7 +750,7 @@ func routeVersionCheck(ctx context.Context, store release.Store, pub ed25519.Pub
// fs.Lookup("account") would be.
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")
+ acctID := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account; this is the flag an ambiguity error tells you to use")
regPath := fs.String("registry", defaultRegistry(), "registry database (resolves -account)")
return acct, acctID, func() string { return resolveAccount(*regPath, *acct, *acctID) }
}
@@ -833,7 +834,7 @@ func runShip(args []string) {
target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)")
fs.Parse(args)
if *app == "" || *ver == "" || fs.NArg() == 0 {
- log.Fatal("usage: carlos ship -app <a> -version <sha> [-kind binary|static] [-console <origin>] <files-or-site-dir>")
+ log.Fatal("usage: carlos ship -app <a> -version <sha> [-kind binary|static] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] <files-or-site-dir>")
}
sess, err := resolveMode(*consoleFlag)
@@ -841,7 +842,13 @@ func runShip(args []string) {
log.Fatal(err)
}
if sess != nil {
- if err := runShipAPI(sess, acctRaw, acctIDRaw, fs, *app, *ver, *kind, *target); err != nil {
+ // apiAccount is resolved HERE, not inside runShipAPI: it fatals on a
+ // name in -account-id, and log.Fatal skips every deferred function —
+ // which is the one thing runShipAPI exists to avoid (its static
+ // branch defers the cleanup of a scratch tarball up to 256MiB).
+ // Evaluated as an argument, the refusal lands before runShipAPI's
+ // body has registered a defer at all.
+ if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, *app, *ver, *kind, *target); err != nil {
log.Fatal(err)
}
return
@@ -902,9 +909,12 @@ func runShip(args []string) {
// than after paying for the whole upload and hitting the server's own 400.
var shipTargetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`)
-// runShipAPI is runShip's API-mode body: the raw -account flag value goes
-// straight to the console (never acctOf/resolveAccount — there is no local
-// registry to resolve against on a developer laptop). Unlike the direct-mode
+// runShipAPI is runShip's API-mode body: acct is the caller's -account /
+// -account-id as apiAccount read them, and goes straight to the console
+// (never acctOf/resolveAccount — there is no local registry to resolve
+// against on a developer laptop). It arrives already resolved because
+// apiAccount can fatal, and nothing that fatals may run inside this
+// function — see the deferred-cleanup note below. Unlike the direct-mode
// body above, artifacts are streamed from disk by path (apiSession.ship
// reads them lazily) rather than buffered into memory here first — for a
// static release, whose bytes only exist in memory (edge.PackDir's tarball),
@@ -916,8 +926,7 @@ var shipTargetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`)
// function — including the static kind's scratch temp file cleanup below,
// up to 256MiB per invocation. Returning lets that defer actually run on
// every exit path, fatal or not.
-func runShipAPI(sess *apiSession, acctRaw, acctIDRaw *string, fs *flag.FlagSet, app, ver, kind, target string) error {
- acct := apiAccount(acctRaw, acctIDRaw)
+func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind, target string) error {
files := map[string]string{}
if kind == "static" {
if fs.NArg() != 1 {
@@ -998,7 +1007,7 @@ func runPromote(args []string) {
consoleFlag := fs.String("console", "", "console origin to promote through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" || fs.NArg() != 2 {
- log.Fatal("usage: carlos promote -app <a> [-hotfix] [-console <origin>] <version> <channel>")
+ log.Fatal("usage: carlos promote -app <a> [-hotfix] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] <version> <channel>")
}
version, channel := fs.Arg(0), fs.Arg(1)
@@ -1066,7 +1075,7 @@ func runChannels(args []string) {
consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos channels -app <a> [-account <sqid-or-name>] [-console <origin>]")
+ log.Fatal("usage: carlos channels -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]")
}
sess, err := resolveMode(*consoleFlag)
@@ -1122,7 +1131,7 @@ func runRollback(args []string) {
consoleFlag := fs.String("console", "", "console origin to roll back through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" || fs.NArg() != 1 {
- log.Fatal("usage: carlos rollback -app <a> [-console <origin>] <channel>")
+ log.Fatal("usage: carlos rollback -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] <channel>")
}
sess, err := resolveMode(*consoleFlag)
@@ -1154,17 +1163,17 @@ func runRollback(args []string) {
const ledgerUsage = `usage: carlos ledger <command>
commands:
- append -app <a> [-account <acct>] <ledger> <entry.json|->
+ append -app <a> [-account <acct>] [-account-id <sqid>] <ledger> <entry.json|->
append one JSON entry to the chain —
reads the file, or stdin with "-";
validated as JSON before it is sent
- head -app <a> [-account <acct>] <ledger>
+ head -app <a> [-account <acct>] [-account-id <sqid>] <ledger>
print the chain's HEAD.json verbatim
- list -app <a> [-account <acct>]
+ list -app <a> [-account <acct>] [-account-id <sqid>]
list this app's ledger names and its
publish manifest (what's public, if
anything)
- publish -app <a> [-account <acct>] [-public=true|false] [-title <t>] <ledger>...
+ publish -app <a> [-account <acct>] [-account-id <sqid>] [-public=true|false] [-title <t>] <ledger>...
add these ledgers to what this app
serves publicly (additive: the ones
already public stay public, and an
@@ -1172,7 +1181,7 @@ commands:
-public=false removes just these,
taking the app private only when
nothing is left
- blob -app <a> [-account <acct>] <ledger> <file>...
+ blob -app <a> [-account <acct>] [-account-id <sqid>] <ledger> <file>...
upload one or more content-addressed
blobs (create-only; re-upload of
identical bytes is a no-op)
@@ -1262,7 +1271,7 @@ func runLedgerAppend(args []string) {
consoleFlag := fs.String("console", "", "console origin to append through (default: the only logged-in one)")
fs.Parse(args)
if *app == "" || fs.NArg() != 2 {
- log.Fatal("usage: carlos ledger append -app <a> [-account <acct>] <ledger> <entry.json|->")
+ log.Fatal("usage: carlos ledger append -app <a> [-account <acct>] [-account-id <sqid>] <ledger> <entry.json|->")
}
name, src := fs.Arg(0), fs.Arg(1)
if !ledger.ValidName(name) {
@@ -1306,7 +1315,7 @@ func runLedgerHead(args []string) {
consoleFlag := fs.String("console", "", "console origin to read through (default: the only logged-in one)")
fs.Parse(args)
if *app == "" || fs.NArg() != 1 {
- log.Fatal("usage: carlos ledger head -app <a> [-account <acct>] <ledger>")
+ log.Fatal("usage: carlos ledger head -app <a> [-account <acct>] [-account-id <sqid>] <ledger>")
}
name := fs.Arg(0)
if !ledger.ValidName(name) {
@@ -1332,7 +1341,7 @@ func runLedgerList(args []string) {
consoleFlag := fs.String("console", "", "console origin to read through (default: the only logged-in one)")
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos ledger list -app <a> [-account <acct>]")
+ log.Fatal("usage: carlos ledger list -app <a> [-account <acct>] [-account-id <sqid>]")
}
sess := ledgerAPISession(*consoleFlag)
@@ -1382,7 +1391,7 @@ func runLedgerPublish(args []string) {
consoleFlag := fs.String("console", "", "console origin to publish through (default: the only logged-in one)")
fs.Parse(args)
if *app == "" || fs.NArg() == 0 {
- log.Fatal("usage: carlos ledger publish -app <a> [-account <acct>] [-public=true|false] [-title <t>] <ledger>...")
+ log.Fatal("usage: carlos ledger publish -app <a> [-account <acct>] [-account-id <sqid>] [-public=true|false] [-title <t>] <ledger>...")
}
names := fs.Args()
for _, n := range names {
@@ -1422,7 +1431,7 @@ func runLedgerBlob(args []string) {
consoleFlag := fs.String("console", "", "console origin to upload through (default: the only logged-in one)")
fs.Parse(args)
if *app == "" || fs.NArg() < 2 {
- log.Fatal("usage: carlos ledger blob -app <a> [-account <acct>] <ledger> <file>...")
+ log.Fatal("usage: carlos ledger blob -app <a> [-account <acct>] [-account-id <sqid>] <ledger> <file>...")
}
name := fs.Arg(0)
if !ledger.ValidName(name) {
diff --git a/e2e_test.go b/e2e_test.go
index ec7c817f9..a0b65ab73 100644
--- a/e2e_test.go
+++ b/e2e_test.go
@@ -1200,6 +1200,90 @@ func TestAddUnitBackingRequiresDBForHibernate(t *testing.T) {
}
}
+// TestAddAccountIDRefusesAName pins the whole -account-id gate end to end,
+// on the verb where getting it wrong is quietest. `carlos add -account-id
+// ops` used to succeed: "ops" passes release.ValidAccount (^[a-z]{3,16}$),
+// so a bare shape check waved it through and stamped Account="ops" onto the
+// registry row — a phantom account (the real ops is Sqid(1) = "bab") that
+// every subsequent adopt and edge lookup would key off and find nothing
+// under. Exit 0, no error, nothing serving. The frozen alphabet has no i, l
+// or o, so domains.ValidSqid can refuse it for free; this test is the proof
+// the CLI actually does.
+//
+// The in-process half of this coverage is TestAccountIDShape
+// (accounts_resolve_test.go); the refusal itself is log.Fatal, so only a
+// real subprocess can observe the exit status and the absent row.
+func TestAddAccountIDRefusesAName(t *testing.T) {
+ goBin, err := exec.LookPath("go")
+ if err != nil {
+ t.Skip("go toolchain not found")
+ }
+ tmp := t.TempDir()
+ carlosBin := filepath.Join(tmp, "carlos")
+ build := exec.Command(goBin, "build", "-o", carlosBin, ".")
+ build.Env = os.Environ()
+ if out, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("build carlos: %v\n%s", err, out)
+ }
+ regPath := filepath.Join(tmp, "registry.db")
+
+ out, err := exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass",
+ "-account-id", "ops", "phantom.example.com").CombinedOutput()
+ if err == nil {
+ t.Fatalf("add -account-id ops: want non-zero exit, got 0\n%s", out)
+ }
+ // The message must send them to the right flag AND name the sqid they
+ // actually wanted — "that's not a sqid" alone leaves them guessing.
+ if !strings.Contains(string(out), "-account ops") || !strings.Contains(string(out), "bab") {
+ t.Errorf("refusal = %q, want it to name -account ops and the real sqid bab", out)
+ }
+
+ // A name that is merely sqid-SHAPED is refused the same way when it
+ // carries an i, l or o — the letters no id can ever encode to.
+ out, err = exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass",
+ "-account-id", "oil", "phantom2.example.com").CombinedOutput()
+ if err == nil {
+ t.Fatalf("add -account-id oil: want non-zero exit, got 0\n%s", out)
+ }
+ if !strings.Contains(string(out), "-account") {
+ t.Errorf("refusal = %q, want it to point at -account", out)
+ }
+
+ // A real sqid is accepted and stamped verbatim — never resolved against
+ // the registry, which is the point of the flag: this one has no accounts
+ // in it at all.
+ out, err = exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass",
+ "-account-id", "bah", "real.example.com").CombinedOutput()
+ if err != nil {
+ t.Fatalf("add -account-id bah: %v\n%s", err, out)
+ }
+
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatalf("open registry: %v", err)
+ }
+ defer reg.Close()
+ routes, err := reg.List()
+ if err != nil {
+ t.Fatalf("list routes: %v", err)
+ }
+ var seen int
+ for _, rt := range routes {
+ switch rt.Host {
+ case "phantom.example.com", "phantom2.example.com":
+ t.Errorf("refused add left a route behind (%s, account %q)", rt.Host, rt.Account)
+ case "real.example.com":
+ seen++
+ if rt.Account != "bah" {
+ t.Errorf("route account = %q, want the -account-id sqid %q", rt.Account, "bah")
+ }
+ }
+ }
+ if seen != 1 {
+ t.Errorf("found %d rows for real.example.com, want 1", seen)
+ }
+}
+
// An app still on the pre-accounts keys can be moved INTO ops. Until `-from
// legacy` existed there was no way to say it: a bare move into ops is refused
// (ops is also the default source, and the verb will not guess a direction),
diff --git a/internal/domains/sqid.go b/internal/domains/sqid.go
index 978adda17..c22a7ca95 100644
--- a/internal/domains/sqid.go
+++ b/internal/domains/sqid.go
@@ -4,6 +4,8 @@
// registry. See docs/superpowers/specs/2026-08-02-custom-domains-design.md.
package domains
+import "strings"
+
// sqidAlphabet is FROZEN FOREVER: lowercase base-23 with the lookalikes
// i, l, o removed. Every platform domain ever minted embeds a sqid over this
// alphabet — changing it (or the offset below) re-homes every one of them.
@@ -12,6 +14,12 @@ package domains
// platform domain invite squatting; custom domains are the vanity mechanism.
const sqidAlphabet = "abcdefghjkmnpqrstuvwxyz"
+// SqidAlphabet exports the frozen alphabet for one purpose: an error message
+// that tells someone which letters a sqid may contain (the CLI's -account-id
+// refusal) should read those letters from the encoder itself, so it can
+// never drift from the truth it is describing.
+const SqidAlphabet = sqidAlphabet
+
// sqidOffset guarantees a minimum length of 3: encoding id+23² in plain
// base-23 means every id ≥ 1 needs at least three digits, with no padding
// case-split (a single strictly-monotonic bijection, so distinct ids can
@@ -32,3 +40,31 @@ func Sqid(id int64) string {
}
return string(buf[i:])
}
+
+// ValidSqid reports whether s is shaped like something Sqid could have
+// emitted: 3 to 16 letters, every one of them in the frozen alphabet.
+//
+// This is STRICTER than release.ValidAccount (^[a-z]{3,16}$), and the
+// difference is the whole point. ValidAccount gates what may become a bucket
+// path segment, so it accepts any lowercase word — including account NAMES
+// like "ops" or "amadan". A name is not a sqid: the frozen alphabet omits i,
+// l and o, so "ops" is a string Sqid can never produce for any id. Anything
+// that means "this value IS an account's identity, matched as a sqid and
+// never as a name" — the CLI's -account-id, above all — must ask THIS
+// question, because accepting "ops" there resolves to an account that does
+// not exist, and writes under it without ever failing (the real ops account
+// is Sqid(1), "bab").
+//
+// The 3 floor is sqidOffset's guarantee and the 16 ceiling is Sqid's buffer;
+// both are frozen with the alphabet.
+func ValidSqid(s string) bool {
+ if len(s) < 3 || len(s) > 16 {
+ return false
+ }
+ for i := 0; i < len(s); i++ {
+ if !strings.ContainsRune(sqidAlphabet, rune(s[i])) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/domains/sqid_test.go b/internal/domains/sqid_test.go
index 77a4b8f81..78b932514 100644
--- a/internal/domains/sqid_test.go
+++ b/internal/domains/sqid_test.go
@@ -42,6 +42,62 @@ func TestSqidGolden(t *testing.T) {
}
}
+// TestValidSqid pins the predicate the CLI's -account-id gate rests on. The
+// case that matters is the i/l/o rows: release.ValidAccount (^[a-z]{3,16}$)
+// accepts every one of them, because it gates bucket path segments and every
+// account NAME is a legal segment. ValidSqid must not — "ops" passing here
+// is `carlos ship -account-id ops` writing to apps/ops/..., exiting 0, and
+// serving nothing, when the real ops account is Sqid(1) = "bab".
+func TestValidSqid(t *testing.T) {
+ for _, tc := range []struct {
+ s string
+ want bool
+ }{
+ {"bab", true}, // ops, the built-in account
+ {"zzz", true}, // never minted here, but shaped like something Sqid emits
+ {"baf", true}, //
+ {"abc", true}, // the 3-char floor sqidOffset guarantees
+ {"abcdefghjkmnpqrs", true}, // 16, Sqid's buffer ceiling
+
+ // The lookalikes the alphabet drops. Each of these passes
+ // release.ValidAccount and is therefore exactly the bug.
+ {"ops", false}, // o
+ {"oil", false}, // o, i, l
+ {"ill", false}, // i, l
+ {"kass", true}, // a real account NAME that IS sqid-shaped: see below
+ {"amadan", true},
+
+ {"ab", false}, // under the 3 floor
+ {"abcdefghjkmnpqrst", false}, // over the 16 ceiling
+ {"", false}, //
+ {"BAB", false}, // uppercase is not the alphabet
+ {"ba1", false}, // digits are not the alphabet
+ {"ba-b", false}, // nor is a hyphen
+ {"ba b", false}, // nor a space (callers must trim first)
+ } {
+ if got := domains.ValidSqid(tc.s); got != tc.want {
+ t.Errorf("ValidSqid(%q) = %v, want %v", tc.s, got, tc.want)
+ }
+ }
+
+ // The honest limit of a shape check, stated so nobody mistakes it for
+ // more: a NAME made only of alphabet letters ("kass", "amadan") is
+ // indistinguishable from a sqid by shape alone, and passes. -account-id
+ // is matched exactly against the account space, so such a value simply
+ // resolves to nothing rather than to the wrong account — the failure is
+ // a 404, not a silent write under a phantom account. It is the i/l/o
+ // names, and only those, that a shape check can catch for free.
+
+ // Every sqid the encoder can actually emit must satisfy the predicate:
+ // the two are frozen together, and this is the direction that would
+ // break first if the alphabet or the buffer ever changed.
+ for id := int64(1); id < 20000; id++ {
+ if s := domains.Sqid(id); !domains.ValidSqid(s) {
+ t.Fatalf("Sqid(%d) = %q, which ValidSqid rejects", id, s)
+ }
+ }
+}
+
// The frozen alphabet omits i, l and o, so no id encodes to "legacy" — which is
// what lets release.LegacySource use that word as a sentinel for the
// account-less key space in `carlos accounts migrate -from legacy`. If the