carlos / platform Public

accounts create: name an owner, or don't create the account

Two ways in and no way past. -owner names one outright — the box-side and
headless path, no browser, no login, and it takes precedence because it is
the explicit instruction and an operator creating an account FOR somebody
else must not have their own address quietly substituted. Otherwise the
logged-in identity is used, and the create goes THROUGH the console, which
writes the manifest and the owner membership in one path so the account is
openable by its creator the instant it exists.

With neither, it refuses and names both — because the alternative is what
happened this week: accounts minted with no owner, apps migrated into
them, and no way to reach them in the console at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell 16da3034c87ef650a861433e69708364cc19c79e parent 26d81b8
3 files changed, +304 −13
  • accounts.go +99 −10
  • auth_e2e_test.go +199 −0
  • e2e_test.go +6 −3
diff --git a/accounts.go b/accounts.go
index 717f10761..04a993c78 100644
--- a/accounts.go
+++ b/accounts.go
@@ -17,6 +17,7 @@ import (
"flag"
"fmt"
"log"
+ "net/http"
"os"
"strings"
"text/tabwriter"
@@ -32,6 +33,12 @@ const accountsUsage = `usage: carlos accounts <create|list|migrate>
create -name <name> mint an account: the next free id, its
frozen sqid, and the bucket manifest every
box materializes identity from
+ [-owner <address>] who owns it. On a box or in CI — no browser,
+ no login — this is the path: it needs no
+ identity of its own and takes precedence
+ over any logged-in one.
+ [-console <url>] which logged-in console to mint through
+ (only needed with several)
list sqid, name, and app count
migrate -app <a> -to <sqid> copy an app's objects to the account-
qualified prefix and re-stamp its routes
@@ -39,6 +46,10 @@ const accountsUsage = `usage: carlos accounts <create|list|migrate>
account-less keys)
[-dry-run] print the plan and touch nothing
+create always names an owner, one way or the other: log this terminal in with
+"carlos auth login" and you become the owner, or pass -owner. It refuses
+rather than mint an account nobody can open in the console.
+
migrate never deletes a legacy object. Deleting them is a separate operator
step, behind the verification gate: every channel pointer must resolve from
the qualified keys with the fallback disabled, and one adopt pass must be a
@@ -68,30 +79,108 @@ func runAccountsCreate(args []string) {
fs := flag.NewFlagSet("accounts create", flag.ExitOnError)
regPath := fs.String("registry", defaultRegistry(), "registry database")
name := fs.String("name", "", "account name (free text, need not be unique)")
+ owner := fs.String("owner", "", "keymail address that owns the account — the box-side/headless path, "+
+ "and what to use when this terminal is not logged in")
+ console := fs.String("console", "", "which logged-in console to create it through (only needed with several)")
fs.Parse(args)
if strings.TrimSpace(*name) == "" {
- log.Fatal("usage: carlos accounts create -name <name>")
+ log.Fatal("usage: carlos accounts create -name <name> [-owner <address>]")
}
+
+ // An ownerless account is the failure this whole path exists to prevent:
+ // it materializes into the console with no members, which means nobody can
+ // open it, invite anyone to it, or see the apps migrated into it. So there
+ // are exactly two ways to name an owner and no way to skip it.
+ //
+ // -owner WINS over the logged-in identity, deliberately: it is the explicit
+ // instruction, it is the documented headless path, and an operator creating
+ // an account FOR somebody else must not have their own address quietly
+ // substituted.
+ if addr := strings.TrimSpace(*owner); addr != "" {
+ createAccountInBucket(*regPath, *name, addr)
+ return
+ }
+ cred, credErr := loggedInCredential(*console)
+ if credErr != nil {
+ log.Fatalf("accounts create: this account would have no owner, and an ownerless account is one\n"+
+ "nobody can open in the console. Name one, either way:\n\n"+
+ " carlos auth login -console <url> log this terminal in; you become the owner\n"+
+ " carlos accounts create -owner <address> -name %q\n"+
+ " name the owner outright — the box-side and\n"+
+ " headless path, no browser or login needed\n\n"+
+ "(%v)", *name, credErr)
+ }
+ createAccountViaConsole(cred, *regPath, *name)
+}
+
+// createAccountInBucket is the original path, plus an owner: mint the id with
+// a create-only manifest write, then materialize the registry row.
+//
+// The bucket first, and not just for ordering: the manifest write IS the
+// allocator. domains.MintAccountOwned picks the next free integer and claims it
+// with a create-only Put, so this CLI and a console minting at the same moment
+// cannot land on the same id — and the registry, which only ever materializes
+// what the bucket says, never has to assign anything. The owner rides along in
+// that same manifest, which is how the console learns who to admit.
+func createAccountInBucket(regPath, name, owner string) {
store := openStore()
- // The bucket first, and not just for ordering: the manifest write IS the
- // allocator. domains.MintAccount picks the next free integer and claims it
- // with a create-only Put, so this CLI and a console minting at the same
- // moment cannot land on the same id — and the registry, which only ever
- // materializes what the bucket says, never has to assign anything.
- id, sqid, err := domains.MintAccount(context.Background(), store, *name, time.Now())
+ id, sqid, err := domains.MintAccountOwned(context.Background(), store, name, owner, time.Now())
if err != nil {
log.Fatalf("accounts create: %v", err)
}
- r, err := registry.Open(*regPath)
+ r, err := registry.Open(regPath)
if err != nil {
log.Fatal(err)
}
defer r.Close()
- if err := r.PutAccount(id, sqid, *name); err != nil {
+ if err := r.PutAccount(id, sqid, name); err != nil {
log.Fatalf("accounts create: materialize: %v", err)
}
- fmt.Printf("created account %q\n id: %d\n sqid: %s\n", *name, id, sqid)
+ fmt.Printf("created account %q\n id: %d\n sqid: %s\n owner: %s\n", name, id, sqid, owner)
fmt.Printf("use it with: carlos ship -account %s -app <app> ...\n", sqid)
+ fmt.Printf("%s becomes its owner in the console the next time the console materializes\n"+
+ "accounts from the bucket (on start, or on any `console member` command).\n", owner)
+}
+
+// createAccountViaConsole mints through the console this terminal is logged in
+// to, which makes the logged-in human the owner in one step.
+//
+// Through the console rather than straight into the bucket because the console
+// is the identity authority: it writes the manifest AND the owner membership in
+// its own database in one path, so the account is openable by its creator the
+// instant it exists — rather than "once something materializes it", which is the
+// gap that produced accounts nobody could reach.
+func createAccountViaConsole(cred *credential, regPath, name string) {
+ c := newConsoleClient(cred.Console, cred.Token)
+ var out struct {
+ ID int64 `json:"id"`
+ Sqid string `json:"sqid"`
+ Name string `json:"name"`
+ Owner string `json:"owner"`
+ URL string `json:"url"`
+ }
+ if _, err := c.do(context.Background(), http.MethodPost, "/auth/cli/accounts",
+ map[string]string{"name": name}, &out); err != nil {
+ log.Fatalf("accounts create: %s: %v", cred.Console, err)
+ }
+ fmt.Printf("created account %q\n id: %d\n sqid: %s\n owner: %s (you)\n", out.Name, out.ID, out.Sqid, out.Owner)
+ fmt.Printf(" open: %s\n", out.URL)
+ fmt.Printf("use it with: carlos ship -account %s -app <app> ...\n", out.Sqid)
+ // The registry row is this box's local materialization of what the bucket
+ // already says. Best-effort, unlike the bucket-mint path: the account
+ // exists and is owned no matter what happens here, and a build machine
+ // with no registry at all is a perfectly normal place to run this.
+ r, err := registry.Open(regPath)
+ if err != nil {
+ fmt.Printf("\n(no registry at %s, so nothing was materialized locally: %v —\n"+
+ "harmless: the bucket is the control plane and every box reconciles from it.)\n", regPath, err)
+ return
+ }
+ defer r.Close()
+ if err := r.PutAccount(out.ID, out.Sqid, out.Name); err != nil {
+ fmt.Printf("\n(could not materialize the registry row here: %v — harmless: the account\n"+
+ "exists in the bucket and every box reconciles from it.)\n", err)
+ }
}
func runAccountsList(args []string) {
diff --git a/auth_e2e_test.go b/auth_e2e_test.go
new file mode 100644
index 000000000..2fadb189f
--- /dev/null
+++ b/auth_e2e_test.go
@@ -0,0 +1,199 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// buildCarlos builds the real binary, the way TestStatusCLI does, so these
+// tests exercise the actual command line an operator types rather than the
+// functions underneath it.
+func buildCarlos(t *testing.T) string {
+ t.Helper()
+ goBin, err := exec.LookPath("go")
+ if err != nil {
+ t.Skip("go toolchain not found; skipping CLI subprocess test")
+ }
+ bin := filepath.Join(t.TempDir(), "carlos")
+ build := exec.Command(goBin, "build", "-o", bin, ".")
+ build.Env = os.Environ()
+ if out, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("build carlos: %v\n%s", err, out)
+ }
+ return bin
+}
+
+// TestAuthCLI drives `carlos auth` as a subprocess against a fake console:
+// login stores a credential the file system can be checked for, whoami reads
+// it back, and logout revokes it.
+func TestAuthCLI(t *testing.T) {
+ bin := buildCarlos(t)
+ fc := newFakeConsole(t)
+ home := t.TempDir()
+ creds := filepath.Join(home, ".carlos", "credentials")
+ env := append(os.Environ(), "CARLOS_CREDENTIALS="+creds)
+
+ run := func(args ...string) (string, error) {
+ cmd := exec.Command(bin, args...)
+ cmd.Env = env
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ // A login with no console at all refuses, and names both ways out.
+ out, err := run("auth", "login")
+ if err == nil {
+ t.Fatalf("carlos auth login with no -console exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "-console") || !strings.Contains(out, "-owner") {
+ t.Fatalf("refusal does not name both options:\n%s", out)
+ }
+
+ fc.approved.Store(true)
+ out, err = run("auth", "login", "-console", fc.ts.URL, "-label", "e2e@box")
+ if err != nil {
+ t.Fatalf("carlos auth login: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "WXYZ-4823") || !strings.Contains(out, "paul@keymail.dev") {
+ t.Fatalf("login output:\n%s", out)
+ }
+ if strings.Contains(out, "cli-token") {
+ t.Fatalf("login printed the bearer token:\n%s", out)
+ }
+ fi, err := os.Stat(creds)
+ if err != nil {
+ t.Fatalf("credentials file: %v", err)
+ }
+ if fi.Mode().Perm() != 0o600 {
+ t.Fatalf("credentials mode = %v, want 0600", fi.Mode().Perm())
+ }
+
+ // whoami with no -console picks the only credential there is.
+ out, err = run("auth", "whoami")
+ if err != nil {
+ t.Fatalf("carlos auth whoami: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "paul@keymail.dev") {
+ t.Fatalf("whoami output:\n%s", out)
+ }
+
+ out, err = run("auth", "logout")
+ if err != nil {
+ t.Fatalf("carlos auth logout: %v\n%s", err, out)
+ }
+ if !fc.revoked.Load() {
+ t.Fatal("logout did not revoke at the console")
+ }
+ if out, err := run("auth", "whoami"); err == nil {
+ t.Fatalf("whoami after logout exited 0:\n%s", out)
+ }
+}
+
+// TestAccountsCreateNeedsAnOwner is the regression this whole feature exists
+// for: an account with no owner is one nobody can open in the console, so the
+// CLI refuses to mint one and names both ways to fix it.
+func TestAccountsCreateNeedsAnOwner(t *testing.T) {
+ bin := buildCarlos(t)
+ tmp := t.TempDir()
+ env := append(os.Environ(),
+ "CARLOS_DEPLOYMENT_DIR="+filepath.Join(tmp, "store"),
+ "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"),
+ "CARLOS_DATA_DIR="+tmp,
+ )
+ cmd := exec.Command(bin, "accounts", "create", "-name", "Orphan")
+ cmd.Env = env
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ t.Fatalf("ownerless accounts create exited 0:\n%s", out)
+ }
+ for _, want := range []string{"carlos auth login", "-owner"} {
+ if !strings.Contains(string(out), want) {
+ t.Fatalf("refusal does not name %q:\n%s", want, out)
+ }
+ }
+ // Nothing was minted: a refusal must not leave a half-made account behind.
+ if keys, err := release.NewDirStore(filepath.Join(tmp, "store")).List(t.Context(), "accounts/"); err != nil || len(keys) != 0 {
+ t.Fatalf("a refused create left %v behind (%v)", keys, err)
+ }
+}
+
+// The headless path: -owner names the owner outright, no login, no browser,
+// and the address lands in the manifest every box materializes identity from.
+func TestAccountsCreateWithOwnerFlag(t *testing.T) {
+ bin := buildCarlos(t)
+ tmp := t.TempDir()
+ storeDir := filepath.Join(tmp, "store")
+ cmd := exec.Command(bin, "accounts", "create", "-name", "Kass", "-owner", "paul@keymail.dev")
+ cmd.Env = append(os.Environ(),
+ "CARLOS_DEPLOYMENT_DIR="+storeDir,
+ "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"),
+ "CARLOS_DATA_DIR="+tmp,
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("carlos accounts create -owner: %v\n%s", err, out)
+ }
+ if !strings.Contains(string(out), "paul@keymail.dev") {
+ t.Fatalf("create output does not name the owner:\n%s", out)
+ }
+ store := release.NewDirStore(storeDir)
+ keys, err := store.List(t.Context(), "accounts/")
+ if err != nil || len(keys) != 1 {
+ t.Fatalf("account manifests = %v (%v), want exactly one", keys, err)
+ }
+ body, _, err := store.Get(t.Context(), keys[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m domains.AccountManifest
+ if err := json.Unmarshal(body, &m); err != nil {
+ t.Fatal(err)
+ }
+ if m.Owner != "paul@keymail.dev" || m.Name != "Kass" {
+ t.Fatalf("manifest = %+v, want Kass owned by paul", m)
+ }
+}
+
+// Logged in, no -owner: the account is created THROUGH the console, so the
+// person at the terminal is its owner from the first instant.
+func TestAccountsCreateUsesTheLoggedInIdentity(t *testing.T) {
+ bin := buildCarlos(t)
+ fc := newFakeConsole(t)
+ tmp := t.TempDir()
+ creds := filepath.Join(tmp, "credentials")
+ f := &credentialFile{Version: 1, Credentials: []credential{{
+ Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
+ CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().Add(24 * time.Hour).UTC(),
+ }}}
+ raw, err := json.Marshal(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(creds, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command(bin, "accounts", "create", "-name", "Kass")
+ cmd.Env = append(os.Environ(),
+ "CARLOS_CREDENTIALS="+creds,
+ "CARLOS_DATA_DIR="+tmp,
+ // Deliberately no CARLOS_DEPLOYMENT_DIR: the console owns the bucket
+ // write on this path, and a laptop has no deployment store configured.
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("carlos accounts create (logged in): %v\n%s", err, out)
+ }
+ for _, want := range []string{"bag", "paul@keymail.dev", "(you)"} {
+ if !strings.Contains(string(out), want) {
+ t.Fatalf("create output missing %q:\n%s", want, out)
+ }
+ }
+}
diff --git a/e2e_test.go b/e2e_test.go
index f74431d1e..5df884c11 100644
--- a/e2e_test.go
+++ b/e2e_test.go
@@ -697,7 +697,10 @@ func TestAccountsCLI(t *testing.T) {
reg.Close()
// create: a minted id, its frozen sqid printed for the operator to use.
- out := run("accounts", "create", "-registry", regPath, "-name", "Kass")
+ // -owner is not optional decoration — an account with no owner is one
+ // nobody can open in the console, so create refuses without either a login
+ // or this flag. This is the headless form of it (see auth.go).
+ out := run("accounts", "create", "-registry", regPath, "-name", "Kass", "-owner", "paul@keymail.dev")
m := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(out)
if m == nil {
t.Fatalf("accounts create printed no sqid:\n%s", out)
@@ -896,7 +899,7 @@ func TestAccountsMigrateTakesTheLiveObjects(t *testing.T) {
}
reg.Close()
- out, err := run("accounts", "create", "-registry", regPath, "-name", "Kass")
+ out, err := run("accounts", "create", "-registry", regPath, "-name", "Kass", "-owner", "paul@keymail.dev")
if err != nil {
t.Fatalf("accounts create: %v\n%s", err, out)
}
@@ -1005,7 +1008,7 @@ func TestAccountsMigrateConvergesADivergedPointer(t *testing.T) {
}
reg.Close()
- out, err := run("accounts", "create", "-registry", regPath, "-name", "Kass")
+ out, err := run("accounts", "create", "-registry", regPath, "-name", "Kass", "-owner", "paul@keymail.dev")
if err != nil {
t.Fatalf("accounts create: %v\n%s", err, out)
}