carlos / platform Public

feat(console): materialization reaps tombstones and refuses resurrection

MaterializeAccounts now reads every account manifest body — the accepted
startup cost — because tombstone state lives in the body and the old
"already known, stop here" short-circuit made a deletion performed
anywhere else invisible here forever (the Kass gap). A tombstoned
manifest reaps its account; a marked row facing a manifest that is live
again is logged as corruption and refused, BEFORE anything can reach
EnsureAccount. Owner adoption stays birth-only, and birth still means
first-ever: reap, rewrite, re-birth would otherwise be a whole-account
takeover for anyone holding s3:PutObject and no signing key.

MaterializeApps converges app rows the same way, row-driven because a
purged app leaves no keys to list: marker → trashed (with the marker's
own timestamp, so a late catch-up does not restart the cooloff), no
marker + keys → restored, no marker + no keys + already trashed here →
row deleted and the name freed. A LIVE row with no keys is left alone —
a claimed-but-never-shipped app is indistinguishable from a purged one
in the bucket, and only the row's own deleted_at tells them apart.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell bfaf42d3fbaf6b44cf5d506de6a878298c20298b parent 0b576bb
3 files changed, +453 −16
  • internal/console/materialize.go +184 −16
  • internal/console/materialize_test.go +228 −0
  • internal/console/store.go +41 −0
