merge: membership tooling + sequential account ids
Closes the capability gap found deploying multi-account: there was no way to add or remove a membership outside the invite flow, which is how a grant holder ended up with production access and had to be removed by hand. Adds the console 'member' verb, console-side materialization of bucket accounts, a backfill that reports grant holders instead of sweeping them into ops, and sequential account ids (nine-character sqids were unusable as URLs).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
19 files changed,
+1581
−269
README.md+9 −7accounts.go+9 −49cmd/console/main.go+25 −1cmd/console/main_test.go+62 −18cmd/console/member.go+213 −0cmd/console/member_test.go+336 −0docs/superpowers/specs/2026-08-02-multi-account-design.md+9 −4e2e_test.go+5 −2internal/console/account.go+10 −42internal/console/account_test.go+146 −58internal/console/materialize.go+104 −0internal/console/materialize_test.go+142 −0internal/console/server.go+49 −19internal/console/server_account_test.go+6 −6internal/console/store.go+138 −60internal/console/store_test.go+11 −0internal/domains/mint.go+139 −0internal/domains/mint_test.go+159 −0internal/domains/reconcile.go+9 −3
diff --git a/README.md b/README.md| index d31ca528a..4f5dd8f05 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -29,13 +29,15 @@ has members. Kass, Keymail and Woodstar each get their own rather than sharing |
| one flat list, and a self-hosted single-app deployment can give team A and |
| team B separate accounts that deploy independently. |
| -An account is `{id, sqid, name}`. The **id** is a random 40-bit integer minted |
| -by the console at creation and written to `accounts/<sqid>/account.json` in |
| -the deployment bucket; the registry only ever *materializes* what the bucket |
| -says, never assigns. That is what makes account identity survive a registry |
| -rebuild and be identical on every box — and, because the ids are random, |
| -sqids are not enumerable: you cannot walk `bab`, `bac`, `bad` to discover who |
| -else is on a deployment. The **sqid** is the frozen encoder over that id and |
| +An account is `{id, sqid, name}`. The **id** is the next free integer, |
| +allocated at creation by a create-only write of `accounts/<sqid>/account.json` |
| +in the deployment bucket; the registry only ever *materializes* what the |
| +bucket says, never assigns. That is what makes account identity survive a |
| +registry rebuild and be identical on every box: minted once, recorded in the |
| +bucket, never reassigned. Sequential ids keep sqids short enough to live in a |
| +hostname you can read out (`kass.bac.oncarlos.com`); they are not secret, and |
| +never were — every platform hostname's certificate is published in |
| +Certificate Transparency. The **sqid** is the frozen encoder over that id and |
| is the account's public identity, in every platform domain and every bucket |
| key. The **name** is free text and deliberately not unique: two accounts may |
| both be called "Blog", because identity is the sqid and there is nothing to |
diff --git a/accounts.go b/accounts.go| index 1f9b26ead..717f10761 100644 |
| --- a/accounts.go |
| +++ b/accounts.go |
| @@ -13,8 +13,6 @@ package main |
| import ( |
| "context" |
| "crypto/ed25519" |
| - "crypto/rand" |
| - "encoding/binary" |
| "errors" |
| "flag" |
| "fmt" |
| @@ -31,7 +29,7 @@ import ( |
| const accountsUsage = `usage: carlos accounts <create|list|migrate> |
| - create -name <name> mint an account: a random 40-bit id, its |
| + create -name <name> mint an account: the next free id, its |
| frozen sqid, and the bucket manifest every |
| box materializes identity from |
| list sqid, name, and app count |
| @@ -66,24 +64,6 @@ func RunAccounts(args []string) { |
| } |
| } |
| -// newAccountID mints an account id the same way the console does: 40 random |
| -// bits, never 0 or 1 (id 1 is the built-in ops account, pinned by registry |
| -// migration). Duplicated rather than shared because internal/console is the |
| -// console's own package and the CLI must not depend on it — the RULE, not the |
| -// code, is what has to match, and both are pinned by tests. |
| -func newAccountID() int64 { |
| - for { |
| - var b [8]byte |
| - if _, err := rand.Read(b[:]); err != nil { |
| - log.Fatalf("accounts: read randomness: %v", err) |
| - } |
| - id := int64(binary.BigEndian.Uint64(b[:]) & ((1 << 40) - 1)) |
| - if id > 1 { |
| - return id |
| - } |
| - } |
| -} |
| - |
| func runAccountsCreate(args []string) { |
| fs := flag.NewFlagSet("accounts create", flag.ExitOnError) |
| regPath := fs.String("registry", defaultRegistry(), "registry database") |
| @@ -92,14 +72,15 @@ func runAccountsCreate(args []string) { |
| if strings.TrimSpace(*name) == "" { |
| log.Fatal("usage: carlos accounts create -name <name>") |
| } |
| - id := newAccountID() |
| - sqid := domains.Sqid(id) |
| store := openStore() |
| - // The bucket first: identity lives there, and the registry only ever |
| - // materializes it. A registry row whose manifest never landed would be an |
| - // account that vanishes on the next rebuild. |
| - if err := writeAccountManifest(context.Background(), store, id, sqid, *name, time.Now()); err != nil { |
| - log.Fatalf("accounts create: write manifest: %v", err) |
| + // 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()) |
| + if err != nil { |
| + log.Fatalf("accounts create: %v", err) |
| } |
| r, err := registry.Open(*regPath) |
| if err != nil { |
| @@ -113,27 +94,6 @@ func runAccountsCreate(args []string) { |
| fmt.Printf("use it with: carlos ship -account %s -app <app> ...\n", sqid) |
| } |
| -// writeAccountManifest publishes an account's identity into the bucket at |
| -// accounts/<sqid>/account.json. Overwrite-safe (read etag, put, one retry), |
| -// the same shape the console's writer uses. |
| -func writeAccountManifest(ctx context.Context, s release.Store, id int64, sqid, name string, created time.Time) error { |
| - body := []byte(fmt.Sprintf(`{"id":%d,"sqid":%q,"name":%q,"created_at":%q}`, |
| - id, sqid, name, created.UTC().Format(time.RFC3339))) |
| - key := "accounts/" + sqid + "/account.json" |
| - put := func() error { |
| - etag := "" |
| - if _, cur, err := s.Get(ctx, key); err == nil { |
| - etag = cur |
| - } |
| - _, err := s.Put(ctx, key, body, etag) |
| - return err |
| - } |
| - if err := put(); err != nil { |
| - return put() // one retry on a lost race; a second loss surfaces |
| - } |
| - return nil |
| -} |
| - |
| func runAccountsList(args []string) { |
| fs := flag.NewFlagSet("accounts list", flag.ExitOnError) |
| regPath := fs.String("registry", defaultRegistry(), "registry database") |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index ed0e2280a..3f771b83c 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -13,6 +13,13 @@ |
| // console grant -operator <address> make address a fleet operator |
| // console revoke -operator <address> remove that operator role |
| // console owners list every owner and their apps |
| +// console member -add <address> <account-sqid> [-role owner|member] |
| +// console member -remove <address> <account-sqid> |
| +// console member -list <account-sqid> |
| +// membership IS authorization in the |
| +// accounts model — a member can deploy |
| +// every app in the account — so adding |
| +// and REMOVING one is box-side authority |
| // console migrate-app -app <a> -to <sqid> |
| // re-home an app onto another account — |
| // the console half of |
| @@ -79,6 +86,8 @@ func main() { |
| if err != nil { |
| log.Fatal(err) |
| } |
| + case "member": |
| + runMember(os.Args[2:]) |
| case "migrate-app": |
| migrateApp(os.Args[2:]) |
| case "owners": |
| @@ -101,7 +110,9 @@ func main() { |
| } |
| func usage() { |
| - log.Fatal("usage: console serve | grant [-operator] <address> [<app>] | revoke [-operator] <address> [<app>] | owners | migrate-app -app <app> -to <sqid> [-from <sqid>]") |
| + log.Fatal("usage: console serve | grant [-operator] <address> [<app>] | revoke [-operator] <address> [<app>] | owners | " + |
| + "member -add|-remove <address> <account-sqid> [-role owner|member] | member -list <account-sqid> | " + |
| + "migrate-app -app <app> -to <sqid> [-from <sqid>]") |
| } |
| // migrateApp completes the second half of `carlos accounts migrate`: that |
| @@ -220,6 +231,19 @@ func openConsoleDB() *console.Store { |
| return st |
| } |
| +// optionalStore is openStore for the verbs that would LIKE the deployment |
| +// bucket but must work without it. `console member` reads account manifests |
| +// out of it to catch up on accounts minted by `carlos accounts create`; a box |
| +// with neither variable set is a legal deployment (grant/revoke/owners have |
| +// always needed only CONSOLE_DB), and refusing to run there would take away |
| +// the membership tooling on exactly the boxes least able to spare it. |
| +func optionalStore() release.Store { |
| + if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") == "" && os.Getenv("CARLOS_DEPLOYMENT_DIR") == "" { |
| + return nil |
| + } |
| + return openStore() |
| +} |
| + |
| // openStore mirrors the carlos CLI's ~10-line store-open helper (cli.go); |
| // duplicated here because cmd/console cannot import package main. |
| func openStore() release.Store { |
diff --git a/cmd/console/main_test.go b/cmd/console/main_test.go| index 706d5d22c..fb1c6ae87 100644 |
| --- a/cmd/console/main_test.go |
| +++ b/cmd/console/main_test.go |
| @@ -5,38 +5,82 @@ import ( |
| "os/exec" |
| "path/filepath" |
| "strings" |
| + "sync" |
| "testing" |
| "time" |
| "github.com/carlosframework/platform/internal/console" |
| ) |
| +// buildConsole compiles the console binary ONCE per test run and hands every |
| +// test the same path: these are end-to-end verb tests (the flag parsing and |
| +// the exit codes are half of what is under test, and neither is reachable from |
| +// a unit test), and a `go build` per test is the whole cost of the package. |
| +var ( |
| + buildOnce sync.Once |
| + consoleBin string |
| + buildDir string |
| + buildErr error |
| +) |
| + |
| +func buildConsole(t *testing.T) string { |
| + t.Helper() |
| + buildOnce.Do(func() { |
| + goBin, err := exec.LookPath("go") |
| + if err != nil { |
| + buildErr = err |
| + return |
| + } |
| + buildDir, buildErr = os.MkdirTemp("", "console-bin") |
| + if buildErr != nil { |
| + return |
| + } |
| + consoleBin = filepath.Join(buildDir, "console") |
| + build := exec.Command(goBin, "build", "-o", consoleBin, ".") |
| + build.Env = os.Environ() |
| + if out, err := build.CombinedOutput(); err != nil { |
| + buildErr = err |
| + t.Logf("build console: %s", out) |
| + } |
| + }) |
| + if buildErr != nil { |
| + t.Skipf("cannot build the console binary: %v", buildErr) |
| + } |
| + return consoleBin |
| +} |
| + |
| +// runner returns a "run these args against the built binary" helper with env |
| +// pinned — combined output plus the error, because a verb's refusal is as |
| +// much under test as its success. |
| +func runner(t *testing.T, bin string, env ...string) func(args ...string) (string, error) { |
| + full := append(os.Environ(), env...) |
| + return func(args ...string) (string, error) { |
| + t.Helper() |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = full |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| + } |
| +} |
| + |
| +func TestMain(m *testing.M) { |
| + code := m.Run() |
| + if buildDir != "" { |
| + os.RemoveAll(buildDir) |
| + } |
| + os.Exit(code) |
| +} |
| + |
| // migrate-app is the CONSOLE half of `carlos accounts migrate`. It lives here |
| // rather than in the carlos binary on purpose: the console's app→account row |
| // is console state, and the carlos CLI has no business opening the console's |
| // database — box-side authority changes go through this binary, exactly like |
| // `console grant`. |
| func TestMigrateAppVerb(t *testing.T) { |
| - goBin, err := exec.LookPath("go") |
| - if err != nil { |
| - t.Skip("go toolchain not found") |
| - } |
| + bin := buildConsole(t) |
| tmp := t.TempDir() |
| - bin := filepath.Join(tmp, "console") |
| - build := exec.Command(goBin, "build", "-o", bin, ".") |
| - build.Env = os.Environ() |
| - if out, err := build.CombinedOutput(); err != nil { |
| - t.Fatalf("build console: %v\n%s", err, out) |
| - } |
| dbPath := filepath.Join(tmp, "console.db") |
| - env := append(os.Environ(), "CONSOLE_DB="+dbPath) |
| - run := func(args ...string) (string, error) { |
| - t.Helper() |
| - cmd := exec.Command(bin, args...) |
| - cmd.Env = env |
| - out, err := cmd.CombinedOutput() |
| - return string(out), err |
| - } |
| + run := runner(t, bin, "CONSOLE_DB="+dbPath) |
| // A console in the shape a deployment is in before the migration: ops |
| // owns kass. |
diff --git a/cmd/console/member.go b/cmd/console/member.go| new file mode 100644 |
| index 000000000..3ab9fdec3 |
| --- /dev/null |
| +++ b/cmd/console/member.go |
| @@ -0,0 +1,213 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "os" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/console" |
| +) |
| + |
| +// memberUsage is spelled out rather than left to flag.PrintDefaults because |
| +// the ORDER matters to a reader: mode first, then who, then where. |
| +const memberUsage = `usage: console member -add <address> <account-sqid> [-role owner|member] |
| + console member -remove <address> <account-sqid> |
| + console member -list <account-sqid>` |
| + |
| +// runMember is the box-side membership tool — `console grant`'s replacement in |
| +// the accounts world, and the answer to a gap that bit production. |
| +// |
| +// Why it has to exist: authorization is "you are a member of the account AND |
| +// the account claims the app". A membership is therefore access to EVERY app |
| +// in that account, not to one app. When the pre-accounts backfill swept |
| +// per-app grant holders into ops, someone holding a single grant for a toy app |
| +// silently acquired promote/rollback over kass, keymail and the console. The |
| +// only fix available at the time was hand-edited SQL against a live database, |
| +// because grant/revoke still spoke the language of the model that had just |
| +// been replaced. This verb is that missing authority. |
| +// |
| +// Box-side exactly like grant/revoke: it needs no session, no browser and no |
| +// signed-in operator, because it is the tool you reach for when the console's |
| +// own authorization is what is wrong. |
| +func runMember(args []string) { |
| + fs := flag.NewFlagSet("member", flag.ExitOnError) |
| + add := fs.Bool("add", false, "add (or re-role) a member") |
| + remove := fs.Bool("remove", false, "remove a member") |
| + list := fs.Bool("list", false, "list an account's members") |
| + // Defaulting to member, never owner: the safe role is the one you get by |
| + // forgetting the flag. An owner can invite, remove members and delete the |
| + // account, so it is always a deliberate word on the command line. |
| + role := fs.String("role", console.RoleMember, "role for -add: owner or member") |
| + pos := parseInterspersed(fs, args) |
| + |
| + modes := 0 |
| + for _, on := range []bool{*add, *remove, *list} { |
| + if on { |
| + modes++ |
| + } |
| + } |
| + if modes != 1 { |
| + log.Fatal(memberUsage) |
| + } |
| + if *role != console.RoleOwner && *role != console.RoleMember { |
| + log.Fatalf("unknown role %q — there are exactly two: owner or member", *role) |
| + } |
| + |
| + var address, sqid string |
| + switch { |
| + case *list: |
| + if len(pos) != 1 { |
| + log.Fatal(memberUsage) |
| + } |
| + sqid = strings.TrimSpace(pos[0]) |
| + default: |
| + if len(pos) != 2 { |
| + log.Fatal(memberUsage) |
| + } |
| + typed := pos[0] |
| + sqid = strings.TrimSpace(pos[1]) |
| + // Lowercased and trimmed before ANY store call — every other address |
| + // path in the console does the same (sign-in, invite), and the |
| + // memberships primary key is literal text with no COLLATE NOCASE, so |
| + // case is the difference between a row and no row. Two ways an |
| + // unnormalized address here goes wrong, both silent: |
| + // |
| + // 1. `member -add James@Keymail.dev bab` would report "added as |
| + // owner" while writing a row the real session — which signs in as |
| + // james@keymail.dev — can never match. The operator walks away |
| + // believing access was restored when it was not, which is the |
| + // worst possible failure for a tool whose whole job is fixing |
| + // authorization. |
| + // 2. A case-typo'd second owner row would inflate the store's |
| + // last-owner count, letting `member -remove` take away the only |
| + // owner anybody can actually sign in as while the guard reports |
| + // another still exists. |
| + address = strings.ToLower(strings.TrimSpace(typed)) |
| + // Same shape check the sign-in form makes: a membership for an address |
| + // keymail could never authenticate is a grant nobody can claim, and a |
| + // silent typo here is a member who "was added" and cannot get in. |
| + if !console.ValidAddress(address) { |
| + log.Fatalf("%q is not a keymail address — expected <user>@<host>, e.g. paul@keymail.dev", typed) |
| + } |
| + } |
| + |
| + st := openConsoleDB() |
| + defer st.Close() |
| + // The bucket is the control plane and the console is not its only minter: |
| + // `carlos accounts create` writes the manifest and the registry row without |
| + // ever opening this database. Catch up BEFORE deciding a sqid does not |
| + // exist, or this verb refuses to administer accounts that demonstrably do. |
| + if _, err := console.MaterializeAccounts(context.Background(), optionalStore(), st); err != nil { |
| + // Not fatal: an unreachable bucket must never be the thing that stops |
| + // an operator removing a membership. |
| + log.Printf("warning: could not read accounts from the deployment bucket: %v", err) |
| + } |
| + |
| + acct, err := st.AccountBySqid(sqid) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if acct == nil { |
| + log.Fatalf("no account %q: nothing with that sqid in %s, and no %s in the deployment bucket — "+ |
| + "create one with: carlos accounts create -name <name>", |
| + sqid, envOr("CONSOLE_DB", "/data/console/console.db"), console.AccountManifestKey(sqid)) |
| + } |
| + |
| + switch { |
| + case *list: |
| + memberList(st, acct) |
| + case *add: |
| + memberAdd(st, acct, address, *role) |
| + case *remove: |
| + memberRemove(st, acct, address) |
| + } |
| +} |
| + |
| +func memberList(st *console.Store, acct *console.Account) { |
| + members, err := st.Members(acct.ID) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if len(members) == 0 { |
| + // Not an error: an account materialized from the bucket legitimately |
| + // has no members here yet, and saying so is how the operator learns |
| + // this verb is what gives it one. |
| + fmt.Printf("%s (%s) has no members\n", acct.Sqid, acct.Name) |
| + return |
| + } |
| + for _, m := range members { |
| + fmt.Printf("%s\t%s\n", m.Name, m.Role) // Members carries the address in Name |
| + } |
| +} |
| + |
| +// memberAdd is idempotent and SAYS which of the three things it did. An |
| +// operator repeating a command has to be able to tell "already done" from |
| +// "just done" — silence reads as success either way, and that is how a |
| +// membership gets added twice with two different roles. |
| +func memberAdd(st *console.Store, acct *console.Account, address, role string) { |
| + current, err := st.Role(acct.ID, address) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if current == role { |
| + fmt.Printf("%s is already a %s of %s (%s) — nothing to do\n", address, role, acct.Sqid, acct.Name) |
| + return |
| + } |
| + // The last-owner guard lives in the store, inside the transaction that |
| + // does the write — the count and the write cannot be separated by a |
| + // concurrent second demotion. Surfaced here verbatim rather than |
| + // re-checked: two copies of a safety rule is one copy too many. |
| + if err := st.AddMember(acct.ID, address, role, time.Now()); err != nil { |
| + log.Fatal(err) |
| + } |
| + if current == "" { |
| + fmt.Printf("added %s to %s (%s) as %s\n", address, acct.Sqid, acct.Name, role) |
| + return |
| + } |
| + fmt.Printf("changed %s in %s (%s) from %s to %s\n", address, acct.Sqid, acct.Name, current, role) |
| +} |
| + |
| +// memberRemove is the verb the flagship needed and did not have. Removing |
| +// somebody who is not a member is a no-op that says so rather than an error: |
| +// the operator's intent — "they must not be in this account" — is already |
| +// satisfied, and failing would make a cleanup script stop halfway. |
| +func memberRemove(st *console.Store, acct *console.Account, address string) { |
| + current, err := st.Role(acct.ID, address) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if current == "" { |
| + fmt.Printf("%s is not a member of %s (%s) — nothing to do\n", address, acct.Sqid, acct.Name) |
| + return |
| + } |
| + if err := st.RemoveMember(acct.ID, address); err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Printf("removed %s (%s) from %s (%s)\n", address, current, acct.Sqid, acct.Name) |
| +} |
| + |
| +// parseInterspersed parses flags that may appear AFTER positional arguments. |
| +// Go's flag package stops at the first non-flag word, which would make |
| +// `console member -add alice@keymail.dev bab -role owner` silently ignore the |
| +// role — the one flag whose omission matters most. Re-parsing what is left |
| +// after each positional accepts both orders instead of quietly doing the |
| +// wrong thing. |
| +func parseInterspersed(fs *flag.FlagSet, args []string) []string { |
| + var pos []string |
| + rest := args |
| + for { |
| + if err := fs.Parse(rest); err != nil { |
| + os.Exit(2) // ExitOnError already printed; unreachable in practice |
| + } |
| + rest = fs.Args() |
| + if len(rest) == 0 { |
| + return pos |
| + } |
| + pos = append(pos, rest[0]) |
| + rest = rest[1:] |
| + } |
| +} |
diff --git a/cmd/console/member_test.go b/cmd/console/member_test.go| new file mode 100644 |
| index 000000000..31c9f8663 |
| --- /dev/null |
| +++ b/cmd/console/member_test.go |
| @@ -0,0 +1,336 @@ |
| +package main_test |
| + |
| +import ( |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// `console member` is the box-side membership tool, and it exists because |
| +// there was none. Authorization is "member of the account AND the account has |
| +// the app", so a membership is access to EVERY app in the account — on the |
| +// flagship, a backfilled membership handed someone who held one grant for a |
| +// toy app the right to promote and roll back kass, keymail and the console |
| +// itself. Removing it took hand-edited SQL, because grant/revoke only ever |
| +// spoke the pre-accounts language. This verb is the missing half. |
| +func TestMemberVerb(t *testing.T) { |
| + bin := buildConsole(t) |
| + tmp := t.TempDir() |
| + dbPath := filepath.Join(tmp, "console.db") |
| + run := runner(t, bin, "CONSOLE_DB="+dbPath) |
| + |
| + // Two accounts: ops with two owners (so one can be removed), and a |
| + // single-owner account to prove the last-owner guard. |
| + st, err := console.Open(dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Now() |
| + if err := st.CreateAccount(console.OpsAccountID, "bab", "ops", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.CreateAccount(42, "cdef", "Kass", "solo@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + st.Close() |
| + |
| + // -list shows who is in an account and with what role. |
| + out, err := run("member", "-list", "bab") |
| + if err != nil { |
| + t.Fatalf("member -list: %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, "paul@keymail.dev") || !strings.Contains(out, "owner") { |
| + t.Errorf("member -list does not show the owner: %s", out) |
| + } |
| + |
| + // -add defaults to member: the safe role. An owner is a deliberate flag. |
| + if out, err := run("member", "-add", "dev@keymail.dev", "bab"); err != nil { |
| + t.Fatalf("member -add: %v\n%s", err, out) |
| + } else if !strings.Contains(out, "member") { |
| + t.Errorf("member -add does not report the role it used: %s", out) |
| + } |
| + if role := roleOf(t, dbPath, console.OpsAccountID, "dev@keymail.dev"); role != console.RoleMember { |
| + t.Fatalf("role after -add = %q, want member", role) |
| + } |
| + |
| + // Idempotent, and it SAYS so — an operator repeating a command must be |
| + // able to tell "already done" from "just done". |
| + out, err = run("member", "-add", "dev@keymail.dev", "bab") |
| + if err != nil { |
| + t.Fatalf("re-adding must be a no-op: %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, "already") { |
| + t.Errorf("re-add output does not say it was already done: %s", out) |
| + } |
| + |
| + // Adding an existing member with a different role RE-ROLES them, and says |
| + // that is what happened rather than silently doing nothing. |
| + out, err = run("member", "-add", "dev@keymail.dev", "bab", "-role", "owner") |
| + if err != nil { |
| + t.Fatalf("re-role: %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, "owner") || strings.Contains(out, "already") { |
| + t.Errorf("re-role output does not report the change: %s", out) |
| + } |
| + if role := roleOf(t, dbPath, console.OpsAccountID, "dev@keymail.dev"); role != console.RoleOwner { |
| + t.Fatalf("role after re-role = %q, want owner", role) |
| + } |
| + |
| + // The removal the flagship needed and did not have. |
| + if out, err := run("member", "-remove", "dev@keymail.dev", "bab"); err != nil { |
| + t.Fatalf("member -remove: %v\n%s", err, out) |
| + } |
| + if role := roleOf(t, dbPath, console.OpsAccountID, "dev@keymail.dev"); role != "" { |
| + t.Fatalf("role after -remove = %q, want none", role) |
| + } |
| + |
| + // Removing a non-member is a no-op that says so, not an error: the |
| + // operator's intent ("they must not be in this account") is satisfied. |
| + out, err = run("member", "-remove", "dev@keymail.dev", "bab") |
| + if err != nil { |
| + t.Fatalf("removing a non-member must not fail: %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, "not a member") { |
| + t.Errorf("no-op removal does not say why: %s", out) |
| + } |
| +} |
| + |
| +// An account with nobody who can administer it is unrecoverable: no one left |
| +// to invite, rename or delete. The store guard already refuses it; the verb's |
| +// job is to surface that refusal instead of swallowing it. |
| +func TestMemberVerbRefusesToStrandAnAccount(t *testing.T) { |
| + bin := buildConsole(t) |
| + tmp := t.TempDir() |
| + dbPath := filepath.Join(tmp, "console.db") |
| + run := runner(t, bin, "CONSOLE_DB="+dbPath) |
| + |
| + st, err := console.Open(dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.CreateAccount(42, "cdef", "Kass", "solo@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + st.Close() |
| + |
| + out, err := run("member", "-remove", "solo@keymail.dev", "cdef") |
| + if err == nil { |
| + t.Errorf("removing the last owner succeeded: %s", out) |
| + } |
| + if !strings.Contains(out, "last owner") { |
| + t.Errorf("refusal does not explain itself: %s", out) |
| + } |
| + out, err = run("member", "-add", "solo@keymail.dev", "cdef", "-role", "member") |
| + if err == nil { |
| + t.Errorf("demoting the last owner succeeded: %s", out) |
| + } |
| + if !strings.Contains(out, "last owner") { |
| + t.Errorf("demotion refusal does not explain itself: %s", out) |
| + } |
| + if role := roleOf(t, dbPath, 42, "solo@keymail.dev"); role != console.RoleOwner { |
| + t.Errorf("the last owner survived as %q, want owner", role) |
| + } |
| +} |
| + |
| +// Addresses are case-insensitive identities, but the memberships primary key |
| +// is literal text with no COLLATE NOCASE — so an unnormalized address here |
| +// writes a row nobody's session can ever match, while the verb cheerfully |
| +// reports success. That is the worst failure available to a tool whose entire |
| +// job is repairing authorization: the operator walks away believing access was |
| +// restored. |
| +func TestMemberVerbNormalizesTheAddress(t *testing.T) { |
| + bin := buildConsole(t) |
| + tmp := t.TempDir() |
| + dbPath := filepath.Join(tmp, "console.db") |
| + run := runner(t, bin, "CONSOLE_DB="+dbPath) |
| + |
| + st, err := console.Open(dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.CreateAccount(console.OpsAccountID, "bab", "ops", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + st.Close() |
| + |
| + if out, err := run("member", "-add", " James@Keymail.dev ", "bab", "-role", "owner"); err != nil { |
| + t.Fatalf("member -add: %v\n%s", err, out) |
| + } |
| + // The address the real session presents is the lowercase one. |
| + if role := roleOf(t, dbPath, console.OpsAccountID, "james@keymail.dev"); role != console.RoleOwner { |
| + t.Fatalf("role for the lowercase address = %q, want owner — the row is unreachable", role) |
| + } |
| + // And it is ONE row, not two: the mixed-case form resolves to the same |
| + // membership, so re-adding it is the no-op it looks like. |
| + out, err := run("member", "-add", "JAMES@keymail.dev", "bab", "-role", "owner") |
| + if err != nil { |
| + t.Fatalf("re-add in another case: %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, "already") { |
| + t.Errorf("a case variant created a second membership: %s", out) |
| + } |
| + if out, _ := run("member", "-list", "bab"); strings.Contains(out, "James@Keymail.dev") || |
| + strings.Contains(out, "JAMES@keymail.dev") { |
| + t.Errorf("member list shows an unnormalized address: %s", out) |
| + } |
| + // Removal normalizes too, or the tool cannot undo what it just did. |
| + if out, err := run("member", "-remove", "James@Keymail.dev", "bab"); err != nil { |
| + t.Fatalf("member -remove: %v\n%s", err, out) |
| + } |
| + if role := roleOf(t, dbPath, console.OpsAccountID, "james@keymail.dev"); role != "" { |
| + t.Errorf("role after case-variant removal = %q, want none", role) |
| + } |
| +} |
| + |
| +// The second way an unnormalized address breaks things: a case-typo'd |
| +// duplicate would count as a second owner, so the last-owner guard would let |
| +// the only owner anybody can sign in as be removed while reporting that |
| +// another remains. Normalizing collapses the duplicate and the invariant holds. |
| +func TestMemberVerbCaseVariantCannotFakeASecondOwner(t *testing.T) { |
| + bin := buildConsole(t) |
| + tmp := t.TempDir() |
| + dbPath := filepath.Join(tmp, "console.db") |
| + run := runner(t, bin, "CONSOLE_DB="+dbPath) |
| + |
| + st, err := console.Open(dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.CreateAccount(42, "cdef", "Kass", "solo@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + st.Close() |
| + |
| + if out, err := run("member", "-add", "Solo@Keymail.dev", "cdef", "-role", "owner"); err != nil { |
| + t.Fatalf("member -add: %v\n%s", err, out) |
| + } |
| + members := membersOf(t, dbPath, 42) |
| + if len(members) != 1 { |
| + t.Fatalf("account has %d members, want 1 — a case variant became a second owner: %+v", |
| + len(members), members) |
| + } |
| + out, err := run("member", "-remove", "solo@keymail.dev", "cdef") |
| + if err == nil { |
| + t.Fatalf("removed the only functionally reachable owner: %s", out) |
| + } |
| + if !strings.Contains(out, "last owner") { |
| + t.Errorf("refusal does not explain itself: %s", out) |
| + } |
| +} |
| + |
| +// A typo must name what it looked for, not just fail. These commands get run |
| +// at 2am against a production box. |
| +func TestMemberVerbRejectsBadArguments(t *testing.T) { |
| + bin := buildConsole(t) |
| + tmp := t.TempDir() |
| + dbPath := filepath.Join(tmp, "console.db") |
| + run := runner(t, bin, "CONSOLE_DB="+dbPath) |
| + |
| + st, err := console.Open(dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.CreateAccount(console.OpsAccountID, "bab", "ops", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + st.Close() |
| + |
| + for _, tc := range []struct { |
| + name string |
| + args []string |
| + want []string |
| + }{ |
| + {"unknown account", []string{"member", "-add", "dev@keymail.dev", "nosuch"}, |
| + []string{"nosuch", "accounts/nosuch/account.json", "carlos accounts create"}}, |
| + {"malformed address", []string{"member", "-add", "dev", "bab"}, |
| + []string{"dev", "@"}}, |
| + {"unknown role", []string{"member", "-add", "dev@keymail.dev", "bab", "-role", "admin"}, |
| + []string{"admin", "owner", "member"}}, |
| + {"no mode", []string{"member", "dev@keymail.dev", "bab"}, |
| + []string{"-add", "-remove", "-list"}}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + out, err := run(tc.args...) |
| + if err == nil { |
| + t.Fatalf("%v succeeded: %s", tc.args, out) |
| + } |
| + for _, want := range tc.want { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("error does not mention %q: %s", want, out) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// `carlos accounts create` mints an account into the BUCKET and the registry |
| +// and never opens the console database. Before materialization, `console |
| +// member -add` refused that sqid — on the same box that had just created it. |
| +func TestMemberVerbAdministersABucketOnlyAccount(t *testing.T) { |
| + bin := buildConsole(t) |
| + tmp := t.TempDir() |
| + dbPath := filepath.Join(tmp, "console.db") |
| + bucket := filepath.Join(tmp, "bucket") |
| + run := runner(t, bin, "CONSOLE_DB="+dbPath, "CARLOS_DEPLOYMENT_DIR="+bucket) |
| + |
| + // Exactly what `carlos accounts create` leaves behind: a manifest, and |
| + // nothing at all in the console's database. |
| + const id = int64(42) |
| + sqid := domains.Sqid(id) |
| + rel := release.NewDirStore(bucket) |
| + if err := console.WriteAccountManifest(t.Context(), rel, id, sqid, "Kass", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + out, err := run("member", "-add", "paul@keymail.dev", sqid, "-role", "owner") |
| + if err != nil { |
| + t.Fatalf("member -add on a bucket-only account: %v\n%s", err, out) |
| + } |
| + if role := roleOf(t, dbPath, id, "paul@keymail.dev"); role != console.RoleOwner { |
| + t.Fatalf("role = %q, want owner", role) |
| + } |
| + // The id came from the manifest, not from a fresh mint: an invented id |
| + // would put the account at a different sqid and re-home everything it owns. |
| + st, err := console.Open(dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer st.Close() |
| + acct, err := st.AccountBySqid(sqid) |
| + if err != nil || acct == nil || acct.ID != id || acct.Name != "Kass" { |
| + t.Fatalf("materialized account = %+v (%v), want id %d named Kass", acct, err, id) |
| + } |
| +} |
| + |
| +func membersOf(t *testing.T, dbPath string, accountID int64) []console.Account { |
| + t.Helper() |
| + st, err := console.Open(dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer st.Close() |
| + members, err := st.Members(accountID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return members |
| +} |
| + |
| +func roleOf(t *testing.T, dbPath string, accountID int64, address string) string { |
| + t.Helper() |
| + st, err := console.Open(dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer st.Close() |
| + role, err := st.Role(accountID, address) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return role |
| +} |
diff --git a/docs/superpowers/specs/2026-08-02-multi-account-design.md b/docs/superpowers/specs/2026-08-02-multi-account-design.md| index 171053d21..906eb9248 100644 |
| --- a/docs/superpowers/specs/2026-08-02-multi-account-design.md |
| +++ b/docs/superpowers/specs/2026-08-02-multi-account-design.md |
| @@ -39,7 +39,7 @@ inventing a permissions model before there is a second user to test it |
| against. Every account has at least one owner; the last owner cannot be |
| removed or demoted (transfer first). |
| -### Durable identity — why the console mints the id |
| +### Durable identity — why the id is minted into the bucket |
| Account identity must survive a registry rebuild and be identical on every |
| box in a deployment. Deriving it from an AUTOINCREMENT registry row id (the |
| @@ -48,11 +48,11 @@ bucket-listing order at first reconcile, so a rebuild — or a second box |
| materializing accounts in a different order — silently re-homes every |
| platform domain. |
| -Instead: **the console mints the random id at account creation and writes |
| -it into the bucket**, at `accounts/<sqid>/account.json`: |
| +Instead: **the id is minted once, into the bucket**, at |
| +`accounts/<sqid>/account.json`: |
| ```json |
| -{"id": 793481920157, "sqid": "kqmv", "name": "Kass", |
| +{"id": 3, "sqid": "bad", "name": "Kass", |
| "created_at": "2026-08-02T09:00:00Z"} |
| ``` |
| @@ -65,6 +65,11 @@ assign in bucket-listing order, so a rebuild reassigned them. |
| Minting must use a **create-only write**, retrying with the next id on |
| conflict, so two concurrent minters can never claim the same integer. |
| +Built as `domains.MintAccount`: list the manifests, take `max+1`, claim it |
| +with a create-only `Put`; the loser of a race re-lists and takes the next id |
| +rather than overwriting a live account's identity. Retries are bounded and |
| +exhausting them fails loudly, because a store that conflicts forever is a |
| +broken deployment, not something to spin on. |
| Sequential ids are enumerable, and that is accepted: every platform |
| domain gets a Let's Encrypt certificate, and Certificate Transparency |
diff --git a/e2e_test.go b/e2e_test.go| index 527d91c15..f74431d1e 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -529,8 +529,11 @@ func TestMultiAccountEndToEnd(t *testing.T) { |
| t.Fatal(err) |
| } |
| - // Two accounts, minted the way the console does it: a random id, its |
| - // frozen sqid, and a bucket manifest the agent materializes from. |
| + // Two accounts, in the shape minting leaves behind: an id, its frozen |
| + // sqid, and a bucket manifest the agent materializes from. The ids are |
| + // hand-picked (and deliberately large) rather than minted here — this test |
| + // is about the agent reading manifests, and a fixture that depended on the |
| + // allocator would move every time the allocator did. |
| reg, err := registry.Open(filepath.Join(tmp, "registry.db")) |
| if err != nil { |
| t.Fatal(err) |
diff --git a/internal/console/account.go b/internal/console/account.go| index 61ea7b88f..e7529eeda 100644 |
| --- a/internal/console/account.go |
| +++ b/internal/console/account.go |
| @@ -1,14 +1,13 @@ |
| -// Account identity: minted here, written to the bucket, materialized by the |
| -// registry. The console is where an account is born — the id is a random |
| -// 40-bit integer, not a row number — because identity has to survive a |
| -// registry rebuild and be identical on every box in a deployment. See |
| +// Account identity: minted into the BUCKET (domains.MintAccount), then |
| +// materialized by the registry and by the console's own tables. The id is the |
| +// next free integer, allocated by a create-only manifest write rather than by |
| +// any local row number, because identity has to survive a registry rebuild and |
| +// be identical on every box in a deployment. See |
| // docs/superpowers/specs/2026-08-02-multi-account-design.md §1. |
| package console |
| import ( |
| "context" |
| - "crypto/rand" |
| - "encoding/binary" |
| "encoding/json" |
| "errors" |
| "os" |
| @@ -18,34 +17,6 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -// accountIDBits is the width of a minted account id. 40 bits is ~1.1e12 |
| -// values: with the ~thousands of accounts a deployment plausibly holds, a |
| -// collision is negligible, while the sqid stays short enough to live |
| -// comfortably in a hostname label. |
| -const accountIDBits = 40 |
| - |
| -// NewAccountID mints an account id: 40 random bits, never 0 and never 1 (id 1 |
| -// is the built-in ops account on every deployment, pinned by registry |
| -// migration). Randomness is not just uniqueness — it makes sqids |
| -// non-enumerable, so nobody can walk bab, bac, bad to discover who else is on |
| -// a deployment. |
| -// |
| -// crypto/rand.Read cannot fail on any platform this runs on (Go 1.24 made it |
| -// infallible); a defensive retry loop would be untestable dead code, so a |
| -// draw of 0 or 1 simply redraws. |
| -func NewAccountID() int64 { |
| - for { |
| - var b [8]byte |
| - if _, err := rand.Read(b[:]); err != nil { |
| - continue |
| - } |
| - id := int64(binary.BigEndian.Uint64(b[:]) & ((1 << accountIDBits) - 1)) |
| - if id > 1 { |
| - return id |
| - } |
| - } |
| -} |
| - |
| // accountSqid is the account's public identity: the FROZEN encoder over its |
| // id (internal/domains). Wrapped here so every mint in the console goes |
| // through one place — the encoding is what every platform domain and every |
| @@ -54,17 +25,14 @@ func accountSqid(id int64) string { return domains.Sqid(id) } |
| // AccountManifestKey is where an account's identity lives in the bucket. The |
| // sqid is in the PATH as well as the body so the agent can list accounts |
| -// without opening every object. |
| -func AccountManifestKey(sqid string) string { return "accounts/" + sqid + "/account.json" } |
| +// without opening every object. Defined once, in internal/domains, and shared |
| +// by everything that touches the object: the console, the CLI and the agent's |
| +// reconcile pass. |
| +func AccountManifestKey(sqid string) string { return domains.AccountManifestKey(sqid) } |
| // accountManifest is the wire shape at that key. The agent's reconcile pass |
| // materializes exactly this and assigns nothing. |
| -type accountManifest struct { |
| - ID int64 `json:"id"` |
| - Sqid string `json:"sqid"` |
| - Name string `json:"name"` |
| - CreatedAt time.Time `json:"created_at"` |
| -} |
| +type accountManifest = domains.AccountManifest |
| // WriteAccountManifest publishes an account into the deployment bucket — the |
| // one place account identity crosses from the console to every box. The |
diff --git a/internal/console/account_test.go b/internal/console/account_test.go| index 448a54022..06ca4b103 100644 |
| --- a/internal/console/account_test.go |
| +++ b/internal/console/account_test.go |
| @@ -3,6 +3,7 @@ package console |
| import ( |
| "context" |
| "encoding/json" |
| + "strings" |
| "testing" |
| "time" |
| @@ -49,8 +50,7 @@ func TestWriteAccountManifestRoundTrips(t *testing.T) { |
| // The sqid a minted account carries must be the frozen encoder over its id — |
| // nothing else, ever, or a platform domain stops resolving to its account. |
| func TestMintedSqidIsTheFrozenEncoding(t *testing.T) { |
| - for i := 0; i < 50; i++ { |
| - id := NewAccountID() |
| + for id := int64(2); id < 60; id++ { |
| if got := domains.Sqid(id); got != domains.Sqid(id) || got == "" { |
| t.Fatalf("Sqid(%d) unstable/empty: %q", id, got) |
| } |
| @@ -60,25 +60,34 @@ func TestMintedSqidIsTheFrozenEncoding(t *testing.T) { |
| } |
| } |
| -// Account ids are minted by the CONSOLE (not the registry, not the bucket |
| -// listing order) so that identity survives a registry rebuild. Randomness is |
| -// load-bearing twice over: it stops two boxes materializing different ids for |
| -// the same account, and it makes sqids non-enumerable — you cannot walk |
| -// bab, bac, bad to discover who else is on a deployment. |
| -func TestNewAccountIDIsRandomAndNeverReserved(t *testing.T) { |
| - seen := map[int64]bool{} |
| - for i := 0; i < 200; i++ { |
| - id := NewAccountID() |
| - if id <= 1 { |
| - t.Fatalf("id %d collides with ops/zero", id) |
| +// Account ids are SEQUENTIAL, and allocated in the BUCKET |
| +// (domains.MintAccount) — not by the registry, not by a console row number. |
| +// Identity survives a registry rebuild for the reason it always did: minted |
| +// once, recorded in the manifest, materialized-never-assigned. What sequential |
| +// buys is a sqid a human can use: a 40-bit random id encoded to NINE |
| +// characters (kass.nyfbskdye.oncarlos.com), which nobody can read out over a |
| +// phone or type without a typo. It gives up nothing in secrecy, because sqids |
| +// were never secret — every platform hostname's certificate is published in |
| +// Certificate Transparency within seconds of issue. |
| +func TestConsoleMintsSequentialIdsIntoTheBucket(t *testing.T) { |
| + rel := release.NewDirStore(t.TempDir()) |
| + ctx := t.Context() |
| + var last int64 |
| + for i := 0; i < 3; i++ { |
| + id, sqid, err := domains.MintAccount(ctx, rel, "Account", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| } |
| - if id > 1<<40 { |
| - t.Fatalf("id %d exceeds 40 bits", id) |
| + if id <= domains.ReservedAccountID { |
| + t.Fatalf("minted id %d collides with ops/zero", id) |
| } |
| - if seen[id] { |
| - t.Fatalf("repeat within 200 draws: %d", id) |
| + if last != 0 && id != last+1 { |
| + t.Errorf("minted %d after %d, want the next integer", id, last) |
| } |
| - seen[id] = true |
| + if len(sqid) > 3 { |
| + t.Errorf("sqid %q is %d characters — the whole point is that it is short", sqid, len(sqid)) |
| + } |
| + last = id |
| } |
| } |
| @@ -220,76 +229,130 @@ func TestLastAccountRoundTrips(t *testing.T) { |
| } |
| } |
| -// A per-app grant was never deployment-wide authority. ops contains console, |
| -// platform-site and status, so backfilling every grant holder as an OWNER of |
| -// it would hand whoever once shipped one app the right to invite people into |
| -// the account that runs the platform itself. App-grant holders land as |
| -// members; the operator flag — which IS deployment-wide authority — is what |
| -// makes an owner. |
| -func TestBackfillGrantsAreMembersOperatorsAreOwners(t *testing.T) { |
| +// A per-app grant was never deployment-wide authority, and membership IS |
| +// authorization: "member of the account AND the account claims the app" means |
| +// one membership of ops is access to console, platform-site, status and every |
| +// other app that lives there. Sweeping grant holders into ops did exactly that |
| +// on the flagship — someone holding ONE grant for a toy app could promote and |
| +// roll back kass, keymail and the console. Non-operators must stay OUT, and be |
| +// reported so they get accounts of their own. |
| +func TestBackfillLeavesGrantHoldersOutOfOpsAndReportsThem(t *testing.T) { |
| st := openTestStore(t) |
| - if err := st.Grant("dev@keymail.dev", "kass"); err != nil { |
| - t.Fatal(err) |
| - } |
| - if err := st.Grant("ops@keymail.dev", "console"); err != nil { |
| - t.Fatal(err) |
| + for _, g := range [][2]string{ |
| + {"dev@keymail.dev", "toy"}, |
| + {"dev@keymail.dev", "another-toy"}, |
| + {"ops@keymail.dev", "console"}, |
| + } { |
| + if err := st.Grant(g[0], g[1]); err != nil { |
| + t.Fatal(err) |
| + } |
| } |
| if err := st.SetOperator("ops@keymail.dev", true); err != nil { |
| t.Fatal(err) |
| } |
| - if err := st.BackfillOpsMemberships(time.Now()); err != nil { |
| + |
| + needs, err := st.BackfillOpsMemberships(time.Now()) |
| + if err != nil { |
| t.Fatal(err) |
| } |
| - if role, _ := st.Role(OpsAccountID, "dev@keymail.dev"); role != RoleMember { |
| - t.Errorf("app-grant holder role = %q, want member", role) |
| + if role, _ := st.Role(OpsAccountID, "dev@keymail.dev"); role != "" { |
| + t.Errorf("app-grant holder got role %q in ops — that is the widening this fixes", role) |
| } |
| if role, _ := st.Role(OpsAccountID, "ops@keymail.dev"); role != RoleOwner { |
| t.Errorf("operator role = %q, want owner", role) |
| } |
| - // Both still have their apps — the demotion is of authority, not access. |
| + // Left out is not the same as forgotten: the operator has to be told who |
| + // needs an account, with their apps, or the migration silently loses them. |
| + if len(needs) != 1 || needs[0].Address != "dev@keymail.dev" { |
| + t.Fatalf("report = %+v, want just dev@keymail.dev", needs) |
| + } |
| + if got := strings.Join(needs[0].Apps, ","); got != "another-toy,toy" { |
| + t.Errorf("reported apps = %q, want both, sorted", got) |
| + } |
| + // The app names are still claimed in ops, which is where every |
| + // pre-accounts app lived — migrate-app is what moves them out. |
| apps, _ := st.AppsIn(OpsAccountID) |
| - if len(apps) != 2 { |
| - t.Errorf("AppsIn(ops) = %v, want both apps claimed", apps) |
| + if len(apps) != 3 { |
| + t.Errorf("AppsIn(ops) = %v, want every granted app claimed", apps) |
| } |
| - // Idempotent, and it must not DEMOTE an owner on a later run. |
| - if err := st.BackfillOpsMemberships(time.Now()); err != nil { |
| + |
| + // Idempotent: a re-run changes nothing and reports the same people, so a |
| + // deployment that has already run this (with the OLD semantics, cleaned up |
| + // by hand) neither loses the cleanup nor re-applies the widening. |
| + again, err := st.BackfillOpsMemberships(time.Now()) |
| + if err != nil { |
| t.Fatal(err) |
| } |
| + if len(again) != 1 || again[0].Address != "dev@keymail.dev" { |
| + t.Errorf("second report = %+v, want the same", again) |
| + } |
| + if role, _ := st.Role(OpsAccountID, "dev@keymail.dev"); role != "" { |
| + t.Errorf("second run put the grant holder into ops as %q", role) |
| + } |
| if role, _ := st.Role(OpsAccountID, "ops@keymail.dev"); role != RoleOwner { |
| - t.Errorf("second backfill demoted the operator to %q", role) |
| + t.Errorf("second run changed the operator to %q", role) |
| } |
| } |
| -// ops must never end up with nobody who can administer it. With grants but no |
| -// operator, the earliest grant holder is promoted — loudly, because it is a |
| -// fallback, not the rule. |
| -func TestBackfillPromotesAnOwnerWhenThereWouldBeNone(t *testing.T) { |
| +// The flagship shape: the backfill has already run once with the old |
| +// semantics, and the widened membership was removed by hand. Re-running must |
| +// not put it back, and must not touch a membership an operator has since |
| +// created deliberately with `console member`. |
| +func TestBackfillNeverResurrectsARemovedMembership(t *testing.T) { |
| st := openTestStore(t) |
| - base := time.Unix(1700000000, 0) |
| - if err := st.GrantAt("second@keymail.dev", "b", base.Add(time.Hour)); err != nil { |
| + now := time.Now() |
| + if err := st.Grant("dev@keymail.dev", "toy"); err != nil { |
| t.Fatal(err) |
| } |
| - if err := st.GrantAt("first@keymail.dev", "a", base); err != nil { |
| + if err := st.Grant("paul@keymail.dev", "kass"); err != nil { |
| t.Fatal(err) |
| } |
| - if err := st.BackfillOpsMemberships(time.Now()); err != nil { |
| + if err := st.SetOperator("paul@keymail.dev", true); err != nil { |
| t.Fatal(err) |
| } |
| - if role, _ := st.Role(OpsAccountID, "first@keymail.dev"); role != RoleOwner { |
| - t.Errorf("earliest grant holder role = %q, want owner (nobody else could administer ops)", role) |
| + if _, err := st.BackfillOpsMemberships(now); err != nil { |
| + t.Fatal(err) |
| } |
| - if role, _ := st.Role(OpsAccountID, "second@keymail.dev"); role != RoleMember { |
| - t.Errorf("later grant holder role = %q, want member", role) |
| + // An operator deliberately admits somebody as a plain member. |
| + if err := st.AddMember(OpsAccountID, "trusted@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + needs, err := st.BackfillOpsMemberships(now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if role, _ := st.Role(OpsAccountID, "dev@keymail.dev"); role != "" { |
| + t.Errorf("the removed membership came back as %q", role) |
| + } |
| + if role, _ := st.Role(OpsAccountID, "trusted@keymail.dev"); role != RoleMember { |
| + t.Errorf("deliberate membership changed to %q, want member", role) |
| + } |
| + // Somebody an operator has already dealt with is not reported again. |
| + for _, h := range needs { |
| + if h.Address == "trusted@keymail.dev" || h.Address == "paul@keymail.dev" { |
| + t.Errorf("reported %s, who is already a member of ops", h.Address) |
| + } |
| } |
| } |
| -// A deployment with no grants at all and no operator gets an ops account with |
| -// no members — which is correct: there is nobody to make an owner, and |
| -// inventing one would be worse than an empty account the first operator |
| -// claims. |
| -func TestBackfillLeavesOpsEmptyWhenThereIsNobody(t *testing.T) { |
| +// With grants but NO operator, nobody is promoted. The old code promoted the |
| +// earliest grant holder to OWNER of ops so the account would have someone who |
| +// could administer it — but ops holds the console itself, and that promotion |
| +// is the same widening by a quieter route. An ownerless ops is recoverable |
| +// box-side (`console member -add <addr> bab -role owner`), which is precisely |
| +// the tool that did not exist when that fallback was written. |
| +func TestBackfillPromotesNobodyWhenThereIsNoOperator(t *testing.T) { |
| st := openTestStore(t) |
| - if err := st.BackfillOpsMemberships(time.Now()); err != nil { |
| + base := time.Unix(1700000000, 0) |
| + if err := st.GrantAt("second@keymail.dev", "b", base.Add(time.Hour)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.GrantAt("first@keymail.dev", "a", base); err != nil { |
| + t.Fatal(err) |
| + } |
| + needs, err := st.BackfillOpsMemberships(time.Now()) |
| + if err != nil { |
| t.Fatal(err) |
| } |
| members, err := st.Members(OpsAccountID) |
| @@ -297,7 +360,32 @@ func TestBackfillLeavesOpsEmptyWhenThereIsNobody(t *testing.T) { |
| t.Fatal(err) |
| } |
| if len(members) != 0 { |
| - t.Errorf("Members(ops) = %+v, want none", members) |
| + t.Errorf("Members(ops) = %+v, want nobody — a grant is not authority over ops", members) |
| + } |
| + if len(needs) != 2 { |
| + t.Errorf("report = %+v, want both grant holders", needs) |
| + } |
| +} |
| + |
| +// The advice is the whole point of the report: an operator reading the log |
| +// must be able to copy three commands per person, not go and design a |
| +// migration. It names the apps, so nobody is left behind. |
| +func TestGrantHolderAdviceIsCopyPasteable(t *testing.T) { |
| + msg := grantHolderAdvice([]GrantHolder{ |
| + {Address: "dev@keymail.dev", Apps: []string{"toy", "other"}}, |
| + {Address: "sam@keymail.dev", Apps: []string{"blog"}}, |
| + }) |
| + for _, want := range []string{ |
| + "2 grant holders need accounts", |
| + "dev@keymail.dev", "toy", "other", "sam@keymail.dev", "blog", |
| + "carlos accounts create", "console member -add", "-role owner", "console migrate-app", |
| + } { |
| + if !strings.Contains(msg, want) { |
| + t.Errorf("advice does not mention %q: %s", want, msg) |
| + } |
| + } |
| + if grantHolderAdvice(nil) != "" { |
| + t.Errorf("advice for nobody = %q, want silence", grantHolderAdvice(nil)) |
| } |
| } |
diff --git a/internal/console/materialize.go b/internal/console/materialize.go| new file mode 100644 |
| index 000000000..a8ec794a6 |
| --- /dev/null |
| +++ b/internal/console/materialize.go |
| @@ -0,0 +1,104 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "log" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// MaterializeAccounts teaches the console about accounts it did not mint. |
| +// |
| +// The bucket is the control plane, and the console is NOT the only minter: |
| +// `carlos accounts create` writes accounts/<sqid>/account.json and a registry |
| +// row without ever opening this database — deliberately, because the carlos |
| +// binary has no business inside console state. The consequence, before this, |
| +// was a console that would refuse `member -add … <sqid>` for an account that |
| +// demonstrably exists on the very box that created it. The agent's reconcile |
| +// pass (internal/domains §1) already materializes exactly these manifests into |
| +// the registry; this is the same pass for the console's own tables, reading |
| +// the same objects through the same key parser. |
| +// |
| +// The id in the manifest is TAKEN AS GIVEN. Nothing here ever mints or |
| +// reassigns one: the id is what the sqid encodes, the sqid is in every |
| +// platform domain and every bucket key, and inventing one would re-home an |
| +// account's whole world. A manifest that disagrees with itself — path sqid, |
| +// body sqid and Sqid(id) must all be the same string — is corruption, not a |
| +// new account, and is skipped loudly. |
| +// |
| +// INSERT-only: an account the console already knows keeps its row, name and |
| +// members. Returns how many were newly materialized. |
| +// |
| +// Best-effort by design. A nil store (no CARLOS_DEPLOYMENT_BUCKET/DIR on this |
| +// box) and a failed listing are both "nothing to catch up on", not errors: an |
| +// unreachable bucket must never be what stops an operator removing a |
| +// membership, which is the emergency this whole verb family exists for. |
| +func MaterializeAccounts(ctx context.Context, s release.Store, st *Store) (int, error) { |
| + if s == nil { |
| + return 0, nil |
| + } |
| + keys, err := s.List(ctx, "accounts/") |
| + if err != nil { |
| + return 0, err |
| + } |
| + n := 0 |
| + for _, key := range keys { |
| + sqid, ok := domains.AccountManifestSqid(key) |
| + if !ok { |
| + continue |
| + } |
| + // Cheap short-circuit, and the reason a steady-state pass does no |
| + // writes at all: the console usually already knows every account. |
| + if a, err := st.AccountBySqid(sqid); err != nil { |
| + return n, err |
| + } else if a != nil { |
| + continue |
| + } |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("console: skipping %s: read failed: %v", key, err) |
| + continue |
| + } |
| + var m accountManifest |
| + if err := json.Unmarshal(body, &m); err != nil || m.ID <= 0 || m.Sqid == "" { |
| + log.Printf("console: skipping %s: malformed account manifest: %v", key, err) |
| + continue |
| + } |
| + if m.Sqid != sqid || domains.Sqid(m.ID) != sqid { |
| + log.Printf("console: skipping %s: sqid %q/%q is not Sqid(%d)=%q — that is corruption, "+ |
| + "not a new account", key, sqid, m.Sqid, m.ID, domains.Sqid(m.ID)) |
| + continue |
| + } |
| + created := m.CreatedAt |
| + if created.IsZero() { |
| + created = time.Now() |
| + } |
| + added, err := st.EnsureAccount(m.ID, m.Sqid, m.Name, created) |
| + if err != nil { |
| + return n, err |
| + } |
| + if !added { |
| + // The sqid was free but the id was not: some other row already |
| + // holds it. Loud, because two accounts cannot share an id and one |
| + // of these two is wrong. |
| + log.Printf("console: did not materialize %s: account id %d is already taken here", key, m.ID) |
| + continue |
| + } |
| + log.Printf("console: materialized account %s (%s) from the bucket", m.Sqid, m.Name) |
| + n++ |
| + } |
| + return n, nil |
| +} |
| + |
| +// materializeQuietly is the call site every entry point actually wants: catch |
| +// up if we can, log and carry on if we cannot. Separated from |
| +// MaterializeAccounts so the failure policy lives in ONE place rather than |
| +// being re-decided by the server, the member verb and whatever comes next. |
| +func materializeQuietly(ctx context.Context, s release.Store, st *Store) { |
| + if _, err := MaterializeAccounts(ctx, s, st); err != nil { |
| + log.Printf("console: could not materialize accounts from the bucket: %v", err) |
| + } |
| +} |
diff --git a/internal/console/materialize_test.go b/internal/console/materialize_test.go| new file mode 100644 |
| index 000000000..302724968 |
| --- /dev/null |
| +++ b/internal/console/materialize_test.go |
| @@ -0,0 +1,142 @@ |
| +package console |
| + |
| +import ( |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The bucket is the control plane, and the console is not the only minter: |
| +// `carlos accounts create` writes accounts/<sqid>/account.json and a registry |
| +// row without ever opening the console database. Before this, the console |
| +// simply did not know such an account existed — so `console member -add` would |
| +// refuse a sqid that demonstrably exists, on the very box that created it. |
| +func TestMaterializeAccountsFromTheBucket(t *testing.T) { |
| + st := openTestStore(t) |
| + rel := release.NewDirStore(t.TempDir()) |
| + ctx := t.Context() |
| + created := time.Unix(1700000000, 0).UTC() |
| + |
| + const kassID, blogID = int64(793481920157), int64(42) |
| + for _, a := range []struct { |
| + id int64 |
| + name string |
| + }{{kassID, "Kass"}, {blogID, "Blog"}} { |
| + if err := WriteAccountManifest(ctx, rel, a.id, domains.Sqid(a.id), a.name, created); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + n, err := MaterializeAccounts(ctx, rel, st) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if n != 2 { |
| + t.Errorf("materialized %d accounts, want 2", n) |
| + } |
| + acct, err := st.AccountBySqid(domains.Sqid(kassID)) |
| + if err != nil || acct == nil { |
| + t.Fatalf("AccountBySqid(Kass) = %v %v, want the materialized account", acct, err) |
| + } |
| + // The id in the manifest is taken as GIVEN. Minting a new one here would |
| + // re-home every platform domain the account owns. |
| + if acct.ID != kassID { |
| + t.Errorf("materialized id = %d, want the manifest's %d", acct.ID, kassID) |
| + } |
| + if acct.Name != "Kass" || !acct.CreatedAt.Equal(created) { |
| + t.Errorf("materialized account = %+v, want Kass created %v", acct, created) |
| + } |
| + |
| + // Idempotent: a second pass finds nothing new, which is what makes it safe |
| + // to run on every console start and before every membership operation. |
| + if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 0 { |
| + t.Errorf("second pass materialized %d (%v), want 0", n, err) |
| + } |
| +} |
| + |
| +// A manifest whose sqid disagrees with its id is corruption, not a new |
| +// account: materializing it would put an account at a domain that stops |
| +// resolving the moment anything recomputes the sqid. Skipped loudly, never |
| +// guessed at — the same rule internal/domains applies to the same objects. |
| +func TestMaterializeSkipsManifestsThatDisagreeWithThemselves(t *testing.T) { |
| + st := openTestStore(t) |
| + rel := release.NewDirStore(t.TempDir()) |
| + ctx := t.Context() |
| + |
| + // sqid in the path and body agree with each other but not with Sqid(id). |
| + if _, err := rel.Put(ctx, "accounts/wrong/account.json", |
| + []byte(`{"id":42,"sqid":"wrong","name":"Impostor"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Body sqid disagrees with the path. |
| + if _, err := rel.Put(ctx, "accounts/"+domains.Sqid(43)+"/account.json", |
| + []byte(`{"id":43,"sqid":"elsewhere","name":"Mislabelled"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := rel.Put(ctx, "accounts/broken/account.json", []byte("{not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := rel.Put(ctx, "accounts/empty/account.json", []byte("{}"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + // One good manifest alongside: a bad neighbour must not stop the pass. |
| + if err := WriteAccountManifest(ctx, rel, 42, domains.Sqid(42), "Blog", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + n, err := MaterializeAccounts(ctx, rel, st) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if n != 1 { |
| + t.Errorf("materialized %d, want only the well-formed manifest", n) |
| + } |
| + for _, sqid := range []string{"wrong", "elsewhere", "broken", "empty", domains.Sqid(43)} { |
| + if a, _ := st.AccountBySqid(sqid); a != nil { |
| + t.Errorf("materialized %q from a manifest that disagrees with itself: %+v", sqid, a) |
| + } |
| + } |
| + if a, _ := st.AccountBySqid(domains.Sqid(42)); a == nil { |
| + t.Error("the well-formed manifest was not materialized") |
| + } |
| +} |
| + |
| +// Materialization is INSERT-only. An account the console already knows keeps |
| +// its row: the console is where names are edited, the manifest is written |
| +// once, and letting a stale manifest name win would rename accounts behind |
| +// the operator's back. Above all it must never reassign an id. |
| +func TestMaterializeNeverRewritesAKnownAccount(t *testing.T) { |
| + st := openTestStore(t) |
| + rel := release.NewDirStore(t.TempDir()) |
| + ctx := t.Context() |
| + now := time.Unix(1700000000, 0).UTC() |
| + |
| + if err := st.CreateAccount(42, domains.Sqid(42), "Current name", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := WriteAccountManifest(ctx, rel, 42, domains.Sqid(42), "Stale name", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 0 { |
| + t.Fatalf("materialized %d (%v) for an account the console already had", n, err) |
| + } |
| + acct, _ := st.AccountBySqid(domains.Sqid(42)) |
| + if acct == nil || acct.Name != "Current name" || acct.ID != 42 { |
| + t.Errorf("known account was rewritten: %+v", acct) |
| + } |
| + // And its membership survives — materialization touches memberships never. |
| + if role, _ := st.Role(42, "paul@keymail.dev"); role != RoleOwner { |
| + t.Errorf("owner role after materialize = %q, want owner", role) |
| + } |
| +} |
| + |
| +// A deployment with no bucket configured at all still has to work: this is |
| +// best-effort catch-up, not a precondition. |
| +func TestMaterializeWithNoStoreIsANoOp(t *testing.T) { |
| + st := openTestStore(t) |
| + if n, err := MaterializeAccounts(t.Context(), nil, st); err != nil || n != 0 { |
| + t.Errorf("MaterializeAccounts(nil store) = %d %v, want 0 nil", n, err) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index be121fcef..011662a43 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -22,6 +22,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| @@ -157,12 +158,27 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { |
| fmt.Fprintln(w, "ok") |
| }) |
| - // One-time backfill: every existing per-app grant becomes a membership of |
| - // the ops account, which is where every pre-accounts app already lives. |
| - // Idempotent, and cheap enough to run on every start. |
| - if err := st.BackfillOpsMemberships(time.Now()); err != nil { |
| + // Catch up on accounts minted outside this database — `carlos accounts |
| + // create` writes the bucket manifest and the registry row and never opens |
| + // console state. Startup is the natural place: the account switcher, the |
| + // membership gate and every /a/<sqid>/… route resolve through the accounts |
| + // table, so an account the console has not materialized is invisible to |
| + // all of them. Best-effort; a bucket hiccup must not stop the console |
| + // booting. |
| + materializeQuietly(context.Background(), cfg.Releases, st) |
| + // The pre-accounts backfill: operators become owners of ops, every granted |
| + // app name is claimed there, and non-operator grant holders are left out |
| + // and REPORTED — a per-app grant was never authority over ops. Additive and |
| + // idempotent, so it is cheap enough to run on every start. The report is |
| + // logged rather than acted on: only an operator can decide who gets an |
| + // account of their own, but nobody can decide it if nobody is told. |
| + needAccounts, err := st.BackfillOpsMemberships(time.Now()) |
| + if err != nil { |
| return nil, err |
| } |
| + if advice := grantHolderAdvice(needAccounts); advice != "" { |
| + log.Print(advice) |
| + } |
| return s, nil |
| } |
| @@ -352,23 +368,32 @@ func (s *Server) currentAccount(ctx context.Context, addr string) (string, error |
| return a.Sqid, nil |
| } |
| -// mintAccount creates an account owned by addr: a random id (never derived |
| -// from a row number), its frozen sqid, the console DB rows, and the bucket |
| -// manifest that every box materializes from. |
| +// mintAccount creates an account owned by addr: the next free id allocated in |
| +// the bucket, its frozen sqid, and the console DB rows. |
| // |
| -// The manifest write is best-effort by design — the account already exists in |
| -// the console, and a bucket hiccup must not lose it. The consequence of a |
| -// missed write is only that platform domains wait for the next successful |
| -// one, which the account page re-attempts. |
| +// The bucket write comes FIRST and is no longer best-effort, because it is now |
| +// the allocator: the id is claimed by a create-only manifest write, which is |
| +// what stops two boxes minting the same integer at the same moment. A console |
| +// row whose manifest never landed would be an account that vanishes on the |
| +// next rebuild — worse than a "new account" button that reports the outage. |
| func (s *Server) mintAccount(ctx context.Context, name, ownerAddr string) (*Account, error) { |
| - id := NewAccountID() |
| - sqid := accountSqid(id) |
| now := time.Now() |
| - if err := s.store.CreateAccount(id, sqid, name, ownerAddr, now); err != nil { |
| + id, sqid, err := domains.MintAccount(ctx, s.cfg.Releases, name, now) |
| + if err != nil { |
| return nil, err |
| } |
| - if err := WriteAccountManifest(ctx, s.cfg.Releases, id, sqid, name, now); err != nil { |
| - log.Printf("console: account manifest for %s: %v", sqid, err) |
| + if err := s.store.CreateAccount(id, sqid, name, ownerAddr, now); err != nil { |
| + // The manifest already landed, so the id is claimed and there is now an |
| + // account in the bucket with no console row and no owner. It is |
| + // unreachable rather than dangerous (no membership means no access, and |
| + // materialization gives it no members either), and the next mint simply |
| + // takes the id after it. But without this line the only trace is a 500 |
| + // in the user's browser: name the sqid so the stranded account can be |
| + // found and either adopted with `console member -add … -role owner` or |
| + // deleted from the bucket. |
| + log.Printf("console: minted account %s (id %d, %q) into the bucket but could not record it "+ |
| + "locally: %v — accounts/%s/account.json is stranded with no owner", sqid, id, name, err, sqid) |
| + return nil, err |
| } |
| if err := s.store.SetLastAccount(ownerAddr, id); err != nil { |
| log.Printf("console: set last account for %s: %v", ownerAddr, err) |
| @@ -496,7 +521,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/", http.StatusSeeOther) |
| } |
| to := strings.ToLower(strings.TrimSpace(r.FormValue("address"))) |
| - if !validAddress(to) { |
| + if !ValidAddress(to) { |
| back("err", "That doesn't look like a keymail address.") |
| return |
| } |
| @@ -607,7 +632,7 @@ func (s *Server) handleContinue(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, "/auth/signin", http.StatusSeeOther) |
| return |
| } |
| - if !validAddress(addr) { |
| + if !ValidAddress(addr) { |
| p := page{Title: "Sign in", Error: "That doesn't look like a keymail address — expected something like you@keymail.dev."} |
| s.render(w, http.StatusOK, "home", p) |
| return |
| @@ -638,7 +663,12 @@ func (s *Server) handleContinue(w http.ResponseWriter, r *http.Request) { |
| s.render(w, http.StatusOK, "check", p) |
| } |
| -func validAddress(a string) bool { |
| +// ValidAddress is the console's one shape check on a keymail address: |
| +// <local>@<host>, no whitespace. Exported because the box-side verbs |
| +// (`console member`) must reject a typo with the SAME rule the web surface |
| +// uses — a membership created from the command line for an address the sign-in |
| +// form would refuse is a grant nobody can ever claim. |
| +func ValidAddress(a string) bool { |
| i := strings.LastIndex(a, "@") |
| return i > 0 && i < len(a)-1 && !strings.ContainsAny(a, " \t\r\n") |
| } |
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go| index b5815da21..d94213795 100644 |
| --- a/internal/console/server_account_test.go |
| +++ b/internal/console/server_account_test.go |
| @@ -51,7 +51,7 @@ func TestLandingRedirectsToLastAccount(t *testing.T) { |
| t.Fatalf("account home = %d", st) |
| } |
| // A second account, visited after, becomes the MRU. |
| - id := NewAccountID() |
| + id := testAccountID() |
| sqid := accountSqid(id) |
| if err := fx.st.CreateAccount(id, sqid, "Second", "paul@keymail.dev", time.Now()); err != nil { |
| t.Fatal(err) |
| @@ -175,11 +175,11 @@ func TestInviteAcceptCreatesMembership(t *testing.T) { |
| func TestSwitcherListsOnlyMyAccounts(t *testing.T) { |
| fx := newConsoleFixture(t) |
| // An account paul is not a member of. |
| - otherID := NewAccountID() |
| + otherID := testAccountID() |
| if err := fx.st.CreateAccount(otherID, accountSqid(otherID), "Someone Else", "sam@keymail.dev", time.Now()); err != nil { |
| t.Fatal(err) |
| } |
| - mineID := NewAccountID() |
| + mineID := testAccountID() |
| if err := fx.st.CreateAccount(mineID, accountSqid(mineID), "Mine", "paul@keymail.dev", time.Now()); err != nil { |
| t.Fatal(err) |
| } |
| @@ -204,7 +204,7 @@ func TestSwitcherListsOnlyMyAccounts(t *testing.T) { |
| func TestAuditIsScopedToTheAccount(t *testing.T) { |
| fx := newConsoleFixture(t) |
| now := time.Now() |
| - other := NewAccountID() |
| + other := testAccountID() |
| if err := fx.st.CreateAccount(other, accountSqid(other), "Other", "sam@keymail.dev", now); err != nil { |
| t.Fatal(err) |
| } |
| @@ -265,7 +265,7 @@ func TestAppPageWarnsWhenAccountsDisagree(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - moved := NewAccountID() |
| + moved := testAccountID() |
| sqid := accountSqid(moved) |
| if err := fx.st.CreateAccount(moved, sqid, "Kass", "paul@keymail.dev", time.Now()); err != nil { |
| t.Fatal(err) |
| @@ -325,7 +325,7 @@ func TestSameAppNameInTwoAccounts(t *testing.T) { |
| signUp(t, fx, c, "code-1") |
| csrf := sessionToken(t, fx, c) |
| - id := NewAccountID() |
| + id := testAccountID() |
| sqid := accountSqid(id) |
| if err := fx.st.CreateAccount(id, sqid, "Second", "paul@keymail.dev", time.Now()); err != nil { |
| t.Fatal(err) |
diff --git a/internal/console/store.go b/internal/console/store.go| index 21d2e3cca..7fb7cb82f 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -15,7 +15,6 @@ import ( |
| "encoding/base64" |
| "encoding/hex" |
| "fmt" |
| - "log" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -129,7 +128,8 @@ func (s *Store) migrate() error { |
| );`, |
| // ---- multi-account ------------------------------------------------- |
| // The account is the tenancy primitive: it owns apps and has members. |
| - // id is MINTED (NewAccountID), never autoincrement — see account.go. |
| + // id is MINTED into the bucket (domains.MintAccount) and materialized |
| + // here, never autoincrement — see account.go. |
| // name is deliberately NOT unique: identity is the sqid, so there is |
| // nothing to squat. |
| `CREATE TABLE IF NOT EXISTS accounts ( |
| @@ -296,84 +296,138 @@ func (s *Store) Owners() (map[string][]string, error) { |
| // ---- accounts, memberships, invites ----------------------------------------- |
| -// BackfillOpsMemberships maps the pre-accounts world onto the new one, once: |
| -// the built-in ops account exists, every address that held ANY app grant is a |
| -// member of it, and every granted app name is claimed in it. That is the |
| -// spec's "every existing grant maps to a membership in the account that owns |
| -// the app" — before accounts, every app was in ops by definition. |
| +// GrantHolder is one pre-accounts app-grant holder that the backfill |
| +// deliberately left OUT of ops, with the apps they held grants for. The |
| +// backfill cannot decide where these people belong — that is an account per |
| +// person or per project, which is an operator's judgement — so it reports them |
| +// instead of guessing. |
| +type GrantHolder struct { |
| + Address string |
| + Apps []string |
| +} |
| + |
| +// BackfillOpsMemberships maps the pre-accounts world onto the new one: the |
| +// built-in ops account exists, every OPERATOR is an owner of it, and every |
| +// granted app name is claimed in it (before accounts, every app was in ops by |
| +// definition). It returns the grant holders it left out, so the caller can say |
| +// who still needs an account of their own. |
| +// |
| +// WHY non-operators are excluded — this widened access in production once |
| +// already, and the reasoning has to survive the next person to read it: |
| // |
| -// Grant holders become MEMBERS, not owners. A per-app grant was never |
| -// deployment-wide authority, and ops is not an ordinary account: it holds |
| -// console, platform-site and status. Backfilling every grant holder as an |
| -// owner would hand whoever once shipped one app the right to invite people |
| -// into the account that runs the platform itself. What IS deployment-wide |
| -// authority is the operator flag, so that — and only that — backfills as |
| -// owner. |
| +// Authorization is "Role(account, address) != "" AND HasAppIn(account, app)". |
| +// A membership is therefore access to EVERY app in the account, not to the app |
| +// somebody once held a grant for. The first version of this function swept all |
| +// grant holders into ops as members, on the theory that a member is the |
| +// harmless role. It is not harmless in ops: ops holds console, platform-site, |
| +// status, and every app that has not yet been migrated out. On the flagship |
| +// that handed a user who held ONE grant — for a toy app — the ability to |
| +// promote and roll back kass, keymail and the console itself. Nobody granted |
| +// them that; the migration invented it, and it took hand-edited SQL to undo. |
| // |
| -// If that would leave ops with no owner at all, the earliest grant holder is |
| -// promoted and the promotion is logged loudly: an account nobody can |
| -// administer is a worse outcome than one operator who has to be told they |
| -// were given the role. A deployment with no grants and no operators gets an |
| -// empty ops account, which is correct — there is nobody to promote, and |
| -// inventing someone would be worse. |
| +// A per-app grant was never deployment-wide authority. What IS deployment-wide |
| +// authority is the operator flag, so that — and only that — makes an ops |
| +// membership here, as owner. |
| // |
| -// Idempotent, so it runs on every console start: existing memberships are |
| -// left exactly as they are (a later run never demotes an owner), and a |
| -// deployment that has already moved on gains nothing and loses nothing. It |
| -// deliberately does NOT delete the owners table: the grants stay readable |
| -// while the migration is being verified. |
| -func (s *Store) BackfillOpsMemberships(now time.Time) error { |
| +// There is deliberately no "promote somebody so ops has an owner" fallback any |
| +// more. The old code promoted the earliest grant holder when no operator |
| +// existed, reasoning that an unadministrable account is worse than an |
| +// unexpected owner. But that is the same widening by a quieter route, and it |
| +// is no longer the only way out: an ownerless ops is recoverable box-side with |
| +// `console member -add <addr> bab -role owner` — the tool that did not exist |
| +// when that fallback was written. |
| +// |
| +// Strictly ADDITIVE and idempotent, so it can run on every console start: it |
| +// only ever INSERT OR IGNOREs, so no existing membership is ever demoted, |
| +// re-roled or removed. That matters concretely — the flagship has already run |
| +// this once under the old semantics and been cleaned up by hand, and a re-run |
| +// must not resurrect what was removed. It also deliberately does NOT delete the |
| +// owners table: the grants stay readable while the migration is verified. |
| +func (s *Store) BackfillOpsMemberships(now time.Time) ([]GrantHolder, error) { |
| tx, err := s.db.Begin() |
| if err != nil { |
| - return err |
| + return nil, err |
| } |
| defer tx.Rollback() |
| if _, err := tx.Exec(`INSERT OR IGNORE INTO accounts (id, sqid, name, created_at) |
| VALUES (?, ?, 'ops', ?)`, OpsAccountID, opsSqid, now.Unix()); err != nil { |
| - return err |
| - } |
| - // OR IGNORE, never OR REPLACE: an address that is already a member keeps |
| - // whatever role it has, so a second run cannot demote an owner. |
| - if _, err := tx.Exec(`INSERT OR IGNORE INTO memberships (account_id, address, role, added_at) |
| -SELECT DISTINCT ?, address, ?, ? FROM owners`, OpsAccountID, RoleMember, now.Unix()); err != nil { |
| - return err |
| + return nil, err |
| } |
| + // OR IGNORE, never OR REPLACE or UPDATE: an address that is already a |
| + // member keeps whatever role it has. This is the only membership write in |
| + // the whole function. |
| if _, err := tx.Exec(`INSERT OR IGNORE INTO memberships (account_id, address, role, added_at) |
| -SELECT ?, address, ?, ? FROM users WHERE operator != 0`, OpsAccountID, RoleOwner, now.Unix()); err != nil { |
| - return err |
| - } |
| - // The operators that were already members as grant holders: promote them. |
| - if _, err := tx.Exec(`UPDATE memberships SET role = ? WHERE account_id = ? |
| -AND address IN (SELECT address FROM users WHERE operator != 0)`, RoleOwner, OpsAccountID); err != nil { |
| - return err |
| +SELECT ?, address, ?, ? FROM users WHERE operator != 0`, |
| + OpsAccountID, RoleOwner, now.Unix()); err != nil { |
| + return nil, err |
| } |
| + // The app NAMES are still claimed in ops: that is where every pre-accounts |
| + // app physically lives, and claiming them keeps two accounts from minting |
| + // the same name before the migration moves them. `console migrate-app` is |
| + // what re-homes them afterwards. |
| if _, err := tx.Exec(`INSERT OR IGNORE INTO apps (app, account_id, created_by, created_at) |
| SELECT app, ?, MIN(address), ? FROM owners GROUP BY app`, OpsAccountID, now.Unix()); err != nil { |
| - return err |
| + return nil, err |
| } |
| - var owners int |
| - if err := tx.QueryRow(`SELECT COUNT(*) FROM memberships WHERE account_id = ? AND role = ?`, |
| - OpsAccountID, RoleOwner).Scan(&owners); err != nil { |
| - return err |
| + // Who is left over: grant holders who are not operators and hold no ops |
| + // membership. The membership exclusion is what makes the report quiet once |
| + // an operator has dealt with someone — deliberately admitting them, or |
| + // giving them their own account — rather than nagging on every restart. |
| + rows, err := tx.Query(`SELECT o.address, o.app FROM owners o |
| +WHERE o.address NOT IN (SELECT address FROM users WHERE operator != 0) |
| + AND o.address NOT IN (SELECT address FROM memberships WHERE account_id = ?) |
| +ORDER BY o.address, o.app`, OpsAccountID) |
| + if err != nil { |
| + return nil, err |
| } |
| - if owners == 0 { |
| - var first string |
| - err := tx.QueryRow(`SELECT address FROM owners ORDER BY created_at, address LIMIT 1`).Scan(&first) |
| - if err == sql.ErrNoRows { |
| - return tx.Commit() // nobody to promote; an empty ops account is honest |
| + var out []GrantHolder |
| + for rows.Next() { |
| + var addr, app string |
| + if err := rows.Scan(&addr, &app); err != nil { |
| + rows.Close() |
| + return nil, err |
| } |
| - if err != nil { |
| - return err |
| + if n := len(out); n > 0 && out[n-1].Address == addr { |
| + out[n-1].Apps = append(out[n-1].Apps, app) |
| + continue |
| } |
| - if _, err := tx.Exec(`UPDATE memberships SET role = ? WHERE account_id = ? AND address = ?`, |
| - RoleOwner, OpsAccountID, first); err != nil { |
| - return err |
| + out = append(out, GrantHolder{Address: addr, Apps: []string{app}}) |
| + } |
| + rows.Close() |
| + if err := rows.Err(); err != nil { |
| + return nil, err |
| + } |
| + if err := tx.Commit(); err != nil { |
| + return nil, err |
| + } |
| + return out, nil |
| +} |
| + |
| +// grantHolderAdvice renders the leftover grant holders as something an |
| +// operator can act on: who, which apps, and the exact three commands that give |
| +// one of them an account. A count and a list would be a to-do; this is a |
| +// runbook, and the difference decides whether it gets done. |
| +// |
| +// Returns "" when there is nobody, so the caller logs nothing rather than a |
| +// line saying nothing happened. |
| +func grantHolderAdvice(holders []GrantHolder) string { |
| + if len(holders) == 0 { |
| + return "" |
| + } |
| + var b strings.Builder |
| + fmt.Fprintf(&b, "console: %d grant holders need accounts of their own — they hold pre-accounts "+ |
| + "app grants and were deliberately NOT made members of ops (a membership there is access to "+ |
| + "console, platform-site and every unmigrated app): ", len(holders)) |
| + for i, h := range holders { |
| + if i > 0 { |
| + b.WriteString("; ") |
| } |
| - log.Printf("console: no operator existed, so %s (the earliest app-grant holder) was made "+ |
| - "OWNER of the ops account — every account needs one. Review this: ops holds console, "+ |
| - "platform-site and status.", first) |
| + fmt.Fprintf(&b, "%s (%s)", h.Address, strings.Join(h.Apps, " ")) |
| } |
| - return tx.Commit() |
| + b.WriteString(". Give each one their own with: carlos accounts create -name <name> " + |
| + "&& console member -add <address> <sqid> -role owner " + |
| + "&& console migrate-app -app <app> -to <sqid>") |
| + return b.String() |
| } |
| // Roles. Two, not a matrix: enough authority to make invites safe, without |
| @@ -433,6 +487,30 @@ func (s *Store) CreateAccount(id int64, sqid, name, ownerAddress string, now tim |
| return tx.Commit() |
| } |
| +// EnsureAccount records an account the console did NOT mint — one that already |
| +// exists in the bucket (MaterializeAccounts). added reports whether this call |
| +// created the row. |
| +// |
| +// Unlike CreateAccount it deliberately creates no membership, and that is not |
| +// an oversight: `carlos accounts create` records no owner anywhere, so the |
| +// console has nobody to name. Inventing one — the invoking operator, the first |
| +// user, anyone — would be minting authority out of thin air over an account |
| +// this box knows nothing about. An ownerless row is honest, harmless (no |
| +// membership means no access), and exactly what `console member -add <addr> |
| +// <sqid> -role owner` is for. |
| +// |
| +// INSERT OR IGNORE, never REPLACE: an account the console already knows must |
| +// keep its id, its name and its members. |
| +func (s *Store) EnsureAccount(id int64, sqid, name string, created time.Time) (added bool, err error) { |
| + res, err := s.db.Exec(`INSERT OR IGNORE INTO accounts (id, sqid, name, created_at) VALUES (?, ?, ?, ?)`, |
| + id, sqid, name, created.Unix()) |
| + if err != nil { |
| + return false, err |
| + } |
| + n, err := res.RowsAffected() |
| + return n > 0, err |
| +} |
| + |
| // AccountBySqid returns the account with that public identity, or (nil, nil) |
| // when there is none — "no such account" is an answer, not an error, and the |
| // caller renders it as the same 404 a nonexistent page gets. |
diff --git a/internal/console/store_test.go b/internal/console/store_test.go| index ceb8d56f4..54b788bd4 100644 |
| --- a/internal/console/store_test.go |
| +++ b/internal/console/store_test.go |
| @@ -3,10 +3,21 @@ package console |
| import ( |
| "path/filepath" |
| "reflect" |
| + "sync/atomic" |
| "testing" |
| "time" |
| ) |
| +// testAccountID hands out distinct account ids for fixtures that just need an |
| +// account to exist. Real minting is a bucket operation now |
| +// (domains.MintAccount, which allocates the next free integer with a |
| +// create-only manifest write) and most of these tests have no bucket in play; |
| +// they only care that two fixture accounts differ. Starts well above the |
| +// built-in ops id and any hand-written literal in these tests. |
| +var testIDs atomic.Int64 |
| + |
| +func testAccountID() int64 { return 1000 + testIDs.Add(1) } |
| + |
| func openTestStore(t *testing.T) *Store { |
| t.Helper() |
| s, err := Open(filepath.Join(t.TempDir(), "console.db")) |
diff --git a/internal/domains/mint.go b/internal/domains/mint.go| new file mode 100644 |
| index 000000000..41ab52da8 |
| --- /dev/null |
| +++ b/internal/domains/mint.go |
| @@ -0,0 +1,139 @@ |
| +// Account identity is minted HERE, into the bucket, because the bucket is the |
| +// control plane: the console mints from the web surface, `carlos accounts |
| +// create` mints from a box with no console at all, and both must land on the |
| +// same allocator. The registry and the console DB only ever materialize what |
| +// the bucket says (Reconcile §1, console.MaterializeAccounts) — neither of |
| +// them assigns. |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// AccountManifest is the wire shape at accounts/<sqid>/account.json — the one |
| +// place account identity crosses between the console, the CLI and every box. |
| +// The sqid is in the PATH as well as the body so a reader can list accounts |
| +// without opening every object; the two must always agree, and both must equal |
| +// Sqid(id). |
| +type AccountManifest struct { |
| + ID int64 `json:"id"` |
| + Sqid string `json:"sqid"` |
| + Name string `json:"name"` |
| + CreatedAt time.Time `json:"created_at"` |
| +} |
| + |
| +// AccountManifestKey is where an account's identity lives in the bucket. |
| +func AccountManifestKey(sqid string) string { return "accounts/" + sqid + "/account.json" } |
| + |
| +// ReservedAccountID is the built-in ops account, pinned on every deployment |
| +// (console or not) so every app always has an account. Nothing else may ever |
| +// hold it, which is why minting starts at 2. |
| +const ReservedAccountID = 1 |
| + |
| +// mintAttempts bounds the create-only retry loop. A handful of losses means |
| +// another minter is genuinely racing us; a store that conflicts forever is a |
| +// broken deployment, and spinning on it hides that. |
| +const mintAttempts = 5 |
| + |
| +// NextAccountID is the id a new account should take: one past the highest any |
| +// manifest in the bucket claims, never 0 and never 1 (ops). |
| +// |
| +// Sequential, not random, and the reason is the sqid. The encoder is base-23 |
| +// over a 3-character floor, so a 40-bit random id encodes to NINE characters — |
| +// kass.nyfbskdye.oncarlos.com — a hostname nobody can read out over a phone, |
| +// type without a typo, or recognise as theirs. Sequential ids give bab, bac, |
| +// bad…, and length grows one character per 23x accounts, so a deployment is |
| +// years of growth away from a four-character sqid. |
| +// |
| +// This costs nothing in durability, which is the property the multi-account |
| +// spec exists for. The bug that made randomness look load-bearing was the |
| +// REGISTRY assigning ids in bucket-listing order, so a rebuild — or a second |
| +// box listing in a different order — silently re-homed every platform domain. |
| +// Durability comes from "minted once, recorded in the bucket manifest, |
| +// materialized-never-assigned", and that is unchanged: sequential ids are |
| +// exactly as durable as random ones. |
| +// |
| +// It buys nothing in secrecy either, and the old comments claiming otherwise |
| +// were wrong. Sqids appear in platform hostnames, and every certificate issued |
| +// for one is published in Certificate Transparency logs within seconds — |
| +// crt.sh will list them for anybody who asks. A sqid was never secret, so |
| +// making it unguessable protected nothing. Account existence is hidden where |
| +// it can actually be hidden: the console answers a non-member with the same |
| +// 404 a nonexistent page gets. |
| +// |
| +// A manifest that disagrees with itself still CONSUMES its id here (unlike |
| +// materialization, which skips it): handing that id to somebody else would put |
| +// two objects on one identity, which is the single thing this scheme exists to |
| +// prevent. |
| +func NextAccountID(ctx context.Context, s release.Store) (int64, error) { |
| + keys, err := s.List(ctx, "accounts/") |
| + if err != nil { |
| + return 0, fmt.Errorf("domains: list accounts: %w", err) |
| + } |
| + max := int64(ReservedAccountID) |
| + for _, key := range keys { |
| + if _, ok := AccountManifestSqid(key); !ok { |
| + continue |
| + } |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + // Unreadable is not "free": an id we cannot see is an id we must |
| + // not reuse, so a read failure fails the mint rather than risking |
| + // a collision. |
| + return 0, fmt.Errorf("domains: read %s: %w", key, err) |
| + } |
| + var m AccountManifest |
| + if err := json.Unmarshal(body, &m); err != nil { |
| + continue // unparseable: claims no id we can honour |
| + } |
| + if m.ID > max { |
| + max = m.ID |
| + } |
| + } |
| + return max + 1, nil |
| +} |
| + |
| +// MintAccount allocates the next free account id and publishes its manifest, |
| +// returning the id and its frozen sqid. |
| +// |
| +// The listing NextAccountID does is not a lock, so the write is CREATE-ONLY |
| +// (ifMatch ""): two boxes minting at the same moment both pick the same |
| +// integer, and exactly one Put wins. The loser re-lists and takes the next id |
| +// rather than overwriting — an overwrite would erase a live account's identity |
| +// and re-home every platform domain it owns. |
| +// |
| +// The manifest lands BEFORE any local row exists, everywhere this is called, |
| +// and that ordering is deliberate: a local row whose manifest never landed is |
| +// an account that vanishes on the next rebuild, while a manifest with no local |
| +// row is materialized back on the next pass. |
| +func MintAccount(ctx context.Context, s release.Store, name string, created time.Time) (int64, string, error) { |
| + for attempt := 0; attempt < mintAttempts; attempt++ { |
| + id, err := NextAccountID(ctx, s) |
| + if err != nil { |
| + return 0, "", err |
| + } |
| + sqid := Sqid(id) |
| + body, err := json.Marshal(AccountManifest{ID: id, Sqid: sqid, Name: name, CreatedAt: created.UTC()}) |
| + if err != nil { |
| + return 0, "", err |
| + } |
| + _, err = s.Put(ctx, AccountManifestKey(sqid), body, "") |
| + if err == nil { |
| + return id, sqid, nil |
| + } |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + return 0, "", fmt.Errorf("domains: write account manifest: %w", err) |
| + } |
| + // Somebody else took this id between our listing and our write. Re-list |
| + // and try the next one. |
| + } |
| + return 0, "", fmt.Errorf("domains: could not mint an account id in %d attempts — "+ |
| + "another minter keeps taking the next free id, or the deployment bucket is rejecting "+ |
| + "create-only writes", mintAttempts) |
| +} |
diff --git a/internal/domains/mint_test.go b/internal/domains/mint_test.go| new file mode 100644 |
| index 000000000..b27ed74c2 |
| --- /dev/null |
| +++ b/internal/domains/mint_test.go |
| @@ -0,0 +1,159 @@ |
| +package domains_test |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Account ids are SEQUENTIAL, and the sqid length is why: the frozen encoder |
| +// is base-23, so a 40-bit id encodes to nine characters — |
| +// kass.nyfbskdye.oncarlos.com — which nobody can read out, type, or trust. |
| +// Sequential ids give bab, bac, bad…, growing a character every 23x. |
| +func TestNextAccountIDIsTheNextFreeInteger(t *testing.T) { |
| + ctx := t.Context() |
| + s := release.NewDirStore(t.TempDir()) |
| + |
| + // An empty bucket starts at 2: 0 is not an id and 1 is reserved for the |
| + // built-in ops account on every deployment. |
| + got, err := domains.NextAccountID(ctx, s) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got != 2 { |
| + t.Errorf("NextAccountID(empty) = %d, want 2", got) |
| + } |
| + if len(domains.Sqid(got)) > 3 { |
| + t.Errorf("Sqid(%d) = %q — sequential ids exist to keep this short", got, domains.Sqid(got)) |
| + } |
| + |
| + for _, id := range []int64{2, 7} { |
| + writeManifest(t, s, id, domains.Sqid(id)) |
| + } |
| + if got, err := domains.NextAccountID(ctx, s); err != nil || got != 8 { |
| + t.Errorf("NextAccountID = %d (%v), want 8 — one past the highest claimed", got, err) |
| + } |
| +} |
| + |
| +// A manifest that disagrees with itself still CONSUMES its id. It is skipped |
| +// for materialization (it is corruption, not an account), but handing its id |
| +// to somebody else would make two objects claim one identity — the one thing |
| +// the whole minting scheme exists to prevent. |
| +func TestNextAccountIDCountsIdsItWouldNotMaterialize(t *testing.T) { |
| + ctx := t.Context() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, err := s.Put(ctx, "accounts/wrong/account.json", |
| + []byte(`{"id":9,"sqid":"wrong","name":"Corrupt"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, "accounts/junk/account.json", []byte("{not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := domains.NextAccountID(ctx, s); err != nil || got != 10 { |
| + t.Errorf("NextAccountID = %d (%v), want 10 — a claimed id is claimed", got, err) |
| + } |
| +} |
| + |
| +// Two boxes minting at once must never agree on an id. The listing that picks |
| +// max+1 is not a lock, so the manifest write is CREATE-ONLY: the loser gets a |
| +// CAS conflict, re-lists, and takes the next one. Overwriting would erase an |
| +// account's identity and re-home every domain it owns. |
| +func TestMintAccountLosesTheRaceAndTakesTheNextID(t *testing.T) { |
| + ctx := t.Context() |
| + inner := release.NewDirStore(t.TempDir()) |
| + planted := false |
| + s := &racingStore{Store: inner, beforePut: func(key string) { |
| + if planted { |
| + return |
| + } |
| + planted = true |
| + // Another minter got there first, between our List and our Put. |
| + writeManifest(t, inner, 2, domains.Sqid(2)) |
| + }} |
| + |
| + id, sqid, err := domains.MintAccount(ctx, s, "Kass", time.Unix(1700000000, 0)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if id != 3 || sqid != domains.Sqid(3) { |
| + t.Errorf("minted (%d, %q), want the next free id 3 (%q)", id, sqid, domains.Sqid(3)) |
| + } |
| + // The competitor's manifest is untouched. |
| + body, _, err := inner.Get(ctx, "accounts/"+domains.Sqid(2)+"/account.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var m struct { |
| + Name string `json:"name"` |
| + } |
| + if err := json.Unmarshal(body, &m); err != nil || m.Name != "Competitor" { |
| + t.Errorf("the racing minter's manifest was overwritten: %s", body) |
| + } |
| + // And ours landed, with the id in the body matching the sqid in the path. |
| + body, _, err = inner.Get(ctx, "accounts/"+sqid+"/account.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var mine struct { |
| + ID int64 `json:"id"` |
| + Sqid string `json:"sqid"` |
| + Name string `json:"name"` |
| + } |
| + if err := json.Unmarshal(body, &mine); err != nil { |
| + t.Fatal(err) |
| + } |
| + if mine.ID != id || mine.Sqid != sqid || mine.Name != "Kass" { |
| + t.Errorf("minted manifest = %+v, want id %d sqid %q", mine, id, sqid) |
| + } |
| +} |
| + |
| +// Retries are BOUNDED. A store that conflicts forever is a broken deployment, |
| +// not something to spin on: fail loudly rather than loop. |
| +func TestMintAccountGivesUpLoudly(t *testing.T) { |
| + ctx := t.Context() |
| + inner := release.NewDirStore(t.TempDir()) |
| + next := int64(2) |
| + s := &racingStore{Store: inner, beforePut: func(key string) { |
| + writeManifest(t, inner, next, domains.Sqid(next)) |
| + next++ |
| + }} |
| + _, _, err := domains.MintAccount(ctx, s, "Kass", time.Now()) |
| + if err == nil { |
| + t.Fatal("minting against a permanently racing store succeeded") |
| + } |
| + if !strings.Contains(err.Error(), "account") { |
| + t.Errorf("give-up error does not say what failed: %v", err) |
| + } |
| +} |
| + |
| +func writeManifest(t *testing.T, s release.Store, id int64, sqid string) { |
| + t.Helper() |
| + body := []byte(`{"id":` + itoa(id) + `,"sqid":"` + sqid + `","name":"Competitor",` + |
| + `"created_at":"2026-08-02T09:00:00Z"}`) |
| + if _, err := s.Put(context.Background(), "accounts/"+sqid+"/account.json", body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func itoa(i int64) string { |
| + b, _ := json.Marshal(i) |
| + return string(b) |
| +} |
| + |
| +// racingStore runs a hook immediately before each Put, so a test can plant the |
| +// object the minter is about to create — the one interleaving that matters and |
| +// the one a sequential allocator has to survive. |
| +type racingStore struct { |
| + release.Store |
| + beforePut func(key string) |
| +} |
| + |
| +func (r *racingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + r.beforePut(key) |
| + return r.Store.Put(ctx, key, body, ifMatch) |
| +} |
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| index a38392895..2d0845f46 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -84,7 +84,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // the built-in ops account. |
| known := map[string]bool{OpsSqid: true} // sqid -> materialized |
| for _, key := range acctKeys { |
| - sqid, ok := accountManifestSqid(key) |
| + sqid, ok := AccountManifestSqid(key) |
| if !ok { |
| continue |
| } |
| @@ -319,10 +319,16 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // deployment has it, console or not. |
| var OpsSqid = Sqid(1) |
| -// accountManifestSqid reports whether key is an account manifest |
| +// AccountManifestSqid reports whether key is an account manifest |
| // (accounts/<sqid>/account.json, exactly one path level deep) and which sqid |
| // its PATH claims. |
| -func accountManifestSqid(key string) (string, bool) { |
| +// |
| +// Exported because the console materializes accounts from the SAME objects |
| +// this reconcile pass does. One parser, not two: if the two ever disagreed |
| +// about what counts as a manifest, the console and the registry would hold |
| +// different account sets on the same box — and the account set is what tells a |
| +// qualified bucket key from a legacy one. |
| +func AccountManifestSqid(key string) (string, bool) { |
| rest, ok := strings.CutPrefix(key, "accounts/") |
| if !ok { |
| return "", false |