carlos / platform Public

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>
Paul Campbell fc94c671abd5664adf24dfd11fcfbedafbb2be7b parent 4875560parent 349c1ed
19 files changed, +1581 −269
  • README.md +9 −7
  • accounts.go +9 −49
  • cmd/console/main.go +25 −1
  • cmd/console/main_test.go +62 −18
  • cmd/console/member.go +213 −0
  • cmd/console/member_test.go +336 −0
  • docs/superpowers/specs/2026-08-02-multi-account-design.md +9 −4
  • e2e_test.go +5 −2
  • internal/console/account.go +10 −42
  • internal/console/account_test.go +146 −58
  • internal/console/materialize.go +104 −0
  • internal/console/materialize_test.go +142 −0
  • internal/console/server.go +49 −19
  • internal/console/server_account_test.go +6 −6
  • internal/console/store.go +138 −60
  • internal/console/store_test.go +11 −0
  • internal/domains/mint.go +139 −0
  • internal/domains/mint_test.go +159 −0
  • internal/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