// 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" "strings" "time" "github.com/carlosframework/platform/internal/release" ) // AccountManifest is the wire shape at accounts//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"` // Owner is the keymail address that should hold the first owner membership // of this account. ADDITIVE and omitempty: every manifest written before // this field existed has no owner and stays ownerless, exactly as it // materialized before. // // It is here — in the bucket, not in a console table — because the bucket is // the control plane, and this field exists to fix a PRODUCTION failure. // `carlos accounts create` recorded an owner nowhere at all, so after kass // and keymail were migrated into fresh accounts the operator could not reach // either of them in the console: the account existed, had no members, and // membership IS authorization. The only way in was hand-running `console // member -add … -role owner`, which nothing told anybody to do. // // Putting it in the manifest rather than teaching the carlos binary to open // the console database keeps a boundary that was deliberately drawn and // enforced in review: console state is the console's authority. Every box // that materializes this object converges on the same owner, so a // CLI-created account and a console-created one end up in the same state // instead of two. // // Always NORMALIZED (NormalizeAddress) before it is written: the console's // memberships primary key is literal text, so an unnormalized address here // would materialize a membership the real session can never match. Owner string `json:"owner,omitempty"` // DeletedAt marks a retired account. The manifest is TOMBSTONED rather than // removed, and the difference is the whole point: deleting the object would // free the id for reuse (NextAccountID takes one past the highest id any // manifest claims), and a reused id is a reused sqid — a hostname that used // to be somebody's, handed to somebody else, with the old name still in // Certificate Transparency and in whatever DNS caches and bookmarks the // world kept. An account id is spent once. // // Every consumer that MATERIALIZES this manifest must skip a tombstoned one // (console.MaterializeAccounts, domains.Reconcile); every consumer that // ALLOCATES against it must still count it (NextAccountID). That split is // the invariant — get it backwards in either direction and you either // resurrect a deleted account or hand its identity to a new one. DeletedAt time.Time `json:"deleted_at,omitempty"` } // Deleted reports whether this manifest is a tombstone. func (m AccountManifest) Deleted() bool { return !m.DeletedAt.IsZero() } // NormalizeAddress is the one normalization every keymail address gets before // it is stored or compared: trimmed and lowercased. Defined here, next to the // manifest field that carries one, so the CLI can apply the console's rule // without importing — or opening — console state. func NormalizeAddress(a string) string { return strings.ToLower(strings.TrimSpace(a)) } // ValidAddress is the platform's one shape check on a keymail address: // @, no whitespace. console.ValidAddress delegates to it, so the // sign-in form, `console member -add` and `carlos accounts create -owner` // cannot drift into accepting different strings — an address one of them takes // and another refuses is a membership somebody can create and nobody can use. func ValidAddress(a string) bool { i := strings.LastIndex(a, "@") return i > 0 && i < len(a)-1 && !strings.ContainsAny(a, " \t\r\n") } // 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. // // An id whose sqid is reserved (IsReservedSqid, #285) is stepped over: the // answer is the first id past the highest claimed one whose sqid is free. No // manifest or tombstone is written for the skipped id, and none is needed. // The skip is a pure function of the id, so every minter computes the same // answer, a restart changes nothing, and once a later id is minted the // skipped one sits below the highest claim, where nothing allocates. 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 skipReservedIDs(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. // // owner is the address that should hold the account's first owner membership. // "" mints an ownerless account, which is what every pre-owner manifest is and // stays; anything else must be a usable keymail address, checked HERE rather // than at each caller because the manifest is written once and read forever — // a malformed owner would be permanent, and the id it consumed is not // reusable. The check happens before the first listing, so a refused mint // writes nothing at all. func MintAccount(ctx context.Context, s release.Store, name, owner string, created time.Time) (int64, string, error) { owner = NormalizeAddress(owner) if owner != "" && !ValidAddress(owner) { return 0, "", fmt.Errorf("domains: %q is not a keymail address — expected @, e.g. paul@keymail.dev", owner) } 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(), Owner: owner, }) 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) } // TombstoneAccount retires an account in the bucket — the control plane, and // therefore the only place a deletion is real. // // A console that deleted only its own rows would watch the account come back: // every console start materializes from these manifests, so the next deploy // would re-create the account AND re-adopt the manifest's owner, handing it // back to whoever held it at creation even if ownership had since been // transferred away. Deleting has to happen where identity lives. // // The manifest is REWRITTEN, not removed — see AccountManifest.DeletedAt for // why the id must stay spent. Name and CreatedAt are kept so the record still // says what this account was; Owner is cleared, because a tombstone is not a // grant and a consumer too old to know about DeletedAt should at worst // materialize something nobody owns rather than something it hands to a // former owner. // // CAS overwrite with one retry, the same shape as WriteAccountManifest. func TombstoneAccount(ctx context.Context, s release.Store, sqid string, now time.Time) error { if sqid == Sqid(ReservedAccountID) { return fmt.Errorf("domains: the ops account cannot be deleted") } key := AccountManifestKey(sqid) tombstone := func() error { body, etag, err := s.Get(ctx, key) if err != nil { return fmt.Errorf("domains: read %s: %w", key, err) } var m AccountManifest if err := json.Unmarshal(body, &m); err != nil { return fmt.Errorf("domains: parse %s: %w", key, err) } if m.Deleted() { return nil // already retired; nothing to do and nothing to undo } m.Owner = "" m.DeletedAt = now.UTC() out, err := json.Marshal(m) if err != nil { return err } _, err = s.Put(ctx, key, out, etag) return err } if err := tombstone(); errors.Is(err, release.ErrCASConflict) { return tombstone() // one retry on a lost race; a second loss surfaces } else if err != nil { return err } return nil }