accounts: the owner travels in the manifest, so the console can see it
An account minted from the command line reached the console with no members, and an account with no members is one nobody can open — apps were migrated into accounts that were then invisible in the console to the very operator who created them. The fix is to put the owner where identity already lives: the bucket manifest. domains.MintAccountOwned records it, the console's materialization pass turns it into the account's first membership, and the console's own mint stamps it too so a second box materializing the same account knows who it belongs to. An empty owner stays legal and means what it always did — unclaimed, and administrable box-side — because every manifest written before this field existed is in that state. An owner the sign-in form would refuse is skipped loudly rather than written: a typo'd address is a grant nobody could ever claim.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5 files changed,
+185
−12
internal/console/authowner_test.go+116 −0internal/console/materialize.go+23 −0internal/console/server.go+4 −1internal/console/store.go+12 −7internal/domains/mint.go+30 −4
diff --git a/internal/console/authowner_test.go b/internal/console/authowner_test.go| new file mode 100644 |
| index 000000000..740d5fdd5 |
| --- /dev/null |
| +++ b/internal/console/authowner_test.go |
| @@ -0,0 +1,116 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// An account minted with an owner in its manifest is materialized WITH that |
| +// owner as a member. This is the whole point of naming an owner at creation: |
| +// an account whose owner is written nowhere is an account nobody can open, |
| +// which is the failure this feature exists to end. |
| +func TestMaterializeHonoursTheManifestOwner(t *testing.T) { |
| + ctx := context.Background() |
| + rel := release.NewDirStore(t.TempDir()) |
| + st := openTestStore(t) |
| + now := time.Now() |
| + |
| + id, sqid, err := domains.MintAccountOwned(ctx, rel, "Kass", "paul@keymail.dev", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _, err := rel.Get(ctx, domains.AccountManifestKey(sqid)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var m domains.AccountManifest |
| + if err := json.Unmarshal(body, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + if m.Owner != "paul@keymail.dev" { |
| + t.Fatalf("manifest owner = %q, want paul@keymail.dev", m.Owner) |
| + } |
| + |
| + n, err := MaterializeAccounts(ctx, rel, st) |
| + if err != nil || n != 1 { |
| + t.Fatalf("MaterializeAccounts = %d %v, want 1", n, err) |
| + } |
| + role, err := st.Role(id, "paul@keymail.dev") |
| + if err != nil || role != RoleOwner { |
| + t.Fatalf("role after materializing = %q %v, want owner", role, err) |
| + } |
| + |
| + // Re-materializing changes nothing — no membership is re-created, no role |
| + // is reset. This runs on every console start. |
| + if err := st.AddMember(id, "paul@keymail.dev", RoleMember, now); err == nil { |
| + t.Fatal("demoting the last owner should have been refused") |
| + } |
| + if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 0 { |
| + t.Fatalf("second MaterializeAccounts = %d %v, want 0", n, err) |
| + } |
| +} |
| + |
| +// A manifest with no owner still materializes — every account minted before |
| +// this existed has none — and still creates no membership out of thin air. |
| +func TestMaterializeWithoutAnOwnerCreatesNoMembership(t *testing.T) { |
| + ctx := context.Background() |
| + rel := release.NewDirStore(t.TempDir()) |
| + st := openTestStore(t) |
| + id, _, err := domains.MintAccount(ctx, rel, "Legacy", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := MaterializeAccounts(ctx, rel, st); err != nil { |
| + t.Fatal(err) |
| + } |
| + members, err := st.Members(id) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(members) != 0 { |
| + t.Fatalf("members = %v, want none", members) |
| + } |
| +} |
| + |
| +// A manifest owner that the sign-in form would refuse is not a membership |
| +// anybody could ever claim, so it is skipped rather than written. |
| +func TestMaterializeRefusesAnUnusableOwner(t *testing.T) { |
| + ctx := context.Background() |
| + rel := release.NewDirStore(t.TempDir()) |
| + st := openTestStore(t) |
| + id, _, err := domains.MintAccountOwned(ctx, rel, "Typo", "not-an-address", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := MaterializeAccounts(ctx, rel, st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if members, err := st.Members(id); err != nil || len(members) != 0 { |
| + t.Fatalf("members = %v %v, want none", members, err) |
| + } |
| +} |
| + |
| +// The console's own mint stamps the owner into the bucket too, so a second |
| +// box materializing the same account knows who it belongs to. |
| +func TestConsoleMintStampsTheOwner(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-owner") |
| + _, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "Kass"}, token) |
| + sqid := str(t, out, "sqid") |
| + body, _, err := fx.rel.Get(context.Background(), AccountManifestKey(sqid)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var m domains.AccountManifest |
| + if err := json.Unmarshal(body, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + if m.Owner != "paul@keymail.dev" { |
| + t.Fatalf("manifest owner = %q, want paul@keymail.dev", m.Owner) |
| + } |
| +} |
diff --git a/internal/console/materialize.go b/internal/console/materialize.go| index a8ec794a6..b6762d8de 100644 |
| --- a/internal/console/materialize.go |
| +++ b/internal/console/materialize.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "encoding/json" |
| "log" |
| + "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| @@ -87,6 +88,28 @@ func MaterializeAccounts(ctx context.Context, s release.Store, st *Store) (int, |
| log.Printf("console: did not materialize %s: account id %d is already taken here", key, m.ID) |
| continue |
| } |
| + // The manifest's owner, when it names one, becomes the account's first |
| + // membership. This is what makes `carlos accounts create -owner <addr>` |
| + // (and a logged-in CLI's create) reach the console at all: without it |
| + // the account materializes with no members, and an account with no |
| + // members is one nobody can open — the exact failure this pass used to |
| + // leave behind. Only on the FIRST materialization, and only for an |
| + // address the sign-in form would accept: a typo'd owner is a grant |
| + // nobody could ever claim, and inventing it would just be a stranded |
| + // row with a confusing name on it. |
| + if m.Owner != "" { |
| + if !ValidAddress(m.Owner) { |
| + log.Printf("console: account %s names owner %q, which is not a keymail address — "+ |
| + "nobody could sign in as it, so no membership was created. Fix it with: "+ |
| + "console member -add <address> %s -role owner", m.Sqid, m.Owner, m.Sqid) |
| + } else if err := st.AddMember(m.ID, strings.ToLower(m.Owner), RoleOwner, created); err != nil { |
| + // Loud but not fatal: the account itself materialized, and an |
| + // operator can still add the owner box-side. Failing the whole |
| + // pass here would hide every other account behind one bad row. |
| + log.Printf("console: account %s materialized but its owner %s could not be recorded: %v — "+ |
| + "add them with: console member -add %s %s -role owner", m.Sqid, m.Owner, err, m.Owner, m.Sqid) |
| + } |
| + } |
| log.Printf("console: materialized account %s (%s) from the bucket", m.Sqid, m.Name) |
| n++ |
| } |
diff --git a/internal/console/server.go b/internal/console/server.go| index 3fc369884..1fc8a78b4 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -383,7 +383,10 @@ func (s *Server) currentAccount(ctx context.Context, addr string) (string, error |
| // next rebuild — worse than a "new account" button that reports the outage. |
| func (s *Server) mintAccount(ctx context.Context, name, ownerAddr string) (*Account, error) { |
| now := time.Now() |
| - id, sqid, err := domains.MintAccount(ctx, s.cfg.Releases, name, now) |
| + // The owner goes into the MANIFEST as well as the local row: a second box |
| + // materializing this account from the bucket must know who it belongs to, |
| + // or the account arrives there with no members and nobody can open it. |
| + id, sqid, err := domains.MintAccountOwned(ctx, s.cfg.Releases, name, ownerAddr, now) |
| if err != nil { |
| return nil, err |
| } |
diff --git a/internal/console/store.go b/internal/console/store.go| index a92fb3ba8..86b483dca 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -496,13 +496,18 @@ func (s *Store) CreateAccount(id int64, sqid, name, ownerAddress string, now tim |
| // 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. |
| +// Unlike CreateAccount it deliberately creates no membership of its own, and |
| +// that is not an oversight: inventing an owner — 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. |
| +// |
| +// The account manifest may NAME an owner, and when it does the caller |
| +// (MaterializeAccounts) records that membership right after this — that is not |
| +// invention, it is the creator's own instruction travelling with the identity. |
| +// Manifests written before that field existed name nobody, and land here |
| +// unclaimed exactly as they always did. |
| // |
| // INSERT OR IGNORE, never REPLACE: an account the console already knows must |
| // keep its id, its name and its members. |
diff --git a/internal/domains/mint.go b/internal/domains/mint.go| index 41ab52da8..b9ce17372 100644 |
| --- a/internal/domains/mint.go |
| +++ b/internal/domains/mint.go |
| @@ -22,9 +22,16 @@ import ( |
| // 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"` |
| + ID int64 `json:"id"` |
| + Sqid string `json:"sqid"` |
| + Name string `json:"name"` |
| + // Owner is the keymail address that owns the account, as named at |
| + // creation. Optional: every manifest written before this field existed |
| + // has none, and an account with no owner in the bucket is exactly what it |
| + // was before — unclaimed, and administrable only box-side. The console's |
| + // materialization pass turns a named owner into the account's first |
| + // membership. |
| + Owner string `json:"owner,omitempty"` |
| CreatedAt time.Time `json:"created_at"` |
| } |
| @@ -113,13 +120,32 @@ func NextAccountID(ctx context.Context, s release.Store) (int64, error) { |
| // 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) { |
| + return MintAccountOwned(ctx, s, name, "", created) |
| +} |
| + |
| +// MintAccountOwned is MintAccount that also records WHO the account belongs |
| +// to, in the manifest, where every box can see it. |
| +// |
| +// The owner has to travel with the identity rather than living only in the |
| +// minter's local database, because the minter is frequently not the console: |
| +// `carlos accounts create` writes the bucket and the registry and never opens |
| +// console state at all. Before this, an account minted from the command line |
| +// arrived at the console with no members, so the operator who created it could |
| +// not open it — the account existed, held apps, and was unreachable. The |
| +// console's materialization pass reads this field and makes that address the |
| +// first owner. |
| +// |
| +// An empty owner stays legal and means exactly what it did before: nobody is |
| +// claimed, and no membership is invented for the account. Every manifest |
| +// written before this field existed is in that state. |
| +func MintAccountOwned(ctx context.Context, s release.Store, name, owner 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()}) |
| + body, err := json.Marshal(AccountManifest{ID: id, Sqid: sqid, Name: name, Owner: owner, CreatedAt: created.UTC()}) |
| if err != nil { |
| return 0, "", err |
| } |