diff --git a/internal/console/materialize.go b/internal/console/materialize.go
index bf5ca33f1..a655f99dd 100644
--- a/internal/console/materialize.go
+++ b/internal/console/materialize.go
@@ -51,18 +51,20 @@ func MaterializeAccounts(ctx context.Context, s release.Store, st *Store) (int,
if !ok {
continue
}
- // Cheap short-circuit, and the reason a steady-state pass does no
- // writes and no object reads at all: the console usually already knows
- // every account.
- //
- // This is ALSO the security boundary for the owner field, so it must
- // stay an unconditional "already known, stop here". See materializeOwner
- // for why an account the console has seen before is never revisited.
- if a, err := st.AccountBySqid(sqid); err != nil {
+ // The UNFILTERED lookup, deliberately: this pass is the one place in
+ // the console that must tell "never seen" from "seen, and deleted".
+ // Every other door asks AccountBySqid and gets nothing for a marked
+ // row; here, a marked row is the console's MEMORY of a tombstone, and
+ // forgetting it is a takeover (see the switch below).
+ a, deleted, err := st.AccountAnyBySqid(sqid)
+ if err != nil {
return n, err
- } else if a != nil {
- continue
}
+ // Read UNCONDITIONALLY now, including for accounts we already know.
+ // This is the accepted cost of reaping: tombstone state lives in the
+ // manifest BODY, so the old "known, stop here before the GET"
+ // short-circuit would make a deletion performed elsewhere invisible
+ // here forever. One small GET per account, at startup.
body, _, err := s.Get(ctx, key)
if err != nil {
log.Printf("console: skipping %s: read failed: %v", key, err)
@@ -78,14 +80,59 @@ func MaterializeAccounts(ctx context.Context, s release.Store, st *Store) (int,
"not a new account", key, sqid, m.Sqid, m.ID, domains.Sqid(m.ID))
continue
}
- // A retired account stays retired. Without this, deletion would be a
- // lie that lasts until the next console start: this pass would find the
- // manifest, see no local row, and re-create the account together with
- // the owner membership the manifest names — which after an ownership
- // transfer is not even the person who deleted it.
- if m.Deleted() {
+ // Everything the row and the manifest can say to each other. Only the
+ // last case — never seen here, and live — falls through to birth.
+ switch {
+ case deleted && m.Deleted():
+ continue // both sides agree the account is retired; nothing to do
+
+ case deleted && !m.Deleted():
+ // A marked row is tombstone MEMORY, and memory does not argue with
+ // a rewritten manifest. Re-materializing here — let alone
+ // re-adopting the owner the manifest now names — would hand the
+ // account, its sqid, its platform hostnames and its whole apps
+ // prefix to whoever holds bucket write access, with no signing key
+ // involved. The manifest is not write-once (see materializeOwner),
+ // so "reap, rewrite, re-birth" would be a complete account
+ // takeover available to anyone who can PUT one object.
+ //
+ // An account id is spent once. This is corruption, not an
+ // un-deletion, and it is logged as such.
+ log.Printf("console: manifest %s is live again but the account was deleted here — "+
+ "refusing to resurrect it; an account id is spent once", key)
+ continue
+
+ case a != nil && m.Deleted():
+ // The control plane has ruled; converge on it. This is what makes a
+ // deletion performed anywhere — another box, `carlos accounts
+ // delete`, an operator with only bucket access — become real here
+ // without hand-run SQL (the Kass gap).
+ if err := st.ReapAccount(a.ID, time.Now()); err != nil {
+ log.Printf("console: reap deleted account %s: %v", sqid, err)
+ } else {
+ log.Printf("console: reaped account %s (%s) — its manifest is tombstoned", sqid, a.Name)
+ }
+ continue
+
+ case a != nil:
+ // Known and live: settled. INSERT-only, and — the security
+ // boundary — owner adoption is never revisited for an account the
+ // console has seen before.
+ continue
+
+ case m.Deleted():
+ // Never seen here and already retired: it stays unseen. Without
+ // this, deletion would be a lie that lasts until the next console
+ // start on a fresh box — the manifest would be read, no row found,
+ // and the account re-created together with the owner membership it
+ // names, which after an ownership transfer is not even the person
+ // who deleted it.
continue
}
+
+ // FIRST-EVER materialization from here down. Nothing above may reach
+ // EnsureAccount or materializeOwner: birth happens once per account id,
+ // for as long as this database lives.
created := m.CreatedAt
if created.IsZero() {
created = time.Now()
@@ -198,6 +245,122 @@ func materializeOwner(st *Store, key string, m accountManifest, created time.Tim
}
}
+// MaterializeApps converges the console's app rows onto the bucket, which is
+// the control plane for trash state exactly as it is for account identity.
+//
+// Trash and purge both happen where this console is not: `carlos` running
+// headless on a box, a second console, the auto-purge pass sweeping cooled-off
+// markers. Each writes the bucket; none opens this database. So the console
+// catches up the same way it catches up on accounts — by reading the objects.
+//
+// Four cases, decided per row:
+//
+// (a) live row + valid marker → mark it trashed (trashed elsewhere)
+// (b) marked row + no marker + keys → un-mark it (restored elsewhere)
+// (c) marked row + no marker + no keys → DELETE the row (purged elsewhere);
+// the row IS the name reservation, so this is where the name frees
+// (d) live row + no marker + no keys → NOTHING.
+//
+// Case (d) is the guard the whole design turns on: an app claimed but never
+// shipped has no objects, which makes it byte-for-byte indistinguishable from
+// a purged one in the bucket. The only thing that tells them apart is the row's
+// own deleted_at — so convergence may only ever destroy a row that was ALREADY
+// trashed here. PurgeAppRow enforces the same rule in SQL.
+//
+// Row-driven, not bucket-driven: a purged app leaves no keys to list, so the
+// listing alone can never raise case (c). Keys under an apps/<sqid>/ prefix
+// whose sqid this console does not know contribute nothing to anything —
+// another deployment's tenants are not this console's business.
+//
+// Best-effort like the rest of this file: a nil store or a failed listing is
+// "nothing to catch up on", never a reason to stop the console.
+func MaterializeApps(ctx context.Context, s release.Store, st *Store) error {
+ if s == nil {
+ return nil
+ }
+ rows, err := st.AppRows()
+ if err != nil {
+ return err
+ }
+ if len(rows) == 0 {
+ return nil
+ }
+ keys, err := s.List(ctx, "apps/")
+ if err != nil {
+ return err
+ }
+ // One listing, indexed by app prefix. The prefixes come from the ROWS, so
+ // an unknown account's keys simply never find a home here.
+ type bucketState struct{ anyKeys, marker bool }
+ state := make(map[string]*bucketState, len(rows))
+ for _, r := range rows {
+ state["apps/"+r.Sqid+"/"+r.App+"/"] = &bucketState{}
+ }
+ for _, key := range keys {
+ parts := strings.SplitN(key, "/", 4)
+ if len(parts) < 4 || parts[0] != "apps" {
+ continue // not an app-scoped key: apps/<sqid>/<app>/<something>
+ }
+ bs := state["apps/"+parts[1]+"/"+parts[2]+"/"]
+ if bs == nil {
+ continue
+ }
+ // Any object under the prefix counts, including a bare directory
+ // placeholder: convergence fails toward KEEPING the row.
+ bs.anyKeys = true
+ if key == release.AppTombstoneKey(parts[1], parts[2]) {
+ bs.marker = true
+ }
+ }
+ for _, r := range rows {
+ bs := state["apps/"+r.Sqid+"/"+r.App+"/"]
+ switch {
+ case bs.marker:
+ // Re-read rather than trusting the listing: it is one GET, and a
+ // marker is an instruction to hide an app. ReadAppTombstone
+ // VALIDATES — a body that disagrees with its path, or carries no
+ // timestamp, is not something to act on either way.
+ tomb, err := release.ReadAppTombstone(ctx, s, r.Sqid, r.App)
+ if err != nil {
+ log.Printf("console: leaving %s/%s alone: %v", r.Sqid, r.App, err)
+ continue
+ }
+ if tomb == nil || r.Deleted {
+ continue // restored between the listing and the read, or already agreed
+ }
+ // The clock comes from the MARKER. A console catching up a week
+ // later must not restart the 30-day cooloff.
+ if err := st.TrashAppRow(r.AccountID, r.App, tomb.DeletedAt); err != nil {
+ log.Printf("console: mark %s/%s trashed: %v", r.Sqid, r.App, err)
+ continue
+ }
+ log.Printf("console: %s/%s is in the trash in the bucket — marking it trashed here", r.Sqid, r.App)
+
+ case !r.Deleted:
+ continue // (d), and the ordinary live app: no marker, nothing to do
+
+ case bs.anyKeys:
+ if err := st.RestoreAppRow(r.AccountID, r.App); err != nil {
+ log.Printf("console: restore %s/%s: %v", r.Sqid, r.App, err)
+ continue
+ }
+ log.Printf("console: %s/%s has no trash marker and still has objects — restoring it here", r.Sqid, r.App)
+
+ default:
+ // Purged: marker gone, objects gone, and the row was already
+ // trashed here — so this frees the name rather than destroying
+ // anything. PurgeAppRow is guarded to refuse a live row even if
+ // this reasoning is ever wrong.
+ if err := st.PurgeAppRow(r.AccountID, r.App); err != nil {
+ log.Printf("console: purge %s/%s: %v", r.Sqid, r.App, err)
+ continue
+ }
+ log.Printf("console: %s/%s is gone from the bucket — releasing the name here", r.Sqid, r.App)
+ }
+ }
+ return 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
@@ -206,4 +369,9 @@ 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)
}
+ // After accounts, always: a reaped account's app rows are gone with it, so
+ // this pass then has nothing to say about them.
+ if err := MaterializeApps(ctx, s, st); err != nil {
+ log.Printf("console: could not converge app rows with the bucket: %v", err)
+ }
}
diff --git a/internal/console/materialize_test.go b/internal/console/materialize_test.go
index 1206fbc76..845d9b3ff 100644
--- a/internal/console/materialize_test.go
+++ b/internal/console/materialize_test.go
@@ -383,6 +383,229 @@ func TestMaterializeLeavesAnOwnerlessManifestOwnerless(t *testing.T) {
}
}
+// THE KASS GAP, closed: a deletion performed anywhere becomes real here.
+//
+// Before this, MaterializeAccounts merely SKIPPED a tombstoned manifest, so an
+// account deleted on another box (or by `carlos accounts delete`) kept its
+// console row, its memberships and its apps forever — the console said the
+// account was live, the control plane said it was gone, and only hand-run SQL
+// could settle it. The bucket is the control plane: it has already ruled.
+func TestMaterializeReapsTombstonedAccounts(t *testing.T) {
+ st := openTestStore(t)
+ rel := release.NewDirStore(t.TempDir())
+ ctx := t.Context()
+ now := time.Unix(1700000000, 0).UTC()
+
+ id, sqid, err := domains.MintAccount(ctx, rel, "Testco", "paul@keymail.dev", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 1 {
+ t.Fatalf("materialized %d (%v), want the new account", n, err)
+ }
+ if _, err := st.CreateAppIn(id, "blog", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.SetLastAccount("paul@keymail.dev", id); err != nil {
+ t.Fatal(err)
+ }
+
+ // Deleted somewhere else entirely, while this console knew nothing about it.
+ if err := domains.TombstoneAccount(ctx, rel, sqid, now); err != nil {
+ t.Fatal(err)
+ }
+ if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 0 {
+ t.Fatalf("materialized %d (%v) on the reaping pass, want 0 — a reap is not a birth", n, err)
+ }
+
+ if a, err := st.AccountBySqid(sqid); err != nil || a != nil {
+ t.Errorf("AccountBySqid after the tombstone = %+v %v, want nothing: the control plane has ruled", a, err)
+ }
+ // Invisible, but REMEMBERED — see the resurrection test for what forgetting costs.
+ if a, deleted, err := st.AccountAnyBySqid(sqid); err != nil || a == nil || !deleted {
+ t.Fatalf("AccountAnyBySqid = %+v %v %v, want a marked row", a, deleted, err)
+ }
+ if members, _ := st.Members(id); len(members) != 0 {
+ t.Errorf("Members = %+v, want none — membership against a dead account is access to nothing", members)
+ }
+ if apps, _ := st.AppsIn(id); len(apps) != 0 {
+ t.Errorf("AppsIn = %v, want none", apps)
+ }
+ if trashed, _ := st.TrashedAppsIn(id); len(trashed) != 0 {
+ t.Errorf("TrashedAppsIn = %+v, want none — the app rows are reaped with the account", trashed)
+ }
+ if last, err := st.LastAccount("paul@keymail.dev"); err != nil || last != 0 {
+ t.Errorf("LastAccount = %d %v, want 0 — the MRU must not land anyone in a reaped account", last, err)
+ }
+ // Stable: a second pass finds a marked row and a tombstoned manifest agreeing.
+ if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 0 {
+ t.Errorf("second pass materialized %d (%v), want 0", n, err)
+ }
+}
+
+// THE SECURITY TEST. Owner adoption is birth-only, and "birth" must mean
+// FIRST-EVER — not "first since the row went away".
+//
+// Deleting the row would forget the account was ever seen, and forgetting
+// re-arms the whole attack materializeOwner exists to prevent: tombstone an
+// account (or wait for someone to delete one), let the console reap it, then
+// rewrite the manifest un-tombstoned with yourself as owner. A console that
+// treats that as a first materialization re-births the account — same id, same
+// sqid, same platform hostnames, same apps prefix — and adopts the attacker.
+// Bucket write access, no signing key, a whole account.
+//
+// So a marked row is tombstone MEMORY, and memory does not argue with a
+// rewritten manifest. An account id is spent once.
+func TestMaterializeRefusesResurrection(t *testing.T) {
+ st := openTestStore(t)
+ rel := release.NewDirStore(t.TempDir())
+ ctx := t.Context()
+ now := time.Unix(1700000000, 0).UTC()
+
+ id, sqid, err := domains.MintAccount(ctx, rel, "Testco", "paul@keymail.dev", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 1 {
+ t.Fatalf("materialized %d (%v), want the new account", n, err)
+ }
+ if err := domains.TombstoneAccount(ctx, rel, sqid, now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := MaterializeAccounts(ctx, rel, st); err != nil {
+ t.Fatal(err)
+ }
+ if _, deleted, _ := st.AccountAnyBySqid(sqid); !deleted {
+ t.Fatal("the account was not reaped, so this test is not testing what it says")
+ }
+
+ // Somebody with plain s3:PutObject — no CARLOS_RELEASE_KEY, no console
+ // session — writes the manifest back, live, naming themselves.
+ rewriteManifest(t, rel, id, "eve@evil.example")
+
+ // Every console start runs this pass, and so does every `console member`.
+ for i := 0; i < 2; i++ {
+ if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 0 {
+ t.Fatalf("pass %d materialized %d (%v) — a spent account id was re-birthed from a rewritten manifest", i, n, err)
+ }
+ }
+ if a, _ := st.AccountBySqid(sqid); a != nil {
+ t.Errorf("the account came back as %+v — reap, rewrite, re-birth is an account takeover", a)
+ }
+ if _, deleted, _ := st.AccountAnyBySqid(sqid); !deleted {
+ t.Error("the marked row lost its mark — the console has forgotten the tombstone")
+ }
+ if role, _ := st.Role(id, "eve@evil.example"); role != "" {
+ t.Errorf("the rewritten manifest granted %q — bucket write became console authority", role)
+ }
+ if accts, _ := st.AccountsFor("eve@evil.example"); len(accts) != 0 {
+ t.Errorf("AccountsFor(attacker) = %+v, want nothing adopted", accts)
+ }
+ if members, _ := st.Members(id); len(members) != 0 {
+ t.Errorf("Members = %+v, want none", members)
+ }
+}
+
+// App-row convergence: the console's rows follow the bucket, because trash and
+// purge can both happen while this console is down (headless `carlos`, another
+// box, the auto-purge pass). Four cases, and case (d) is the one that keeps
+// convergence honest.
+func TestMaterializeAppsConverges(t *testing.T) {
+ st := openTestStore(t)
+ rel := release.NewDirStore(t.TempDir())
+ ctx := t.Context()
+ now := time.Unix(1700000000, 0).UTC()
+ trashedAt := now.Add(-48 * time.Hour)
+
+ id, sqid, err := domains.MintAccount(ctx, rel, "Testco", "paul@keymail.dev", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 1 {
+ t.Fatalf("materialized %d (%v), want the account", n, err)
+ }
+ for _, app := range []string{"alpha", "beta", "gamma", "delta"} {
+ if _, err := st.CreateAppIn(id, app, "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ }
+ shipped := func(app string) {
+ t.Helper()
+ if _, err := rel.Put(ctx, "apps/"+sqid+"/"+app+"/channels/stable.json", []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // (a) live row, trashed headlessly: the marker exists in the bucket.
+ shipped("alpha")
+ if err := release.TrashApp(ctx, rel, sqid, "alpha", "paul@keymail.dev", trashedAt); err != nil {
+ t.Fatal(err)
+ }
+ // (b) marked row, restored headlessly: marker gone, objects still there.
+ shipped("beta")
+ if err := st.TrashAppRow(id, "beta", trashedAt); err != nil {
+ t.Fatal(err)
+ }
+ // (c) marked row, purged: no marker and no objects at all.
+ if err := st.TrashAppRow(id, "gamma", trashedAt); err != nil {
+ t.Fatal(err)
+ }
+ // (d) live row, never shipped: no marker, no objects — a legitimate claim.
+ // Nothing about it is distinguishable from (c) except that its row is LIVE.
+
+ // Another account's keys, for a sqid this console has never heard of: they
+ // must contribute nothing to anybody's decision.
+ if _, err := rel.Put(ctx, "apps/"+domains.Sqid(999999)+"/alpha/channels/stable.json", []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := MaterializeApps(ctx, rel, st); err != nil {
+ t.Fatal(err)
+ }
+
+ if live, _ := st.HasAppIn(id, "alpha"); live {
+ t.Error("(a) alpha has a trash marker in the bucket but its row is still live")
+ }
+ if trashed, _ := st.HasTrashedAppIn(id, "alpha"); !trashed {
+ t.Error("(a) alpha was not marked trashed from its bucket marker")
+ }
+ if live, _ := st.HasAppIn(id, "beta"); !live {
+ t.Error("(b) beta's marker is gone and its objects remain — the row should be live again")
+ }
+ if trashed, _ := st.HasTrashedAppIn(id, "gamma"); trashed {
+ t.Error("(c) gamma is purged in the bucket but its row still holds the name")
+ }
+ if live, _ := st.HasAppIn(id, "gamma"); live {
+ t.Error("(c) gamma came back to life")
+ }
+ if accts, _ := st.AppAccounts("gamma"); len(accts) != 0 {
+ t.Errorf("(c) gamma still has rows %v — the row IS the name reservation, so purge must free it", accts)
+ }
+ if live, _ := st.HasAppIn(id, "delta"); !live {
+ t.Error("(d) a claimed-but-never-shipped app was eaten by convergence — " +
+ "an app with no objects is indistinguishable from a purged one except by its own row")
+ }
+
+ // The cooloff clock comes from the MARKER, not from this pass: the bucket
+ // records when the app was trashed, and a console catching up days later
+ // must not restart the 30 days.
+ trashedRows, err := st.TrashedAppsIn(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(trashedRows) != 1 || trashedRows[0].App != "alpha" || !trashedRows[0].DeletedAt.Equal(trashedAt) {
+ t.Errorf("TrashedAppsIn = %+v, want alpha trashed at %v", trashedRows, trashedAt)
+ }
+
+ // Idempotent: convergence has converged.
+ if err := MaterializeApps(ctx, rel, st); err != nil {
+ t.Fatal(err)
+ }
+ apps, _ := st.AppsIn(id)
+ if len(apps) != 2 || apps[0] != "beta" || apps[1] != "delta" {
+ t.Errorf("AppsIn after a second pass = %v, want [beta delta]", apps)
+ }
+}
+
// rewriteManifest overwrites a live account manifest with one naming owner —
// what a bucket-writer does, and the thing MintAccount's create-only Put does
// NOT prevent anyone else from doing. The etag is read and passed because the
@@ -415,4 +638,9 @@ func TestMaterializeWithNoStoreIsANoOp(t *testing.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)
}
+ // And app convergence above all: with no bucket to compare against, "no
+ // keys" would otherwise read as "purged everywhere".
+ if err := MaterializeApps(t.Context(), nil, st); err != nil {
+ t.Errorf("MaterializeApps(nil store) = %v, want nil", err)
+ }
}
diff --git a/internal/console/store.go b/internal/console/store.go
index d3585aac0..e6d0fc23a 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -1140,6 +1140,47 @@ func (s *Store) HasTrashedAppIn(accountID int64, app string) (bool, error) {
return n > 0, err
}
+// AppRow is one apps row carrying its account's SQID — the coordinate the
+// bucket is keyed by. The console's own tables key apps by account id, but
+// every object belonging to an app lives under apps/<sqid>/<app>/, so a pass
+// that compares rows against the control plane needs both.
+type AppRow struct {
+ AccountID int64
+ Sqid string
+ App string
+ Deleted bool // trashed here (deleted_at != 0), not purged
+}
+
+// AppRows lists every app row of every LIVE account, trashed ones included —
+// the input to MaterializeApps, which is the one caller.
+//
+// It has to be row-driven rather than bucket-driven: a purged app has no keys
+// left to list, so the only evidence it ever existed is the row still holding
+// its name. The join drops rows belonging to a marked account, which ReapAccount
+// already deletes — a straggler must never be converged against a dead
+// account's prefix, and freeing its name is that account's business, not this
+// pass's.
+func (s *Store) AppRows() ([]AppRow, error) {
+ rows, err := s.db.Query(`SELECT p.account_id, a.sqid, p.app, p.deleted_at
+FROM apps p JOIN accounts a ON a.id = p.account_id
+WHERE a.deleted_at = 0 ORDER BY a.sqid, p.app`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []AppRow
+ for rows.Next() {
+ var r AppRow
+ var deletedAt int64
+ if err := rows.Scan(&r.AccountID, &r.Sqid, &r.App, &deletedAt); err != nil {
+ return nil, err
+ }
+ r.Deleted = deletedAt != 0
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
// AppAccounts lists the accounts that claim an app NAME, ascending. Usually
// one; several is legitimate (names are unique per account, not globally) and
// is what makes MoveApp require an explicit source.