carlos / platform Public

console: accounts, memberships, invites — membership is the permission

Accounts are the tenancy primitive: id minted here (crypto/rand, 40 bits,
never 0 or 1 — ops is pinned at 1), sqid as the public identity, name
deliberately not unique. CreateAccount writes the account and its first owner
in one transaction: an account with no owner is unadministrable.

Memberships supersede the per-app owners grant — app access follows from
account membership. Two roles only. The last owner can neither be removed nor
demoted, guarded inside the transaction so two concurrent removals cannot both
pass the count.

Invites are single-use, 7-day, revocable, and addressed: an invite is not a
bearer token, so accepting requires being signed in as the address it was sent
to. Revoking DELETES, so a leaked link is indistinguishable from one that
never existed.

users.last_account carries the MRU landing. apps gains account_id and — the
one table rebuild in the console — PRIMARY KEY(account_id, app), because
"unique within an account" is not something an ALTER can express and a console
that refuses a second account's "blog" until an operator runs a verb is a
console that is wrong by default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell be02113e6065522c97289e0a0afe657c2a5afd60 parent eb01315
3 files changed, +726 −5
  • internal/console/account.go +39 −0
  • internal/console/account_test.go +216 −0
  • internal/console/store.go +471 −5
diff --git a/internal/console/account.go b/internal/console/account.go
new file mode 100644
index 000000000..a0c438e16
--- /dev/null
+++ b/internal/console/account.go
@@ -0,0 +1,39 @@
+// 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
+// docs/superpowers/specs/2026-08-02-multi-account-design.md §1.
+package console
+
+import (
+ "crypto/rand"
+ "encoding/binary"
+)
+
+// 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
+ }
+ }
+}
diff --git a/internal/console/account_test.go b/internal/console/account_test.go
new file mode 100644
index 000000000..46aabcdf9
--- /dev/null
+++ b/internal/console/account_test.go
@@ -0,0 +1,216 @@
+package console
+
+import (
+ "testing"
+ "time"
+)
+
+// 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)
+ }
+ if id > 1<<40 {
+ t.Fatalf("id %d exceeds 40 bits", id)
+ }
+ if seen[id] {
+ t.Fatalf("repeat within 200 draws: %d", id)
+ }
+ seen[id] = true
+ }
+}
+
+func TestCreateAccountMakesOwner(t *testing.T) {
+ st := openTestStore(t)
+ if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ role, _ := st.Role(42, "paul@keymail.dev")
+ if role != "owner" {
+ t.Errorf("creator role = %q, want owner", role)
+ }
+ if role, _ := st.Role(42, "someone@keymail.dev"); role != "" {
+ t.Errorf("non-member role = %q, want empty", role)
+ }
+ accts, err := st.AccountsFor("paul@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(accts) != 1 || accts[0].Sqid != "cdef" || accts[0].Role != "owner" || accts[0].Name != "Kass" {
+ t.Errorf("AccountsFor = %+v", accts)
+ }
+ if accts, _ := st.AccountsFor("someone@keymail.dev"); len(accts) != 0 {
+ t.Errorf("a non-member sees accounts: %+v", accts)
+ }
+}
+
+// Every account has at least one owner, always: the last one cannot be
+// removed (transfer first). Otherwise an account becomes unadministrable —
+// nobody left who can invite, rename, or delete it.
+func TestLastOwnerCannotBeRemoved(t *testing.T) {
+ st := openTestStore(t)
+ if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.RemoveMember(42, "paul@keymail.dev"); err == nil {
+ t.Fatal("removing the last owner must fail")
+ }
+ // A member is not a second owner: demoting the only owner is the same
+ // hole by another name.
+ if err := st.AddMember(42, "kim@keymail.dev", "member", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.RemoveMember(42, "paul@keymail.dev"); err == nil {
+ t.Fatal("a member does not make the owner removable")
+ }
+ if err := st.AddMember(42, "sam@keymail.dev", "owner", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.RemoveMember(42, "paul@keymail.dev"); err != nil {
+ t.Fatalf("removable once a second owner exists: %v", err)
+ }
+}
+
+func TestLastOwnerCannotBeDemoted(t *testing.T) {
+ st := openTestStore(t)
+ if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.AddMember(42, "paul@keymail.dev", "member", time.Now()); err == nil {
+ t.Fatal("demoting the last owner must fail")
+ }
+ if role, _ := st.Role(42, "paul@keymail.dev"); role != "owner" {
+ t.Errorf("refused demotion still changed the role: %q", role)
+ }
+}
+
+func TestInviteSingleUseAndExpiring(t *testing.T) {
+ st := openTestStore(t)
+ now := time.Now()
+ if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ tok, err := st.CreateInvite(42, "sam@keymail.dev", "paul@keymail.dev", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.AcceptInvite(tok, "wrong@keymail.dev", now); err == nil {
+ t.Error("an invite must only be acceptable by its addressee")
+ }
+ if _, err := st.AcceptInvite(tok, "sam@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if role, _ := st.Role(42, "sam@keymail.dev"); role != "member" {
+ t.Errorf("accepted invite role = %q, want member", role)
+ }
+ if _, err := st.AcceptInvite(tok, "sam@keymail.dev", now); err == nil {
+ t.Error("invite must be single-use")
+ }
+ tok2, _ := st.CreateInvite(42, "kim@keymail.dev", "paul@keymail.dev", now)
+ if _, err := st.AcceptInvite(tok2, "kim@keymail.dev", now.Add(8*24*time.Hour)); err == nil {
+ t.Error("invite must expire after 7 days")
+ }
+}
+
+func TestInvitesListedAndRevocable(t *testing.T) {
+ st := openTestStore(t)
+ now := time.Now()
+ if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ tok, _ := st.CreateInvite(42, "sam@keymail.dev", "paul@keymail.dev", now)
+ pending, err := st.PendingInvites(42)
+ if err != nil || len(pending) != 1 || pending[0].Address != "sam@keymail.dev" {
+ t.Fatalf("PendingInvites = %+v %v", pending, err)
+ }
+ if err := st.RevokeInvite(tok); err != nil {
+ t.Fatal(err)
+ }
+ if pending, _ := st.PendingInvites(42); len(pending) != 0 {
+ t.Errorf("revoked invite still pending: %+v", pending)
+ }
+ if _, err := st.AcceptInvite(tok, "sam@keymail.dev", now); err == nil {
+ t.Error("a revoked invite must not be acceptable")
+ }
+}
+
+func TestLastAccountRoundTrips(t *testing.T) {
+ st := openTestStore(t)
+ if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ // No history yet is 0, not an error.
+ if got, err := st.LastAccount("paul@keymail.dev"); err != nil || got != 0 {
+ t.Fatalf("LastAccount before any visit = %d, %v; want 0, nil", got, err)
+ }
+ if err := st.SetLastAccount("paul@keymail.dev", 42); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := st.LastAccount("paul@keymail.dev"); got != 42 {
+ t.Errorf("LastAccount = %d, want 42", got)
+ }
+ // It works for an address that has never signed in (the invite case).
+ if err := st.SetLastAccount("newcomer@keymail.dev", 42); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := st.LastAccount("newcomer@keymail.dev"); got != 42 {
+ t.Errorf("LastAccount(newcomer) = %d, want 42", got)
+ }
+}
+
+// Account names are deliberately not unique — identity is the sqid — and
+// AccountsFor must order deterministically (creation order) so "your first
+// membership" is a stable answer.
+func TestAccountsForOrdersByCreation(t *testing.T) {
+ st := openTestStore(t)
+ now := time.Now()
+ if err := st.CreateAccount(7, "cdef", "Blog", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.CreateAccount(9, "cdeg", "Blog", "paul@keymail.dev", now.Add(time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ accts, _ := st.AccountsFor("paul@keymail.dev")
+ if len(accts) != 2 || accts[0].Sqid != "cdef" || accts[1].Sqid != "cdeg" {
+ t.Fatalf("AccountsFor = %+v, want cdef then cdeg", accts)
+ }
+}
+
+func TestAccountBySqidAndAppOwnership(t *testing.T) {
+ st := openTestStore(t)
+ now := time.Now()
+ if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ a, err := st.AccountBySqid("cdef")
+ if err != nil || a == nil || a.ID != 42 || a.Name != "Kass" {
+ t.Fatalf("AccountBySqid = %+v %v", a, err)
+ }
+ if a, err := st.AccountBySqid("nosuch"); err != nil || a != nil {
+ t.Fatalf("AccountBySqid(absent) = %+v %v, want nil nil", a, err)
+ }
+ // Apps belong to an account now; the same name may exist in two.
+ if created, err := st.CreateAppIn(42, "blog", "paul@keymail.dev", now); err != nil || !created {
+ t.Fatalf("CreateAppIn = %v %v", created, err)
+ }
+ if created, err := st.CreateAppIn(42, "blog", "paul@keymail.dev", now); err != nil || created {
+ t.Fatalf("second CreateAppIn in the same account = %v %v, want false nil", created, err)
+ }
+ if err := st.CreateAccount(43, "cdeg", "Other", "sam@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if created, err := st.CreateAppIn(43, "blog", "sam@keymail.dev", now); err != nil || !created {
+ t.Fatalf("same app name in another account must be free: %v %v", created, err)
+ }
+ apps, err := st.AppsIn(42)
+ if err != nil || len(apps) != 1 || apps[0] != "blog" {
+ t.Fatalf("AppsIn(42) = %v %v", apps, err)
+ }
+}
diff --git a/internal/console/store.go b/internal/console/store.go
index ea6d73ea3..ab87e7f02 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -125,6 +125,40 @@ func (s *Store) migrate() error {
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
+);`,
+ // ---- multi-account -------------------------------------------------
+ // The account is the tenancy primitive: it owns apps and has members.
+ // id is MINTED (NewAccountID), 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 (
+ id INTEGER PRIMARY KEY,
+ sqid TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+);`,
+ // Membership replaces the per-app owners grant: app access follows
+ // from account membership. Exactly two roles, owner and member.
+ `CREATE TABLE IF NOT EXISTS memberships (
+ account_id INTEGER NOT NULL,
+ address TEXT NOT NULL,
+ role TEXT NOT NULL,
+ added_at INTEGER NOT NULL,
+ PRIMARY KEY (account_id, address)
+);`,
+ // Invites are single-use, expiring, revocable. The token is stored in
+ // the clear (unlike sessions/confirmations): it is delivered into the
+ // addressee's own Keymail inbox and is worthless without also being
+ // signed in as that address, and an owner has to be able to LIST and
+ // revoke a specific pending invite.
+ `CREATE TABLE IF NOT EXISTS invites (
+ token TEXT PRIMARY KEY,
+ account_id INTEGER NOT NULL,
+ address TEXT NOT NULL,
+ invited_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ accepted_at INTEGER NOT NULL DEFAULT 0
);`,
} {
if _, err := s.db.Exec(stmt); err != nil {
@@ -135,12 +169,66 @@ func (s *Store) migrate() error {
// re-running is always safe.
for _, stmt := range []string{
`ALTER TABLE users ADD COLUMN operator INTEGER NOT NULL DEFAULT 0;`,
+ // Where a returning user lands: the account they were last in.
+ `ALTER TABLE users ADD COLUMN last_account INTEGER NOT NULL DEFAULT 0;`,
+ // Apps belong to an account. The column lands with default 1 (ops), so
+ // every already-claimed app keeps working; the PRIMARY KEY stays `app`
+ // until the migration verb rebuilds the table, because changing a PK
+ // is not an additive migration.
+ `ALTER TABLE apps ADD COLUMN account_id INTEGER NOT NULL DEFAULT 1;`,
} {
if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
}
}
- return nil
+ return s.migrateAppsKey()
+}
+
+// migrateAppsKey moves the apps table from PRIMARY KEY(app) — a GLOBAL name
+// land-grab — to PRIMARY KEY(account_id, app), which is what "app names are
+// unique within an account, never globally" actually means. SQLite cannot
+// ALTER a primary key, so this is the one place in the console that rebuilds
+// a table rather than appending to the ALTER list.
+//
+// It is safe to do here rather than behind an operator verb because every row
+// is carried across unchanged (account_id already defaulted to 1 = ops), the
+// table is small, and the whole thing runs in one transaction. Leaving it to
+// a verb would mean a console that silently refuses a second account's "blog"
+// until someone remembers to run it.
+//
+// Idempotent: it does nothing once the new key is in place.
+func (s *Store) migrateAppsKey() error {
+ var ddl string
+ err := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='apps'`).Scan(&ddl)
+ if err != nil {
+ return err
+ }
+ if strings.Contains(ddl, "PRIMARY KEY (account_id, app)") {
+ return nil // already rebuilt
+ }
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ for _, stmt := range []string{
+ `CREATE TABLE apps_new (
+ app TEXT NOT NULL,
+ account_id INTEGER NOT NULL DEFAULT 1,
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ PRIMARY KEY (account_id, app)
+);`,
+ `INSERT INTO apps_new (app, account_id, created_by, created_at)
+ SELECT app, account_id, created_by, created_at FROM apps;`,
+ `DROP TABLE apps;`,
+ `ALTER TABLE apps_new RENAME TO apps;`,
+ } {
+ if _, err := tx.Exec(stmt); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
}
// ---- owners -----------------------------------------------------------------
@@ -194,13 +282,358 @@ func (s *Store) Owners() (map[string][]string, error) {
return out, rows.Err()
}
+// ---- accounts, memberships, invites -----------------------------------------
+
+// Roles. Two, not a matrix: enough authority to make invites safe, without
+// inventing a permissions model before there is a second user to test it
+// against.
+const (
+ RoleOwner = "owner"
+ RoleMember = "member"
+)
+
+// InviteTTL bounds a pending invite.
+const InviteTTL = 7 * 24 * time.Hour
+
+// Account is one tenancy root as the console sees it. Role is filled only by
+// AccountsFor (it is the viewer's role, not a property of the account).
+type Account struct {
+ ID int64
+ Sqid string
+ Name string
+ Role string
+ CreatedAt time.Time
+}
+
+// Invite is one pending invitation.
+type Invite struct {
+ Token string
+ AccountID int64
+ Address string
+ InvitedBy string
+ CreatedAt time.Time
+ ExpiresAt time.Time
+}
+
+// CreateAccount records a minted account and makes its creator the first
+// owner, in ONE transaction: an account with no owner is unadministrable —
+// nobody left who can invite, rename, or delete it — so the two writes must
+// never be separable by a crash.
+func (s *Store) CreateAccount(id int64, sqid, name, ownerAddress string, now time.Time) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if _, err := tx.Exec(`INSERT INTO accounts (id, sqid, name, created_at) VALUES (?, ?, ?, ?)`,
+ id, sqid, name, now.Unix()); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`INSERT INTO memberships (account_id, address, role, added_at) VALUES (?, ?, ?, ?)`,
+ id, ownerAddress, RoleOwner, now.Unix()); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// 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.
+func (s *Store) AccountBySqid(sqid string) (*Account, error) {
+ var a Account
+ var created int64
+ err := s.db.QueryRow(`SELECT id, sqid, name, created_at FROM accounts WHERE sqid = ?`, sqid).
+ Scan(&a.ID, &a.Sqid, &a.Name, &created)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ a.CreatedAt = time.Unix(created, 0).UTC()
+ return &a, nil
+}
+
+// AccountByID is AccountBySqid keyed the other way, for the MRU landing.
+func (s *Store) AccountByID(id int64) (*Account, error) {
+ var a Account
+ var created int64
+ err := s.db.QueryRow(`SELECT id, sqid, name, created_at FROM accounts WHERE id = ?`, id).
+ Scan(&a.ID, &a.Sqid, &a.Name, &created)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ a.CreatedAt = time.Unix(created, 0).UTC()
+ return &a, nil
+}
+
+// AccountsFor returns the accounts address is a member of, in creation order
+// — the switcher's list, and the "your first membership" fallback when there
+// is no MRU history. Ordered by created_at then id so two accounts created in
+// the same second still order deterministically.
+func (s *Store) AccountsFor(address string) ([]Account, error) {
+ rows, err := s.db.Query(`SELECT a.id, a.sqid, a.name, a.created_at, m.role
+FROM accounts a JOIN memberships m ON m.account_id = a.id
+WHERE m.address = ? ORDER BY a.created_at, a.id`, address)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Account
+ for rows.Next() {
+ var a Account
+ var created int64
+ if err := rows.Scan(&a.ID, &a.Sqid, &a.Name, &created, &a.Role); err != nil {
+ return nil, err
+ }
+ a.CreatedAt = time.Unix(created, 0).UTC()
+ out = append(out, a)
+ }
+ return out, rows.Err()
+}
+
+// Role is address's role in the account, "" when they are not a member. It is
+// the one permission check every account-scoped route makes.
+func (s *Store) Role(accountID int64, address string) (string, error) {
+ var role string
+ err := s.db.QueryRow(`SELECT role FROM memberships WHERE account_id = ? AND address = ?`,
+ accountID, address).Scan(&role)
+ if err == sql.ErrNoRows {
+ return "", nil
+ }
+ if err != nil {
+ return "", err
+ }
+ return role, nil
+}
+
+// Members lists an account's memberships, owners first then by address.
+func (s *Store) Members(accountID int64) ([]Account, error) {
+ rows, err := s.db.Query(`SELECT address, role FROM memberships WHERE account_id = ?
+ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END, address`, accountID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Account
+ for rows.Next() {
+ var a Account
+ if err := rows.Scan(&a.Name, &a.Role); err != nil { // Name carries the address here
+ return nil, err
+ }
+ a.ID = accountID
+ out = append(out, a)
+ }
+ return out, rows.Err()
+}
+
+// AddMember adds or re-roles a member. Demoting the LAST owner is refused for
+// the same reason removing them is: it leaves an account nobody can
+// administer. Transfer ownership first.
+func (s *Store) AddMember(accountID int64, address, role string, now time.Time) error {
+ if role != RoleOwner && role != RoleMember {
+ return fmt.Errorf("console: invalid role %q", role)
+ }
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if role != RoleOwner {
+ if err := lastOwnerGuard(tx, accountID, address); err != nil {
+ return err
+ }
+ }
+ if _, err := tx.Exec(`INSERT INTO memberships (account_id, address, role, added_at)
+VALUES (?, ?, ?, ?)
+ON CONFLICT(account_id, address) DO UPDATE SET role = excluded.role`,
+ accountID, address, role, now.Unix()); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// RemoveMember drops a membership, refusing to remove the last owner.
+func (s *Store) RemoveMember(accountID int64, address string) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if err := lastOwnerGuard(tx, accountID, address); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`DELETE FROM memberships WHERE account_id = ? AND address = ?`,
+ accountID, address); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// lastOwnerGuard refuses a change that would leave an account with no owner.
+// Inside the caller's transaction, so the count and the write cannot be
+// separated by a concurrent second removal.
+func lastOwnerGuard(tx *sql.Tx, accountID int64, address string) error {
+ var role string
+ err := tx.QueryRow(`SELECT role FROM memberships WHERE account_id = ? AND address = ?`,
+ accountID, address).Scan(&role)
+ if err == sql.ErrNoRows {
+ return nil // not a member: nothing to protect
+ }
+ if err != nil {
+ return err
+ }
+ if role != RoleOwner {
+ return nil
+ }
+ var owners int
+ if err := tx.QueryRow(`SELECT COUNT(*) FROM memberships WHERE account_id = ? AND role = ?`,
+ accountID, RoleOwner).Scan(&owners); err != nil {
+ return err
+ }
+ if owners <= 1 {
+ return fmt.Errorf("console: %s is the last owner of this account — transfer ownership first", address)
+ }
+ return nil
+}
+
+// SetLastAccount records where a user was last, so signing in lands them
+// back there. Upserts the users row: an invited address that has never
+// signed in still gets a landing spot.
+func (s *Store) SetLastAccount(address string, accountID int64) error {
+ _, err := s.db.Exec(`INSERT INTO users (address, created_at, last_account) VALUES (?, ?, ?)
+ON CONFLICT(address) DO UPDATE SET last_account = excluded.last_account`,
+ address, time.Now().Unix(), accountID)
+ return err
+}
+
+// LastAccount is the account id a user was last in, 0 when there is no
+// history (a fresh user, or one whose only account was deleted).
+func (s *Store) LastAccount(address string) (int64, error) {
+ var id int64
+ err := s.db.QueryRow(`SELECT last_account FROM users WHERE address = ?`, address).Scan(&id)
+ if err == sql.ErrNoRows {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+ return id, nil
+}
+
+// CreateInvite mints a single-use, 7-day invitation for address, returning
+// the token the sealed Keymail link carries.
+func (s *Store) CreateInvite(accountID int64, address, invitedBy string, now time.Time) (string, error) {
+ tok, err := randomToken()
+ if err != nil {
+ return "", err
+ }
+ _, err = s.db.Exec(`INSERT INTO invites (token, account_id, address, invited_by, created_at, expires_at)
+VALUES (?, ?, ?, ?, ?, ?)`,
+ tok, accountID, address, invitedBy, now.Unix(), now.Add(InviteTTL).Unix())
+ if err != nil {
+ return "", err
+ }
+ return tok, nil
+}
+
+// AcceptInvite consumes an invite and creates the membership, in one
+// transaction. Three gates, all inside it: the token must be unaccepted, the
+// address must be the one it was addressed to (an invite is not a bearer
+// token — the link lands in ONE keymail inbox and only that identity can use
+// it), and it must not have expired.
+func (s *Store) AcceptInvite(token, address string, now time.Time) (int64, error) {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return 0, err
+ }
+ defer tx.Rollback()
+ var accountID, expires, accepted int64
+ var want string
+ err = tx.QueryRow(`SELECT account_id, address, expires_at, accepted_at FROM invites WHERE token = ?`,
+ token).Scan(&accountID, &want, &expires, &accepted)
+ if err == sql.ErrNoRows {
+ return 0, fmt.Errorf("console: no such invite")
+ }
+ if err != nil {
+ return 0, err
+ }
+ if accepted != 0 {
+ return 0, fmt.Errorf("console: that invite has already been used")
+ }
+ if want != address {
+ return 0, fmt.Errorf("console: that invite was sent to a different address")
+ }
+ if now.Unix() > expires {
+ return 0, fmt.Errorf("console: that invite has expired")
+ }
+ if _, err := tx.Exec(`UPDATE invites SET accepted_at = ? WHERE token = ?`, now.Unix(), token); err != nil {
+ return 0, err
+ }
+ // An invite always grants member; owners are promoted deliberately.
+ if _, err := tx.Exec(`INSERT INTO memberships (account_id, address, role, added_at)
+VALUES (?, ?, ?, ?) ON CONFLICT(account_id, address) DO NOTHING`,
+ accountID, address, RoleMember, now.Unix()); err != nil {
+ return 0, err
+ }
+ if err := tx.Commit(); err != nil {
+ return 0, err
+ }
+ return accountID, nil
+}
+
+// RevokeInvite deletes a pending invite. Deleting rather than flagging is
+// deliberate: a revoked invite must be indistinguishable from one that never
+// existed, so a leaked link reveals nothing about the account.
+func (s *Store) RevokeInvite(token string) error {
+ _, err := s.db.Exec(`DELETE FROM invites WHERE token = ?`, token)
+ return err
+}
+
+// PendingInvites lists an account's unaccepted, unexpired invitations.
+func (s *Store) PendingInvites(accountID int64) ([]Invite, error) {
+ rows, err := s.db.Query(`SELECT token, account_id, address, invited_by, created_at, expires_at
+FROM invites WHERE account_id = ? AND accepted_at = 0 ORDER BY created_at, address`, accountID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Invite
+ for rows.Next() {
+ var i Invite
+ var created, expires int64
+ if err := rows.Scan(&i.Token, &i.AccountID, &i.Address, &i.InvitedBy, &created, &expires); err != nil {
+ return nil, err
+ }
+ i.CreatedAt = time.Unix(created, 0).UTC()
+ i.ExpiresAt = time.Unix(expires, 0).UTC()
+ out = append(out, i)
+ }
+ return out, rows.Err()
+}
+
// ---- apps (claim-once names) ------------------------------------------------
-// CreateApp claims an app name, once. created is false when the name was
-// already claimed — the caller treats that exactly like every other "taken".
+// OpsAccountID is the built-in operator account's id, pinned by registry
+// migration on every deployment (its sqid is release.OpsAccount).
+const OpsAccountID = 1
+
+// CreateApp claims an app name in the built-in ops account — the shape the
+// account-less /apps routes still use. CreateAppIn is the account-aware verb.
func (s *Store) CreateApp(app, createdBy string, now time.Time) (created bool, err error) {
- res, err := s.db.Exec(`INSERT INTO apps (app, created_by, created_at) VALUES (?, ?, ?)
-ON CONFLICT(app) DO NOTHING`, app, createdBy, now.Unix())
+ return s.CreateAppIn(OpsAccountID, app, createdBy, now)
+}
+
+// CreateAppIn claims an app name WITHIN an account, once. created is false
+// when that account already has the name — another account holding it is not
+// a conflict, because app names are unique per account, never globally.
+func (s *Store) CreateAppIn(accountID int64, app, createdBy string, now time.Time) (created bool, err error) {
+ res, err := s.db.Exec(`INSERT INTO apps (app, account_id, created_by, created_at)
+VALUES (?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`,
+ app, accountID, createdBy, now.Unix())
if err != nil {
return false, err
}
@@ -208,6 +641,39 @@ ON CONFLICT(app) DO NOTHING`, app, createdBy, now.Unix())
return n > 0, err
}
+// AppsIn lists an account's claimed app names, sorted.
+func (s *Store) AppsIn(accountID int64) ([]string, error) {
+ rows, err := s.db.Query(`SELECT app FROM apps WHERE account_id = ? ORDER BY app`, accountID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []string
+ for rows.Next() {
+ var a string
+ if err := rows.Scan(&a); err != nil {
+ return nil, err
+ }
+ out = append(out, a)
+ }
+ return out, rows.Err()
+}
+
+// HasAppIn reports whether an account already claims an app name.
+func (s *Store) HasAppIn(accountID int64, app string) (bool, error) {
+ var n int
+ err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND app = ?`,
+ accountID, app).Scan(&n)
+ return n > 0, err
+}
+
+// SetAppAccount re-homes a claimed app onto another account — the console
+// half of `carlos accounts migrate`.
+func (s *Store) SetAppAccount(app string, accountID int64) error {
+ _, err := s.db.Exec(`UPDATE apps SET account_id = ? WHERE app = ?`, accountID, app)
+ return err
+}
+
// HasApp reports whether an app name is claimed in the apps table.
func (s *Store) HasApp(app string) (bool, error) {
var n int