carlos / platform Public

console: materialize accounts from the bucket — the console is not the only minter

`carlos accounts create` writes accounts/<sqid>/account.json and the registry
row and never opens console state (deliberately: the carlos binary has no
business inside the console's database). The console therefore never learned of
those accounts, so `console member -add … <sqid>` refused a sqid that
demonstrably exists — on the very box that had just created it.

MaterializeAccounts reads the SAME manifests the domains reconciler reads,
through the same key parser (domains.AccountManifestSqid, exported for it: two
parsers could disagree about what counts as a manifest, and the account set is
what tells a qualified bucket key from a legacy one). Runs on console startup
and before every membership operation.

The manifest's id is taken as GIVEN — nothing here mints or reassigns one. A
manifest that disagrees with itself (path sqid, body sqid and Sqid(id) must be
the same string) is corruption, not a new account, and is skipped loudly.
INSERT-only, so a known account keeps its row, name and members.

Store.EnsureAccount deliberately creates NO membership, unlike CreateAccount:
`carlos accounts create` records no owner anywhere, so the console has nobody
to name, and inventing one would mint authority out of thin air. An ownerless
row is honest and harmless — `console member -add <addr> <sqid> -role owner`
is how it gets an owner, which is now a tool that exists.

Best-effort throughout: no bucket configured, or an unreachable one, must never
be what stops an operator removing a membership.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude 5e22bada7145f2ba83ba3873a6b67cc15c3747cb parent 2ab8080
8 files changed, +355 −6
  • cmd/console/main.go +13 −0
  • cmd/console/member.go +13 −2
  • cmd/console/member_test.go +42 −1
  • internal/console/materialize.go +104 −0
  • internal/console/materialize_test.go +142 −0
  • internal/console/server.go +8 −0
  • internal/console/store.go +24 −0
  • internal/domains/reconcile.go +9 −3
diff --git a/cmd/console/main.go b/cmd/console/main.go
index bcd5db3d3..3f771b83c 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -231,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/member.go b/cmd/console/member.go
index 03aed9239..bbf513893 100644
--- a/cmd/console/member.go
+++ b/cmd/console/member.go
@@ -1,6 +1,7 @@
package main
import (
+ "context"
"flag"
"fmt"
"log"
@@ -77,14 +78,24 @@ func runMember(args []string) {
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 — create one with: "+
- "carlos accounts create -name <name>", sqid, envOr("CONSOLE_DB", "/data/console/console.db"))
+ 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 {
diff --git a/cmd/console/member_test.go b/cmd/console/member_test.go
index b5a46d7b9..b786ee3c2 100644
--- a/cmd/console/member_test.go
+++ b/cmd/console/member_test.go
@@ -7,6 +7,8 @@ import (
"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
@@ -158,7 +160,7 @@ func TestMemberVerbRejectsBadArguments(t *testing.T) {
want []string
}{
{"unknown account", []string{"member", "-add", "dev@keymail.dev", "nosuch"},
- []string{"nosuch", "carlos accounts create"}},
+ []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"},
@@ -180,6 +182,45 @@ func TestMemberVerbRejectsBadArguments(t *testing.T) {
}
}
+// `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 roleOf(t *testing.T, dbPath string, accountID int64, address string) string {
t.Helper()
st, err := console.Open(dbPath)
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 6d1045044..915d50e1d 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -157,6 +157,14 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
+ // 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)
// 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.
diff --git a/internal/console/store.go b/internal/console/store.go
index 21d2e3cca..ac9869997 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -433,6 +433,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/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