carlos / platform Public

console: the switcher, the invite, the account you were last in

Every app page and mutation is re-rooted under /a/<sqid>/ — explicit and
shareable, so a link a teammate opens lands where you meant. / redirects to
the account you were last in, else your first membership, else one minted on
the spot: nobody signed in ever has nowhere to land.

Membership is the permission. The gate answers signed-in-non-member,
nonexistent account and unrouted URL with the BYTE-IDENTICAL 404 — account
existence is not probeable, matching /fleet — and owner-only actions answer a
member the same way, so a member cannot probe them either.

Invites reuse the sealed-envelope sender the sign-in confirmations already
use: the link lands in the addressee's Keymail Apps inbox, and the platform
still emails nobody. Pending invites are listed and revocable, scoped to the
account so one owner cannot revoke another account's invite by pasting a
token.

App names are claimed per account now; the pre-accounts checks (grants,
orphan bucket objects) still guard the OPS account, where every pre-accounts
app lives, and deliberately do not block another account's identical name. The
retired apps/<app>/account.json is no longer written — the linkage is the key
path — and the manifest the console does write is the ACCOUNT's, never
overwritten by a page view.

BackfillOpsMemberships maps every existing grant onto an ops membership on
start, idempotently, without deleting the grants.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell 356027766643a5bb676170692f4f83d088332810 parent 2408cb1
9 files changed, +1005 −291
  • internal/console/account.go +7 −0
  • internal/console/keymail_test.go +18 −2
  • internal/console/server.go +456 −143
  • internal/console/server_account_test.go +264 −0
  • internal/console/server_test.go +157 −136
  • internal/console/store.go +36 −0
  • internal/console/templates/app.html +4 −4
  • internal/console/templates/apps.html +43 −5
  • internal/console/templates/layout.html +20 −1
diff --git a/internal/console/account.go b/internal/console/account.go
index 027903127..61ea7b88f 100644
--- a/internal/console/account.go
+++ b/internal/console/account.go
@@ -14,6 +14,7 @@ import (
"os"
"time"
+ "github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/release"
)
@@ -45,6 +46,12 @@ func NewAccountID() int64 {
}
}
+// accountSqid is the account's public identity: the FROZEN encoder over its
+// id (internal/domains). Wrapped here so every mint in the console goes
+// through one place — the encoding is what every platform domain and every
+// bucket key embeds, and it can never be recomputed differently.
+func accountSqid(id int64) string { return domains.Sqid(id) }
+
// AccountManifestKey is where an account's identity lives in the bucket. The
// sqid is in the PATH as well as the body so the agent can list accounts
// without opening every object.
diff --git a/internal/console/keymail_test.go b/internal/console/keymail_test.go
index f2dcfb020..12f1e8152 100644
--- a/internal/console/keymail_test.go
+++ b/internal/console/keymail_test.go
@@ -47,6 +47,18 @@ type fakeKeymail struct {
exchanges int
}
+// sentBodies is every message body the fake has accepted — how a test proves
+// a link was delivered into a Keymail inbox rather than emailed.
+func (f *fakeKeymail) sentBodies() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ out := make([]string, 0, len(f.sends))
+ for _, s := range f.sends {
+ out = append(out, s.Body)
+ }
+ return out
+}
+
func newFakeKeymail(t *testing.T, address string) *fakeKeymail {
t.Helper()
boxPriv, err := ecdh.P256().GenerateKey(rand.Reader)
@@ -157,8 +169,12 @@ func (f *fakeKeymail) handleSend(w http.ResponseWriter, r *http.Request) {
return
}
e := req.Envelope
- if e.To != f.address {
- jsonErr(w, http.StatusForbidden, "envelope to must be "+f.address)
+ // Any recipient is accepted: a real keymail delivers to whoever the
+ // envelope names (that is how an invite reaches a teammate). The fake has
+ // one box key, so everything seals to it — the recipient is recorded, not
+ // gated. From/signature/blob checks below are the ones that matter.
+ if e.To == "" {
+ jsonErr(w, http.StatusForbidden, "envelope needs a recipient")
return
}
if host := OriginHost(f.origin); !strings.HasSuffix(e.From, "@"+host) {
diff --git a/internal/console/server.go b/internal/console/server.go
index 22f0aa2c0..6968a117a 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -10,7 +10,6 @@ import (
"crypto/ed25519"
"crypto/subtle"
"embed"
- "encoding/json"
"errors"
"fmt"
"html/template"
@@ -138,16 +137,32 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /auth/confirm", s.handleConfirm)
s.mux.HandleFunc("POST /auth/signout", s.handleSignout)
s.mux.HandleFunc("GET /fleet", s.handleFleet)
- s.mux.HandleFunc("GET /apps", s.handleApps)
- s.mux.HandleFunc("POST /apps/new", s.handleNewApp)
- s.mux.HandleFunc("GET /apps/{app}", s.handleApp)
- s.mux.HandleFunc("POST /apps/{app}/promote", s.handlePromote)
- s.mux.HandleFunc("POST /apps/{app}/rollback", s.handleRollback)
- s.mux.HandleFunc("POST /apps/{app}/canary", s.handleNewCanary)
- s.mux.HandleFunc("POST /apps/{app}/canary/retire", s.handleRetireCanary)
+ // Account-scoped and explicit: /a/<sqid>/apps/<app> rather than
+ // account-by-session, so a link a teammate opens lands where you meant.
+ s.mux.HandleFunc("GET /a/{sqid}/{$}", s.handleAccountHome)
+ s.mux.HandleFunc("POST /a/{sqid}/apps/new", s.handleNewApp)
+ s.mux.HandleFunc("GET /a/{sqid}/apps/{app}", s.handleApp)
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/promote", s.handlePromote)
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rollback", s.handleRollback)
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary", s.handleNewCanary)
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary/retire", s.handleRetireCanary)
+ s.mux.HandleFunc("POST /a/{sqid}/invite", s.handleInvite)
+ s.mux.HandleFunc("POST /a/{sqid}/invite/revoke", s.handleRevokeInvite)
+ s.mux.HandleFunc("GET /invite/{token}", s.handleAcceptInvite)
+ s.mux.HandleFunc("POST /accounts/new", s.handleNewAccount)
+ // The pre-accounts URLs, kept as redirects so old links and bookmarks
+ // still land somewhere sensible rather than 404ing.
+ s.mux.HandleFunc("GET /apps", s.handleLegacyApps)
+ s.mux.HandleFunc("GET /apps/{app}", s.handleLegacyApp)
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
+ // 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.
+ if err := st.BackfillOpsMemberships(time.Now()); err != nil {
+ return nil, err
+ }
return s, nil
}
@@ -171,16 +186,27 @@ type page struct {
Flash string // one-shot success notice (cookie-carried across a redirect)
FlashErr string // one-shot error notice
- Apps []appRow // /apps
- Detail *AppView // /apps/{app}
- Versions []VersionView // /apps/{app}: shipped releases, newest first
- Instances *InstanceReport // /apps/{app}: the app's self-report, nil when unpolled
- Events []Event // /apps/{app}: recent console actions, newest first
+ Apps []appRow // account home
+ Detail *AppView // /a/{sqid}/apps/{app}
+ Versions []VersionView // app page: shipped releases, newest first
+ Instances *InstanceReport // app page: the app's self-report, nil when unpolled
+ Events []Event // app page: recent console actions, newest first
CanPromote bool // a signing key is configured on this host
+ // The account chrome every /a/{sqid}/… page carries.
+ Account *Account // the account being viewed
+ Role string // the viewer's role in it: owner | member
+ Base string // "/a/<sqid>" — what every form on the page posts under
+ Accounts []Account // the switcher: every account the viewer belongs to
+ Members []Account // account home: memberships (Name carries the address)
+ Invites []Invite // account home, owners only: pending invitations
+
Fleet *status.Fleet // /fleet: nil means no snapshot published yet
}
+// IsOwner is the template's one permission question.
+func (p page) IsOwner() bool { return p.Role == RoleOwner }
+
// appRow is one line of the "your apps" table.
type appRow struct {
App string
@@ -274,14 +300,300 @@ func (s *Server) takeFlash(w http.ResponseWriter, r *http.Request) (flash, flash
// ---- auth handlers ----------------------------------------------------------
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
- if _, _, ok := s.session(r); ok {
- http.Redirect(w, r, "/apps", http.StatusFound)
+ if addr, _, ok := s.session(r); ok {
+ s.redirectToCurrentAccount(w, r, addr, http.StatusFound)
return
}
p := page{Title: "Sign in"}
s.render(w, http.StatusOK, "home", p)
}
+// redirectToCurrentAccount sends a signed-in user to the account they were
+// last in — else their first membership by creation order, else a personal
+// account minted on the spot. Nobody signed in ever has nowhere to land.
+func (s *Server) redirectToCurrentAccount(w http.ResponseWriter, r *http.Request, addr string, code int) {
+ sqid, err := s.currentAccount(r.Context(), addr)
+ if err != nil {
+ log.Printf("console: landing for %s: %v", addr, err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not open your account. Please try again.")
+ return
+ }
+ http.Redirect(w, r, "/a/"+url.PathEscape(sqid)+"/", code)
+}
+
+// currentAccount resolves the three landing cases in order: MRU history (only
+// if still a member — an account you were removed from must not strand you),
+// first membership, or mint.
+func (s *Server) currentAccount(ctx context.Context, addr string) (string, error) {
+ if id, err := s.store.LastAccount(addr); err != nil {
+ return "", err
+ } else if id != 0 {
+ if role, err := s.store.Role(id, addr); err != nil {
+ return "", err
+ } else if role != "" {
+ if a, err := s.store.AccountByID(id); err != nil {
+ return "", err
+ } else if a != nil {
+ return a.Sqid, nil
+ }
+ }
+ }
+ accts, err := s.store.AccountsFor(addr)
+ if err != nil {
+ return "", err
+ }
+ if len(accts) > 0 {
+ return accts[0].Sqid, nil
+ }
+ a, err := s.mintAccount(ctx, addr, addr)
+ if err != nil {
+ return "", err
+ }
+ return a.Sqid, nil
+}
+
+// mintAccount creates an account owned by addr: a random id (never derived
+// from a row number), its frozen sqid, the console DB rows, and the bucket
+// manifest that every box materializes from.
+//
+// The manifest write is best-effort by design — the account already exists in
+// the console, and a bucket hiccup must not lose it. The consequence of a
+// missed write is only that platform domains wait for the next successful
+// one, which the account page re-attempts.
+func (s *Server) mintAccount(ctx context.Context, name, ownerAddr string) (*Account, error) {
+ id := NewAccountID()
+ sqid := accountSqid(id)
+ now := time.Now()
+ if err := s.store.CreateAccount(id, sqid, name, ownerAddr, now); err != nil {
+ return nil, err
+ }
+ if err := WriteAccountManifest(ctx, s.cfg.Releases, id, sqid, name, now); err != nil {
+ log.Printf("console: account manifest for %s: %v", sqid, err)
+ }
+ if err := s.store.SetLastAccount(ownerAddr, id); err != nil {
+ log.Printf("console: set last account for %s: %v", ownerAddr, err)
+ }
+ return &Account{ID: id, Sqid: sqid, Name: name, Role: RoleOwner, CreatedAt: now}, nil
+}
+
+// account is the gate on every /a/{sqid}/… route: a session, an account that
+// exists, and a membership in it. All three failures answer with the
+// BYTE-IDENTICAL http.NotFound an unrouted URL gets — account existence is
+// not probeable, matching /fleet's operator gate. Signed-out users are sent
+// to the front page instead, which reveals nothing either way because it
+// happens for every sqid, real or not.
+func (s *Server) account(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, role string, ok bool) {
+ addr, raw, sok := s.session(r)
+ if !sok {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return "", "", nil, "", false
+ }
+ a, err := s.store.AccountBySqid(r.PathValue("sqid"))
+ if err != nil {
+ log.Printf("console: account lookup: %v", err)
+ http.NotFound(w, r)
+ return "", "", nil, "", false
+ }
+ if a == nil {
+ http.NotFound(w, r)
+ return "", "", nil, "", false
+ }
+ role, err = s.store.Role(a.ID, addr)
+ if err != nil {
+ log.Printf("console: role lookup: %v", err)
+ http.NotFound(w, r)
+ return "", "", nil, "", false
+ }
+ if role == "" {
+ http.NotFound(w, r)
+ return "", "", nil, "", false
+ }
+ return addr, raw, a, role, true
+}
+
+// owner is account plus the owner-only gate. A member who probes an
+// owner-only action gets the same 404 a non-member does: the difference
+// between "not yours" and "not allowed" is not information this hands out.
+func (s *Server) owner(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, ok bool) {
+ addr, raw, acct, role, ok := s.account(w, r)
+ if !ok {
+ return "", "", nil, false
+ }
+ if role != RoleOwner {
+ http.NotFound(w, r)
+ return "", "", nil, false
+ }
+ return addr, raw, acct, true
+}
+
+// handleLegacyApps / handleLegacyApp keep the pre-accounts URLs alive as
+// redirects into the current account.
+func (s *Server) handleLegacyApps(w http.ResponseWriter, r *http.Request) {
+ addr, _, ok := s.session(r)
+ if !ok {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ s.redirectToCurrentAccount(w, r, addr, http.StatusFound)
+}
+
+func (s *Server) handleLegacyApp(w http.ResponseWriter, r *http.Request) {
+ addr, _, ok := s.session(r)
+ if !ok {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ sqid, err := s.currentAccount(r.Context(), addr)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ http.Redirect(w, r, "/a/"+url.PathEscape(sqid)+"/apps/"+url.PathEscape(r.PathValue("app")), http.StatusFound)
+}
+
+// handleNewAccount creates an account for any signed-in user.
+func (s *Server) handleNewAccount(w http.ResponseWriter, r *http.Request) {
+ addr, raw, ok := s.session(r)
+ if !ok {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ name := strings.TrimSpace(r.FormValue("name"))
+ if name == "" || len(name) > 64 {
+ s.setFlash(w, "err", "An account needs a name (up to 64 characters).")
+ s.redirectToCurrentAccount(w, r, addr, http.StatusSeeOther)
+ return
+ }
+ a, err := s.mintAccount(r.Context(), name, addr)
+ if err != nil {
+ log.Printf("console: create account for %s: %v", addr, err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not create the account. Please try again.")
+ return
+ }
+ s.setFlash(w, "ok", "Created account "+name+" — you're its owner.")
+ http.Redirect(w, r, "/a/"+url.PathEscape(a.Sqid)+"/", http.StatusSeeOther)
+}
+
+// handleInvite sends a single-use invitation into the addressee's Keymail
+// Apps inbox — the same sealed-envelope path sign-in confirmations use. The
+// platform never emails anyone: the invite arrives in the mailbox the
+// identity itself is anchored to.
+func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, ok := s.owner(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/", http.StatusSeeOther)
+ }
+ to := strings.ToLower(strings.TrimSpace(r.FormValue("address")))
+ if !validAddress(to) {
+ back("err", "That doesn't look like a keymail address.")
+ return
+ }
+ if role, err := s.store.Role(acct.ID, to); err != nil {
+ back("err", "Could not check that address. Please try again.")
+ return
+ } else if role != "" {
+ back("err", to+" is already a member of this account.")
+ return
+ }
+ tok, err := s.store.CreateInvite(acct.ID, to, addr, time.Now())
+ if err != nil {
+ log.Printf("console: create invite: %v", err)
+ back("err", "Could not create the invitation. Please try again.")
+ return
+ }
+ link := s.cfg.Origin + "/invite/" + url.PathEscape(tok)
+ // The console's own grant is the instrument: it can only send from the
+ // INVITER's stored token, so an owner who has never completed the OAuth
+ // ceremony cannot invite. The invite row still exists, and the owner can
+ // pass the link on by hand — which is why the failure is a flash, not a
+ // rollback.
+ if gt, _ := s.store.GrantToken(addr); gt != "" {
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+ if _, err := s.km.SendApp(ctx, gt, to,
+ "You've been invited to an account on the CARLOS platform",
+ addr+" invited you to the account \""+acct.Name+"\" on the CARLOS platform.\n\n"+
+ "Open this link while signed in as "+to+" to accept. It works once and expires in 7 days:\n\n"+
+ link+"\n\nIf you weren't expecting this, ignore it — nothing happens without the link."); err != nil {
+ log.Printf("console: invite send to %s: %v", to, err)
+ back("err", "Invited "+to+", but delivering the link failed — you can send it on yourself: "+link)
+ return
+ }
+ }
+ back("ok", "Invited "+to+" — the link is in their Keymail Apps inbox.")
+}
+
+func (s *Server) handleRevokeInvite(w http.ResponseWriter, r *http.Request) {
+ _, raw, acct, ok := s.owner(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ tok := r.FormValue("token")
+ // Scoped to THIS account: an owner of one account must not be able to
+ // revoke another account's invite by pasting its token.
+ pending, err := s.store.PendingInvites(acct.ID)
+ if err != nil {
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read the invitations.")
+ return
+ }
+ for _, inv := range pending {
+ if inv.Token == tok {
+ if err := s.store.RevokeInvite(tok); err != nil {
+ log.Printf("console: revoke invite: %v", err)
+ } else {
+ s.setFlash(w, "ok", "Revoked the invitation to "+inv.Address+".")
+ }
+ break
+ }
+ }
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/", http.StatusSeeOther)
+}
+
+// handleAcceptInvite consumes an invite link. It requires a session as the
+// invited address — the link lands in one Keymail inbox and is worthless to
+// anyone else. An address that has never signed in does the OAuth ceremony
+// first and comes back to the link.
+func (s *Server) handleAcceptInvite(w http.ResponseWriter, r *http.Request) {
+ addr, _, ok := s.session(r)
+ if !ok {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ now := time.Now()
+ accountID, err := s.store.AcceptInvite(r.PathValue("token"), addr, now)
+ if err != nil {
+ s.renderMessage(w, r, http.StatusGone, "Invitation unavailable",
+ "That invitation has expired, was already used, or was sent to a different address. Ask for a fresh one.")
+ return
+ }
+ a, err := s.store.AccountByID(accountID)
+ if err != nil || a == nil {
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Joined, but could not open the account. Try the front page.")
+ return
+ }
+ if err := s.store.SetLastAccount(addr, accountID); err != nil {
+ log.Printf("console: set last account: %v", err)
+ }
+ s.setFlash(w, "ok", "You've joined "+a.Name+".")
+ http.Redirect(w, r, "/a/"+url.PathEscape(a.Sqid)+"/", http.StatusSeeOther)
+}
+
// handleContinue is the one button: empty address → the OAuth sign-up
// ceremony; an address → a confirmation link into its Keymail Apps inbox.
// The interstitial renders identically whether or not the address is known,
@@ -406,7 +718,7 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
if created {
s.setCookie(w, welcomeCookie, "1", 300)
}
- http.Redirect(w, r, "/apps", http.StatusSeeOther)
+ s.redirectToCurrentAccount(w, r, address, http.StatusSeeOther)
}
// handleConfirm consumes a returning-sign-in confirmation link. The token is
@@ -428,7 +740,7 @@ func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
return
}
s.setCookie(w, sessionCookie, raw, int(SessionTTL.Seconds()))
- http.Redirect(w, r, "/apps", http.StatusSeeOther)
+ s.redirectToCurrentAccount(w, r, addr, http.StatusSeeOther)
}
// handleSignout deletes the console session. The Keymail grant deliberately
@@ -487,25 +799,26 @@ func (s *Server) handleFleet(w http.ResponseWriter, r *http.Request) {
// ---- app pages --------------------------------------------------------------
-func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) {
- addr, raw, ok := s.session(r)
+// handleAccountHome is the account's app list, its switcher, its members and
+// its pending invitations — the page /​ lands you on.
+func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, ok := s.account(w, r)
if !ok {
- http.Redirect(w, r, "/", http.StatusFound)
return
}
- p := page{Title: "Your apps", Address: addr, CSRF: raw}
+ p := s.accountPage(w, r, addr, raw, acct, role)
+ p.Title = acct.Name
if c, err := r.Cookie(welcomeCookie); err == nil && c.Value == "1" {
p.Welcome = true
s.clearCookie(w, welcomeCookie)
}
- p.Flash, p.FlashErr = s.takeFlash(w, r)
- apps, err := s.store.AppsFor(addr)
+ apps, err := s.store.AppsIn(acct.ID)
if err != nil {
s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.")
return
}
if len(apps) > 0 {
- views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, release.OpsAccount, apps, s.cfg.EdgeIPs)
+ views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, apps, s.cfg.EdgeIPs)
if err != nil {
log.Printf("console: assemble: %v", err)
s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.")
@@ -515,57 +828,83 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) {
p.Apps = append(p.Apps, summarize(v))
}
}
+ if role == RoleOwner {
+ if invites, err := s.store.PendingInvites(acct.ID); err != nil {
+ log.Printf("console: pending invites: %v", err)
+ } else {
+ p.Invites = invites
+ }
+ }
+ if members, err := s.store.Members(acct.ID); err != nil {
+ log.Printf("console: members: %v", err)
+ } else {
+ p.Members = members
+ }
s.render(w, http.StatusOK, "apps", p)
}
-// ownedApp gates the per-app pages and mutations: session required, and the
-// path's app must be owned by the signed-in address — not owned and
-// nonexistent look identical (404) on purpose.
-//
-// acct is the account sqid whose bucket keys this app's objects live under.
-// Every route under this gate is still the account-less /apps/{app} shape, so
-// it is the built-in ops account for now; task 5 re-roots these handlers
-// under /a/{sqid}/ and the sqid comes from the path. Reads fall back to the
-// legacy unqualified keys either way, so nothing breaks in between.
-func (s *Server) ownedApp(w http.ResponseWriter, r *http.Request) (addr, raw, acct, app string, ok bool) {
- addr, raw, sok := s.session(r)
- if !sok {
- http.Redirect(w, r, "/", http.StatusFound)
- return "", "", "", "", false
+// accountPage fills the chrome every account-scoped page shares — the
+// switcher, the base URL its forms post to, the viewer's role — and records
+// the MRU, so signing in lands you back on the account you were last in.
+func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role string) page {
+ if err := s.store.SetLastAccount(addr, acct.ID); err != nil {
+ log.Printf("console: set last account: %v", err)
+ }
+ p := page{
+ Address: addr, CSRF: raw,
+ Account: acct, Role: role, Base: "/a/" + url.PathEscape(acct.Sqid),
+ CanPromote: s.cfg.Key != nil,
+ }
+ p.Flash, p.FlashErr = s.takeFlash(w, r)
+ if accts, err := s.store.AccountsFor(addr); err != nil {
+ log.Printf("console: switcher: %v", err)
+ } else {
+ p.Accounts = accts
+ }
+ return p
+}
+
+// accountApp gates the per-app pages and mutations: membership in the
+// account (any role — a member can deploy), and an app that account actually
+// claims. An app that is not this account's and one that does not exist look
+// identical on purpose.
+func (s *Server) accountApp(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, role, app string, ok bool) {
+ addr, raw, acct, role, ok = s.account(w, r)
+ if !ok {
+ return "", "", nil, "", "", false
}
app = r.PathValue("app")
- apps, err := s.store.AppsFor(addr)
+ has, err := s.store.HasAppIn(acct.ID, app)
if err != nil {
s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.")
- return "", "", "", "", false
+ return "", "", nil, "", "", false
}
- for _, a := range apps {
- if a == app {
- return addr, raw, release.OpsAccount, app, true
- }
+ if !has {
+ s.renderMessage(w, r, http.StatusNotFound, "Not found", "No such app here.")
+ return "", "", nil, "", "", false
}
- s.renderMessage(w, r, http.StatusNotFound, "Not found", "No such app here.")
- return "", "", "", "", false
+ return addr, raw, acct, role, app, true
}
func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
- addr, raw, acct, app, ok := s.ownedApp(w, r)
+ addr, raw, acct, role, app, ok := s.accountApp(w, r)
if !ok {
return
}
- // Backfill the account manifest for apps claimed before the domains
- // feature existed (or CLI-granted ones): best-effort, on the OWNER's own
- // first view — ownedApp above already proved the viewer owns this app.
- s.ensureAccountManifest(r.Context(), app, addr)
- views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct, []string{app}, s.cfg.EdgeIPs)
+ // The account manifest is re-attempted on view: minting writes it, but a
+ // bucket hiccup there is deliberately non-fatal, and this is where it gets
+ // another chance. Best-effort — a page view never fails on it.
+ s.ensureAccountManifest(r.Context(), acct)
+ views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, []string{app}, s.cfg.EdgeIPs)
if err != nil || len(views) != 1 {
log.Printf("console: assemble %s: %v", app, err)
s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.")
return
}
- p := page{Title: app, Address: addr, CSRF: raw, Detail: &views[0], CanPromote: s.cfg.Key != nil}
- p.Flash, p.FlashErr = s.takeFlash(w, r)
- versions, err := Versions(s.cfg.Releases, acct, app)
+ p := s.accountPage(w, r, addr, raw, acct, role)
+ p.Title = app
+ p.Detail = &views[0]
+ versions, err := Versions(s.cfg.Releases, acct.Sqid, app)
if err != nil {
log.Printf("console: versions %s: %v", app, err)
p.FlashErr = "Could not list this app's releases."
@@ -591,44 +930,15 @@ func (s *Server) audit(app, addr, action, detail string) {
}
}
-// writeAccountManifest lands apps/<app>/account.json in the deployment
-// bucket: {"account": <addr>, "owner": <addr>} — the account name IS the
-// claiming address for now (one account per user), and the agent's domains
-// reconcile pass materializes it into the registry from here. Overwrite-safe
-// put (status.PublishFleet's read-etag-then-Put idiom): the store's Put is
-// CAS-only, and a backfill racing another of the owner's own tabs must
-// retry, not fail.
-func (s *Server) writeAccountManifest(ctx context.Context, app, addr string) error {
- key := "apps/" + app + "/account.json"
- body, err := json.Marshal(map[string]string{"account": addr, "owner": addr})
- if err != nil {
- return err
- }
- put := func() error {
- etag := ""
- if _, cur, err := s.cfg.Releases.Get(ctx, key); err == nil {
- etag = cur
- } else if !errors.Is(err, os.ErrNotExist) {
- return err
- }
- _, err := s.cfg.Releases.Put(ctx, key, body, etag)
- return err
- }
- if err := put(); errors.Is(err, release.ErrCASConflict) {
- return put() // one retry on a lost race; a second loss surfaces
- } else if err != nil {
- return err
- }
- return nil
-}
-
-// ensureAccountManifest is the view-time backfill: write the manifest only
-// when none exists. An existing manifest is account truth someone already
-// wrote (possibly by hand, possibly naming a different account) — a page
-// view must never clobber it. Best-effort: failures are logged, never fail
-// the page.
-func (s *Server) ensureAccountManifest(ctx context.Context, app, addr string) {
- key := "apps/" + app + "/account.json"
+// ensureAccountManifest is the view-time backfill for account identity: write
+// accounts/<sqid>/account.json only when none exists. An existing manifest is
+// what every box has already materialized — a page view must never clobber
+// it, because rewriting a different id there would re-home every platform
+// domain the account owns. It also covers the accounts that predate this
+// feature (the built-in ops account, and any minted while the bucket was
+// unreachable). Best-effort: failures are logged, never fail the page.
+func (s *Server) ensureAccountManifest(ctx context.Context, acct *Account) {
+ key := AccountManifestKey(acct.Sqid)
_, _, err := s.cfg.Releases.Get(ctx, key)
if err == nil {
return // already there — nothing to backfill
@@ -637,11 +947,11 @@ func (s *Server) ensureAccountManifest(ctx context.Context, app, addr string) {
log.Printf("console: account manifest %s: %v", key, err)
return
}
- if err := s.writeAccountManifest(ctx, app, addr); err != nil {
+ if err := WriteAccountManifest(ctx, s.cfg.Releases, acct.ID, acct.Sqid, acct.Name, acct.CreatedAt); err != nil {
log.Printf("console: backfill account manifest %s: %v", key, err)
return
}
- log.Printf("console: backfilled account manifest for %s (owner %s)", app, addr)
+ log.Printf("console: backfilled account manifest for %s (%s)", acct.Sqid, acct.Name)
}
// handleNewApp claims an app name — once, ever. A name is refused when the
@@ -649,49 +959,58 @@ func (s *Server) ensureAccountManifest(ctx context.Context, app, addr string) {
// claim-proof), or when the release store already holds anything under
// apps/<name>/.
func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
- addr, raw, ok := s.session(r)
+ addr, raw, acct, _, ok := s.account(w, r)
if !ok {
- http.Redirect(w, r, "/", http.StatusFound)
return
}
if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
return
}
+ home := "/a/" + url.PathEscape(acct.Sqid) + "/"
fail := func(msg string) {
s.setFlash(w, "err", msg)
- http.Redirect(w, r, "/apps", http.StatusSeeOther)
+ http.Redirect(w, r, home, http.StatusSeeOther)
}
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
if !release.ValidApp(name) {
fail("Invalid app name — lowercase letters, digits, and dashes, starting with a letter.")
return
}
- if has, err := s.store.HasApp(name); err != nil {
+ // Taken means taken IN THIS ACCOUNT — app names are unique per account,
+ // never globally, so another account's "blog" is not a conflict.
+ if has, err := s.store.HasAppIn(acct.ID, name); err != nil {
fail("Could not check that name. Please try again.")
return
} else if has {
fail("That name is taken.")
return
}
- if has, err := s.store.HasOwners(name); err != nil {
- fail("Could not check that name. Please try again.")
- return
- } else if has {
- fail("That name is taken.")
- return
+ // The pre-accounts checks still apply to the OPS account, where every
+ // CLI-granted and pre-accounts app lives: a name with grants or bucket
+ // objects there stays claim-proof.
+ if acct.ID == OpsAccountID {
+ if has, err := s.store.HasOwners(name); err != nil {
+ fail("Could not check that name. Please try again.")
+ return
+ } else if has {
+ fail("That name is taken.")
+ return
+ }
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
- // Both prefixes: the account-qualified one a migrated app writes to, and
- // the legacy unqualified one an unmigrated app's objects are still at.
- // Missing the legacy prefix here would hand out a name whose releases
- // already exist — which is precisely the "orphan bucket objects" case this
- // check was added for.
- for _, prefix := range []string{
- release.AppPrefix(release.OpsAccount, name),
- release.LegacyAppPrefix(name),
- } {
+ // The account's own prefix, plus — for ops only — the legacy unqualified
+ // one an unmigrated app's objects are still at. Missing the legacy prefix
+ // would hand out a name whose releases already exist, which is precisely
+ // the "orphan bucket objects" case this check was added for. For a
+ // non-ops account the legacy prefix belongs to somebody else's app and
+ // must NOT block the name.
+ prefixes := []string{release.AppPrefix(acct.Sqid, name)}
+ if acct.ID == OpsAccountID {
+ prefixes = append(prefixes, release.LegacyAppPrefix(name))
+ }
+ for _, prefix := range prefixes {
keys, err := s.cfg.Releases.List(ctx, prefix)
if err != nil {
fail("Could not check the release store. Please try again.")
@@ -702,7 +1021,7 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
return
}
}
- created, err := s.store.CreateApp(name, addr, time.Now())
+ created, err := s.store.CreateAppIn(acct.ID, name, addr, time.Now())
if err != nil {
fail("Could not create the app. Please try again.")
return
@@ -711,25 +1030,19 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
fail("That name is taken.")
return
}
- if err := s.store.Grant(addr, name); err != nil {
- fail("Created the app, but granting you ownership failed — ask an operator.")
- return
- }
- // The account manifest: claim is where account truth originates, and it
- // travels through the bucket to the agent's reconcile pass. Best-effort —
- // the claim already succeeded, and the app-page backfill is the safety
- // net if this write loses to a network hiccup.
- if err := s.writeAccountManifest(ctx, name, addr); err != nil {
- log.Printf("console: account manifest for %s: %v", name, err)
- }
+ // The app→account linkage needs no manifest of its own any more: it IS the
+ // bucket key path (apps/<sqid>/<app>/…), written by the first ship. The
+ // account's own manifest is what the agent materializes, and minting
+ // already wrote it.
+ s.ensureAccountManifest(ctx, acct)
s.audit(name, addr, "create", "created app "+name)
s.setFlash(w, "ok", "Created app "+name+" — it's yours.")
- http.Redirect(w, r, "/apps/"+url.PathEscape(name), http.StatusSeeOther)
+ http.Redirect(w, r, home+"apps/"+url.PathEscape(name), http.StatusSeeOther)
}
// handlePromote points a channel at a shipped version, from the app page.
func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
- addr, raw, acct, app, ok := s.ownedApp(w, r)
+ addr, raw, acct, _, app, ok := s.accountApp(w, r)
if !ok {
return
}
@@ -744,7 +1057,7 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/apps/"+url.PathEscape(app), http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
}
channel := strings.TrimSpace(r.FormValue("new_channel"))
if channel == "" {
@@ -761,11 +1074,11 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
back("err", "Invalid version.")
return
}
- if _, err := release.GetManifest(ctx, s.cfg.Releases, acct, app, version); err != nil {
+ if _, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version); err != nil {
back("err", "Version "+version+" is not a shipped release of "+app+".")
return
}
- if err := release.Promote(ctx, s.cfg.Releases, s.cfg.Key, acct, app, channel, version); err != nil {
+ if err := release.Promote(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, version); err != nil {
log.Printf("console: promote %s %s -> %s: %v", app, version, channel, err)
back("err", "Promote failed: "+err.Error())
return
@@ -780,7 +1093,7 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
// handleRollback swaps a channel back to its previous version.
func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
- addr, raw, acct, app, ok := s.ownedApp(w, r)
+ addr, raw, acct, _, app, ok := s.accountApp(w, r)
if !ok {
return
}
@@ -795,7 +1108,7 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/apps/"+url.PathEscape(app), http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
}
channel := strings.TrimSpace(r.FormValue("channel"))
if !release.ValidChannel(channel) {
@@ -804,7 +1117,7 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
- cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct, app, channel)
+ cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel)
if err != nil {
back("err", "Could not read channel "+channel+".")
return
@@ -814,7 +1127,7 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
return
}
target := cur.History[0]
- if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct, app, channel); err != nil {
+ if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil {
log.Printf("console: rollback %s %s: %v", app, channel, err)
back("err", "Rollback failed: "+err.Error())
return
@@ -866,7 +1179,7 @@ func (s *Server) channelHosts(app, channel string) ([]string, error) {
// pointed at a chosen shipped version. Canary channels bypass bake by
// design, so the canary serves the moment a host follows its channel.
func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) {
- addr, raw, acct, app, ok := s.ownedApp(w, r)
+ addr, raw, acct, _, app, ok := s.accountApp(w, r)
if !ok {
return
}
@@ -881,7 +1194,7 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/apps/"+url.PathEscape(app), http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
}
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
if !canaryNameRe.MatchString(name) {
@@ -896,20 +1209,20 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) {
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
- if _, err := release.GetManifest(ctx, s.cfg.Releases, acct, app, version); err != nil {
+ if _, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version); err != nil {
back("err", "Version "+version+" is not a shipped release of "+app+".")
return
}
// New means new: an existing canary is promoted to (releases table) or
// retired, never quietly re-created.
- if _, _, err := s.cfg.Releases.Get(ctx, release.ChannelKey(acct, app, channel)); err == nil {
+ if _, _, err := s.cfg.Releases.Get(ctx, release.ChannelKey(acct.Sqid, app, channel)); err == nil {
back("err", "Canary "+name+" already exists — promote to it from the releases table, or retire it first.")
return
} else if !errors.Is(err, os.ErrNotExist) {
back("err", "Could not check the release store. Please try again.")
return
}
- if err := release.Promote(ctx, s.cfg.Releases, s.cfg.Key, acct, app, channel, version); err != nil {
+ if err := release.Promote(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, version); err != nil {
log.Printf("console: canary %s %s -> %s: %v", app, version, channel, err)
back("err", "Creating the canary failed: "+err.Error())
return
@@ -928,7 +1241,7 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) {
// handleRetireCanary removes a canary's channel pointer. Shipped releases
// are untouched — retirement un-points, it never deletes bits.
func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) {
- addr, raw, acct, app, ok := s.ownedApp(w, r)
+ addr, raw, acct, _, app, ok := s.accountApp(w, r)
if !ok {
return
}
@@ -943,7 +1256,7 @@ func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/apps/"+url.PathEscape(app), http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
}
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
if !canaryNameRe.MatchString(name) {
@@ -967,7 +1280,7 @@ func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) {
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
- key := release.ChannelKey(acct, app, channel)
+ key := release.ChannelKey(acct.Sqid, app, channel)
if _, _, err := s.cfg.Releases.Get(ctx, key); errors.Is(err, os.ErrNotExist) {
back("err", "No canary named "+name+" here.")
return
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go
new file mode 100644
index 000000000..2ea0a33d1
--- /dev/null
+++ b/internal/console/server_account_test.go
@@ -0,0 +1,264 @@
+package console
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+// Account existence must not be probeable: a signed-in non-member asking for
+// an account URL gets the BYTE-IDENTICAL response a nonexistent page gets.
+// Same assertion shape as TestFleetOperatorOnly — compare the full body
+// against a genuinely unrouted path.
+func TestNonMemberGetsIdenticalNotFound(t *testing.T) {
+ fx := newConsoleFixture(t)
+ // paul owns the fixture account; bob is signed in but is not a member.
+ fx.setUser("bob@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-bob")
+
+ memberStatus, _, memberBody := get(t, c, fx.u("/"))
+ ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page")
+ if memberStatus != http.StatusNotFound || memberStatus != ghostStatus {
+ t.Fatalf("non-member account URL = %d, nonexistent page = %d; want both 404", memberStatus, ghostStatus)
+ }
+ if memberBody != ghostBody {
+ t.Fatalf("non-member 404 body differs from a nonexistent page's:\n%q\n%q", memberBody, ghostBody)
+ }
+ // An account that does not exist AT ALL answers identically, so you cannot
+ // tell "not yours" from "not real".
+ if _, _, body := get(t, c, fx.ts.URL+"/a/zzzz/"); body != ghostBody {
+ t.Fatalf("nonexistent account body differs: %q", body)
+ }
+ // And the mutation routes are gated the same way.
+ st, _, _ := postForm(t, c, fx.u("/apps/kass/promote"), url.Values{"channel": {"stable"}, "version": {"v1"}})
+ if st != http.StatusNotFound {
+ t.Errorf("non-member promote = %d, want 404", st)
+ }
+}
+
+func TestLandingRedirectsToLastAccount(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-1")
+
+ // Signing up lands somewhere; visiting the fixture account sets the MRU.
+ if st, _, _ := get(t, c, fx.u("/")); st != http.StatusOK {
+ t.Fatalf("account home = %d", st)
+ }
+ // A second account, visited after, becomes the MRU.
+ id := NewAccountID()
+ sqid := accountSqid(id)
+ if err := fx.st.CreateAccount(id, sqid, "Second", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if st, _, _ := get(t, c, fx.ts.URL+"/a/"+sqid+"/"); st != http.StatusOK {
+ t.Fatalf("second account home = %d", st)
+ }
+ st, loc, _ := get(t, c, fx.ts.URL+"/")
+ if st != http.StatusFound || loc != "/a/"+sqid+"/" {
+ t.Fatalf("landing = %d -> %q, want 302 -> /a/%s/", st, loc, sqid)
+ }
+}
+
+// A user with no memberships at all gets a personal account minted on the
+// spot — with a bucket manifest, so every box materializes the same identity.
+func TestLandingMintsPersonalAccountWhenNoMemberships(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.setUser("newbie@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-new")
+
+ st, loc, _ := get(t, c, fx.ts.URL+"/")
+ if st != http.StatusFound || !strings.HasPrefix(loc, "/a/") {
+ t.Fatalf("landing = %d -> %q, want 302 into an account", st, loc)
+ }
+ accts, err := fx.st.AccountsFor("newbie@keymail.dev")
+ if err != nil || len(accts) != 1 {
+ t.Fatalf("AccountsFor = %+v %v, want one minted account", accts, err)
+ }
+ if accts[0].Role != RoleOwner {
+ t.Errorf("minted account role = %q, want owner", accts[0].Role)
+ }
+ if loc != "/a/"+accts[0].Sqid+"/" {
+ t.Errorf("landed at %q, want /a/%s/", loc, accts[0].Sqid)
+ }
+ // The manifest is in the bucket, and its sqid is the frozen encoding.
+ if _, _, err := fx.rel.Get(t.Context(), AccountManifestKey(accts[0].Sqid)); err != nil {
+ t.Fatalf("no account manifest in the bucket: %v", err)
+ }
+ if accountSqid(accts[0].ID) != accts[0].Sqid {
+ t.Errorf("minted sqid %q is not Sqid(%d)", accts[0].Sqid, accts[0].ID)
+ }
+ // Landing again does NOT mint a second one.
+ get(t, c, fx.ts.URL+"/")
+ if accts, _ := fx.st.AccountsFor("newbie@keymail.dev"); len(accts) != 1 {
+ t.Errorf("a second landing minted another account: %+v", accts)
+ }
+}
+
+func TestOwnerOnlyActionsRefuseMembers(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-sam")
+ csrf := sessionToken(t, fx, c)
+
+ // A member CAN deploy...
+ if st, _, _ := get(t, c, fx.u("/apps/kass")); st != http.StatusOK {
+ t.Fatalf("member app page = %d, want 200", st)
+ }
+ // ...but cannot invite or revoke: owner-only actions answer with the same
+ // 404 a non-member gets, so a member cannot even probe them.
+ st, _, _ := postForm(t, c, fx.u("/invite"),
+ url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}})
+ if st != http.StatusNotFound {
+ t.Errorf("member invite = %d, want 404", st)
+ }
+ if pending, _ := fx.st.PendingInvites(OpsAccountID); len(pending) != 0 {
+ t.Errorf("a member's refused invite still created one: %+v", pending)
+ }
+ st, _, _ = postForm(t, c, fx.u("/invite/revoke"), url.Values{"csrf": {csrf}, "token": {"whatever"}})
+ if st != http.StatusNotFound {
+ t.Errorf("member revoke = %d, want 404", st)
+ }
+}
+
+func TestInviteAcceptCreatesMembership(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := newBrowser(t)
+ signUp(t, fx, owner, "code-owner")
+ csrf := sessionToken(t, fx, owner)
+
+ st, loc, _ := postForm(t, owner, fx.u("/invite"),
+ url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
+ if st != http.StatusSeeOther || loc != fx.path("/") {
+ t.Fatalf("invite = %d -> %q", st, loc)
+ }
+ pending, _ := fx.st.PendingInvites(OpsAccountID)
+ if len(pending) != 1 || pending[0].Address != "sam@keymail.dev" {
+ t.Fatalf("PendingInvites = %+v", pending)
+ }
+ // The link went into sam's Keymail inbox, not an email.
+ sent := fx.fk.sentBodies()
+ if len(sent) == 0 || !strings.Contains(strings.Join(sent, "\n"), "/invite/"+pending[0].Token) {
+ t.Fatalf("invite link not delivered through keymail: %v", sent)
+ }
+
+ // Sam signs in and accepts.
+ fx.setUser("sam@keymail.dev")
+ sam := newBrowser(t)
+ signUp(t, fx, sam, "code-sam")
+ st, loc, _ = get(t, sam, fx.ts.URL+"/invite/"+pending[0].Token)
+ if st != http.StatusSeeOther || loc != fx.path("/") {
+ t.Fatalf("accept = %d -> %q, want 303 -> the account", st, loc)
+ }
+ if role, _ := fx.st.Role(OpsAccountID, "sam@keymail.dev"); role != RoleMember {
+ t.Fatalf("role after accept = %q, want member", role)
+ }
+ if st, _, _ := get(t, sam, fx.u("/")); st != http.StatusOK {
+ t.Errorf("accepted member cannot see the account: %d", st)
+ }
+ // Single use.
+ if st, _, _ := get(t, sam, fx.ts.URL+"/invite/"+pending[0].Token); st == http.StatusSeeOther {
+ t.Error("invite link worked twice")
+ }
+}
+
+func TestSwitcherListsOnlyMyAccounts(t *testing.T) {
+ fx := newConsoleFixture(t)
+ // An account paul is not a member of.
+ otherID := NewAccountID()
+ if err := fx.st.CreateAccount(otherID, accountSqid(otherID), "Someone Else", "sam@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ mineID := NewAccountID()
+ if err := fx.st.CreateAccount(mineID, accountSqid(mineID), "Mine", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-1")
+ _, _, body := get(t, c, fx.u("/"))
+ if !strings.Contains(body, "Mine") {
+ t.Error("switcher is missing an account the user belongs to")
+ }
+ if strings.Contains(body, "Someone Else") {
+ t.Errorf("switcher lists an account the user is not a member of:\n%s", body)
+ }
+ if !strings.Contains(body, "/a/"+accountSqid(mineID)+"/") {
+ t.Error("switcher has no link to the user's other account")
+ }
+}
+
+// Creating an account from the console: minted id, bucket manifest, owner
+// membership, and you land in it.
+func TestCreateAccountFromConsole(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-1")
+ csrf := sessionToken(t, fx, c)
+
+ st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new",
+ url.Values{"csrf": {csrf}, "name": {"Kass"}})
+ if st != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") {
+ t.Fatalf("create account = %d -> %q", st, loc)
+ }
+ accts, _ := fx.st.AccountsFor("paul@keymail.dev")
+ var made *Account
+ for i := range accts {
+ if accts[i].Name == "Kass" {
+ made = &accts[i]
+ }
+ }
+ if made == nil || made.Role != RoleOwner {
+ t.Fatalf("AccountsFor = %+v, want an owned Kass", accts)
+ }
+ if _, _, err := fx.rel.Get(t.Context(), AccountManifestKey(made.Sqid)); err != nil {
+ t.Errorf("no manifest for the new account: %v", err)
+ }
+ if loc != "/a/"+made.Sqid+"/" {
+ t.Errorf("landed at %q, want the new account", loc)
+ }
+}
+
+// App names are unique per ACCOUNT: the same name in two accounts is two
+// different apps, with two different bucket prefixes.
+func TestSameAppNameInTwoAccounts(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-1")
+ csrf := sessionToken(t, fx, c)
+
+ id := NewAccountID()
+ sqid := accountSqid(id)
+ if err := fx.st.CreateAccount(id, sqid, "Second", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ for _, base := range []string{fx.path(""), "/a/" + sqid} {
+ st, _, _ := postForm(t, c, fx.ts.URL+base+"/apps/new",
+ url.Values{"csrf": {csrf}, "name": {"blog"}})
+ if st != http.StatusSeeOther {
+ t.Fatalf("claim blog under %s = %d", base, st)
+ }
+ }
+ for _, acct := range []int64{OpsAccountID, id} {
+ apps, err := fx.st.AppsIn(acct)
+ if err != nil || len(apps) == 0 {
+ t.Fatalf("AppsIn(%d) = %v %v", acct, apps, err)
+ }
+ found := false
+ for _, a := range apps {
+ if a == "blog" {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("account %d has no blog: %v", acct, apps)
+ }
+ }
+}
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 184f47cc1..d0a597ad1 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -34,6 +34,7 @@ type consoleFixture struct {
regPath string
priv ed25519.PrivateKey
pub ed25519.PublicKey
+ sqid string // the fixture account's public identity
}
func newConsoleFixture(t *testing.T) *consoleFixture {
@@ -85,12 +86,24 @@ func newConsoleFixtureWith(t *testing.T, withKey bool, wrap func(release.Store)
}
// Fixture route hosts don't resolve; keep the not-reporting verdict quick.
srv.instances.timeout = 100 * time.Millisecond
- if err := st.Grant("paul@keymail.dev", "kass"); err != nil {
+ // paul owns the built-in ops account (created by NewServer's backfill),
+ // which claims kass — the shape a deployment is in once the pre-accounts
+ // grants have been mapped onto memberships.
+ if err := st.AddMember(OpsAccountID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
t.Fatal(err)
}
- return &consoleFixture{ts: ts, srv: srv, st: st, fk: fk, rel: relStore, regPath: regPath, priv: priv, pub: pub}
+ if _, err := st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ return &consoleFixture{ts: ts, srv: srv, st: st, fk: fk, rel: relStore, regPath: regPath,
+ priv: priv, pub: pub, sqid: release.OpsAccount}
}
+// path is a path under the fixture's account, and u is its full URL — every
+// app page and mutation lives under /a/<sqid>/ now.
+func (fx *consoleFixture) path(p string) string { return "/a/" + fx.sqid + p }
+func (fx *consoleFixture) u(p string) string { return fx.ts.URL + fx.path(p) }
+
// channelVersion reads a channel pointer straight from the bucket.
func (fx *consoleFixture) channelVersion(t *testing.T, app, channel string) *release.ChannelPointer {
t.Helper()
@@ -180,8 +193,10 @@ func signUp(t *testing.T, fx *consoleFixture, c *http.Client, code string) {
fx.fk.expectCode = code
fx.fk.mu.Unlock()
status, loc, _ = get(t, c, fx.ts.URL+"/auth/callback?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(q.Get("state")))
- if status != http.StatusSeeOther || loc != "/apps" {
- t.Fatalf("callback = %d -> %q, want 303 -> /apps", status, loc)
+ // Signing in lands you in an account — the MRU one, your first membership,
+ // or one minted on the spot.
+ if status != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") {
+ t.Fatalf("callback = %d -> %q, want 303 into an account", status, loc)
}
}
@@ -204,7 +219,7 @@ func TestConsoleE2E(t *testing.T) {
if status, _, body := get(t, c, fx.ts.URL+"/healthz"); status != 200 || !strings.Contains(body, "ok") {
t.Fatalf("healthz = %d %q", status, body)
}
- if status, loc, _ := get(t, c, fx.ts.URL+"/apps"); status != http.StatusFound || loc != "/" {
+ if status, loc, _ := get(t, c, fx.u("/")); status != http.StatusFound || loc != "/" {
t.Fatalf("signed-out /apps = %d -> %q, want 302 -> /", status, loc)
}
if _, _, body := get(t, c, fx.ts.URL+"/"); !strings.Contains(body, "Sign in with Keymail") {
@@ -214,7 +229,7 @@ func TestConsoleE2E(t *testing.T) {
signUp(t, fx, c, "code-1")
// First view: kass listed (and only kass), with the account-created flash.
- _, _, body := get(t, c, fx.ts.URL+"/apps")
+ _, _, body := get(t, c, fx.u("/"))
for _, want := range []string{"Account created", "paul@keymail.dev", "kass", "v1", "canary/x v2"} {
if !strings.Contains(body, want) {
t.Fatalf("/apps missing %q in:\n%s", want, body)
@@ -224,12 +239,12 @@ func TestConsoleE2E(t *testing.T) {
t.Fatal("/apps lists an app the user does not own")
}
// The flash is one-shot.
- if _, _, body := get(t, c, fx.ts.URL+"/apps"); strings.Contains(body, "Account created") {
+ if _, _, body := get(t, c, fx.u("/")); strings.Contains(body, "Account created") {
t.Fatal("welcome flash survived a second page view")
}
// Detail page: routes and channels, with the bake approximation labeled.
- status, _, body := get(t, c, fx.ts.URL+"/apps/kass")
+ status, _, body := get(t, c, fx.u("/apps/kass"))
if status != 200 {
t.Fatalf("/apps/kass = %d", status)
}
@@ -244,10 +259,10 @@ func TestConsoleE2E(t *testing.T) {
}
// Apps not owned 404 exactly like apps that don't exist.
- if status, _, _ := get(t, c, fx.ts.URL+"/apps/otherapp"); status != http.StatusNotFound {
+ if status, _, _ := get(t, c, fx.u("/apps/otherapp")); status != http.StatusNotFound {
t.Fatalf("/apps/otherapp = %d, want 404", status)
}
- if status, _, _ := get(t, c, fx.ts.URL+"/apps/nosuchapp"); status != http.StatusNotFound {
+ if status, _, _ := get(t, c, fx.u("/apps/nosuchapp")); status != http.StatusNotFound {
t.Fatalf("/apps/nosuchapp = %d, want 404", status)
}
@@ -259,7 +274,7 @@ func TestConsoleE2E(t *testing.T) {
if status, loc, _ := postForm(t, c, fx.ts.URL+"/auth/signout", url.Values{"csrf": {raw}}); status != http.StatusSeeOther || loc != "/" {
t.Fatalf("signout = %d -> %q, want 303 -> /", status, loc)
}
- if status, _, _ := get(t, c, fx.ts.URL+"/apps"); status != http.StatusFound {
+ if status, _, _ := get(t, c, fx.u("/")); status != http.StatusFound {
t.Fatalf("post-signout /apps = %d, want redirect", status)
}
}
@@ -293,10 +308,10 @@ func TestReturningSignInViaConfirmationLink(t *testing.T) {
}
// The link mints a session — once.
- if status, loc, _ := get(t, c, link); status != http.StatusSeeOther || loc != "/apps" {
+ if status, loc, _ := get(t, c, link); status != http.StatusSeeOther || loc != fx.path("/") {
t.Fatalf("confirm = %d -> %q, want 303 -> /apps", status, loc)
}
- _, _, body = get(t, c, fx.ts.URL+"/apps")
+ _, _, body = get(t, c, fx.u("/"))
if !strings.Contains(body, "kass") {
t.Fatal("confirmed session doesn't see the user's apps")
}
@@ -343,11 +358,11 @@ func TestSecondOAuthIsPlainSignIn(t *testing.T) {
fx := newConsoleFixture(t)
c1 := newBrowser(t)
signUp(t, fx, c1, "code-1")
- get(t, c1, fx.ts.URL+"/apps") // consume the flash
+ get(t, c1, fx.u("/")) // consume the flash
c2 := newBrowser(t)
signUp(t, fx, c2, "code-2") // same address, fresh ceremony: no re-signup
- if _, _, body := get(t, c2, fx.ts.URL+"/apps"); strings.Contains(body, "Account created") {
+ if _, _, body := get(t, c2, fx.u("/")); strings.Contains(body, "Account created") {
t.Fatal("second OAuth for a known address flashed account-created")
}
fx.fk.mu.Lock()
@@ -359,16 +374,19 @@ func TestSecondOAuthIsPlainSignIn(t *testing.T) {
func TestEmptyStateWelcomesNewUser(t *testing.T) {
fx := newConsoleFixture(t)
- fx.setUser("bob@keymail.dev") // no grants for bob
+ fx.setUser("bob@keymail.dev") // no memberships for bob
c := newBrowser(t)
signUp(t, fx, c, "code-b")
- status, _, body := get(t, c, fx.ts.URL+"/apps")
+ // bob has no memberships, so signing in mints him a personal account and
+ // lands him in it — the empty state is that account's, not paul's.
+ _, home, _ := get(t, c, fx.ts.URL+"/")
+ status, _, body := get(t, c, fx.ts.URL+home)
if status != 200 {
- t.Fatalf("/apps = %d", status)
+ t.Fatalf("account home = %d", status)
}
for _, want := range []string{
"Account created", "Welcome to the CARLOS platform",
- "bob@keymail.dev", "Create app", `action="/apps/new"`,
+ "bob@keymail.dev", "Create app", `action="` + strings.TrimSuffix(home, "/") + `/apps/new"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("empty state missing %q in:\n%s", want, body)
@@ -393,11 +411,11 @@ func TestCreateApp(t *testing.T) {
csrf := sessionToken(t, fx, c)
// Happy path: claim, own, land on the ship-your-first-release panel.
- status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/new", url.Values{"csrf": {csrf}, "name": {"zephyr"}})
- if status != http.StatusSeeOther || loc != "/apps/zephyr" {
+ status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"zephyr"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/zephyr") {
t.Fatalf("claim = %d -> %q, want 303 -> /apps/zephyr", status, loc)
}
- status, _, body := get(t, c, fx.ts.URL+"/apps/zephyr")
+ status, _, body := get(t, c, fx.u("/apps/zephyr"))
if status != 200 {
t.Fatalf("/apps/zephyr = %d", status)
}
@@ -411,43 +429,41 @@ func TestCreateApp(t *testing.T) {
t.Fatalf("/apps/zephyr missing %q in:\n%s", want, body)
}
}
- apps, err := fx.st.AppsFor("paul@keymail.dev")
+ apps, err := fx.st.AppsIn(OpsAccountID)
if err != nil || !strings.Contains(strings.Join(apps, " "), "zephyr") {
- t.Fatalf("AppsFor = %v %v, want zephyr owned", apps, err)
+ t.Fatalf("AppsIn = %v %v, want zephyr claimed in the account", apps, err)
}
- // A second user cannot claim the same name, a CLI-granted app, or an
- // app with releases already in the bucket — all the same "taken".
+ // Taken is taken WITHIN THE ACCOUNT: the name just claimed, a name the
+ // pre-accounts grants hold, and a name whose releases already sit in the
+ // bucket — all the same "taken". (Another ACCOUNT claiming the same name
+ // is not a conflict; that's TestSameAppNameInTwoAccounts.)
if err := release.Ship(context.Background(), fx.rel, release.OpsAccount, release.Manifest{
Schema: 1, App: "orphan", Version: "v1", Kind: "binary",
Artifacts: map[string]release.Artifact{"linux-amd64": {File: "orphan"}},
}, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil {
t.Fatal(err)
}
- fx.setUser("bob@keymail.dev")
- cb := newBrowser(t)
- signUp(t, fx, cb, "code-bob")
- bobCSRF := sessionToken(t, fx, cb)
- for _, name := range []string{"zephyr", "kass", "orphan"} {
- if status, loc, _ := postForm(t, cb, fx.ts.URL+"/apps/new", url.Values{"csrf": {bobCSRF}, "name": {name}}); status != http.StatusSeeOther || loc != "/apps" {
- t.Fatalf("claim %s = %d -> %q, want 303 -> /apps", name, status, loc)
+ if err := fx.st.Grant("cli@keymail.dev", "cligranted"); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"zephyr", "kass", "orphan", "cligranted"} {
+ if status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {name}}); status != http.StatusSeeOther || loc != fx.path("/") {
+ t.Fatalf("claim %s = %d -> %q, want 303 -> the account home", name, status, loc)
}
- if _, _, body := get(t, cb, fx.ts.URL+"/apps"); !strings.Contains(body, "That name is taken.") {
+ if _, _, body := get(t, c, fx.u("/")); !strings.Contains(body, "That name is taken.") {
t.Fatalf("claim %s: no taken flash", name)
}
- if apps, _ := fx.st.AppsFor("bob@keymail.dev"); len(apps) != 0 {
- t.Fatalf("bob owns %v after refused claim of %s", apps, name)
- }
}
// Invalid names refused; CSRF enforced.
- if status, _, _ := postForm(t, cb, fx.ts.URL+"/apps/new", url.Values{"csrf": {bobCSRF}, "name": {"Bad Name!"}}); status != http.StatusSeeOther {
+ if status, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"Bad Name!"}}); status != http.StatusSeeOther {
t.Fatalf("invalid name = %d, want redirect with flash", status)
}
- if _, _, body := get(t, cb, fx.ts.URL+"/apps"); !strings.Contains(body, "Invalid app name") {
+ if _, _, body := get(t, c, fx.u("/")); !strings.Contains(body, "Invalid app name") {
t.Fatal("no invalid-name flash")
}
- if status, _, _ := postForm(t, cb, fx.ts.URL+"/apps/new", url.Values{"csrf": {"wrong"}, "name": {"fine-name"}}); status != http.StatusForbidden {
+ if status, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {"wrong"}, "name": {"fine-name"}}); status != http.StatusForbidden {
t.Fatalf("bad csrf claim = %d, want 403", status)
}
}
@@ -458,8 +474,8 @@ func TestPromoteFlow(t *testing.T) {
csrf := sessionToken(t, fx, c)
// The app page offers promote forms per shipped version.
- _, _, body := get(t, c, fx.ts.URL+"/apps/kass")
- if !strings.Contains(body, `action="/apps/kass/promote"`) {
+ _, _, body := get(t, c, fx.u("/apps/kass"))
+ if !strings.Contains(body, `action="/a/bab/apps/kass/promote"`) {
t.Fatal("no promote form on the app page")
}
if strings.Contains(body, "Promotions disabled") {
@@ -467,34 +483,34 @@ func TestPromoteFlow(t *testing.T) {
}
// Promote v2 to stable (select field).
- status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/kass/promote",
+ status, loc, _ := postForm(t, c, fx.u("/apps/kass/promote"),
url.Values{"csrf": {csrf}, "version": {"v2"}, "channel": {"stable"}})
- if status != http.StatusSeeOther || loc != "/apps/kass" {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
t.Fatalf("promote = %d -> %q", status, loc)
}
if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" || len(p.History) == 0 || p.History[0] != "v1" {
t.Fatalf("stable pointer = %+v, want v2 with v1 in history", p)
}
- _, _, body = get(t, c, fx.ts.URL+"/apps/kass")
+ _, _, body = get(t, c, fx.u("/apps/kass"))
if !strings.Contains(body, "Promoted v2 to stable.") ||
!strings.Contains(body, "bake window") || !strings.Contains(body, "rollback never bakes") {
t.Fatalf("promote flash missing bake note:\n%s", body)
}
// A brand-new channel via the free-text field; beta also bakes.
- postForm(t, c, fx.ts.URL+"/apps/kass/promote",
+ postForm(t, c, fx.u("/apps/kass/promote"),
url.Values{"csrf": {csrf}, "version": {"v1"}, "channel": {"stable"}, "new_channel": {"beta"}})
if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v1" {
t.Fatalf("beta pointer = %+v, want v1", p)
}
- if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "Promoted v1 to beta.") {
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Promoted v1 to beta.") {
t.Fatal("no beta promote flash")
}
// Unshipped version: refused, pointer unmoved.
- postForm(t, c, fx.ts.URL+"/apps/kass/promote",
+ postForm(t, c, fx.u("/apps/kass/promote"),
url.Values{"csrf": {csrf}, "version": {"v9"}, "channel": {"stable"}})
- if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "not a shipped release") {
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "not a shipped release") {
t.Fatal("no bad-version flash")
}
if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" {
@@ -502,21 +518,21 @@ func TestPromoteFlow(t *testing.T) {
}
// Invalid channel name refused.
- postForm(t, c, fx.ts.URL+"/apps/kass/promote",
+ postForm(t, c, fx.u("/apps/kass/promote"),
url.Values{"csrf": {csrf}, "version": {"v1"}, "new_channel": {"bad//name"}})
- if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "Invalid channel name.") {
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Invalid channel name.") {
t.Fatal("no invalid-channel flash")
}
// CSRF enforced; non-owners see the same 404 as everyone else.
- if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/promote",
+ if status, _, _ := postForm(t, c, fx.u("/apps/kass/promote"),
url.Values{"csrf": {"wrong"}, "version": {"v1"}, "channel": {"stable"}}); status != http.StatusForbidden {
t.Fatalf("bad csrf promote = %d, want 403", status)
}
fx.setUser("bob@keymail.dev")
cb := newBrowser(t)
signUp(t, fx, cb, "code-bob-p")
- if status, _, _ := postForm(t, cb, fx.ts.URL+"/apps/kass/promote",
+ if status, _, _ := postForm(t, cb, fx.u("/apps/kass/promote"),
url.Values{"csrf": {sessionToken(t, fx, cb)}, "version": {"v2"}, "channel": {"stable"}}); status != http.StatusNotFound {
t.Fatalf("non-owner promote = %d, want 404", status)
}
@@ -529,32 +545,32 @@ func TestRollbackFlow(t *testing.T) {
// Fixture stable is v1 with no history: no rollback button for it (nor
// for the single-promote canary), and a forced POST politely refuses.
- _, _, body := get(t, c, fx.ts.URL+"/apps/kass")
+ _, _, body := get(t, c, fx.u("/apps/kass"))
if strings.Contains(body, `name="channel" value="stable"`) || strings.Contains(body, `name="channel" value="canary/x"`) {
t.Fatal("rollback button rendered for a channel with no history")
}
- postForm(t, c, fx.ts.URL+"/apps/kass/rollback", url.Values{"csrf": {csrf}, "channel": {"canary/x"}})
- if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "no previous version") {
+ postForm(t, c, fx.u("/apps/kass/rollback"), url.Values{"csrf": {csrf}, "channel": {"canary/x"}})
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "no previous version") {
t.Fatal("no empty-history rollback flash")
}
// Promote v2 over v1, then roll back: pointer returns to v1 and v2
// becomes the (re-)rollback target.
- postForm(t, c, fx.ts.URL+"/apps/kass/promote",
+ postForm(t, c, fx.u("/apps/kass/promote"),
url.Values{"csrf": {csrf}, "version": {"v2"}, "channel": {"stable"}})
- if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, `name="channel" value="stable"`) {
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, `name="channel" value="stable"`) {
t.Fatal("no rollback button after stable gained history")
}
- status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/kass/rollback",
+ status, loc, _ := postForm(t, c, fx.u("/apps/kass/rollback"),
url.Values{"csrf": {csrf}, "channel": {"stable"}})
- if status != http.StatusSeeOther || loc != "/apps/kass" {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
t.Fatalf("rollback = %d -> %q", status, loc)
}
p := fx.channelVersion(t, "kass", "stable")
if p.Version != "v1" || len(p.History) == 0 || p.History[0] != "v2" {
t.Fatalf("after rollback stable = %+v, want v1 with v2 first in history", p)
}
- if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "Rolled back stable to v1.") {
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Rolled back stable to v1.") {
t.Fatal("no rollback flash")
}
}
@@ -577,9 +593,9 @@ func TestCanaryFlow(t *testing.T) {
csrf := sessionToken(t, fx, c)
// The app page: create form, and the fixture canary listed hostless.
- _, _, body := get(t, c, fx.ts.URL+"/apps/kass")
+ _, _, body := get(t, c, fx.u("/apps/kass"))
for _, want := range []string{
- `action="/apps/kass/canary"`, `action="/apps/kass/canary/retire"`,
+ `action="/a/bab/apps/kass/canary"`, `action="/a/bab/apps/kass/canary/retire"`,
"canary/x", "no host yet", "Create canary",
} {
if !strings.Contains(body, want) {
@@ -588,15 +604,15 @@ func TestCanaryFlow(t *testing.T) {
}
// Create a canary: a fresh canary/rc channel pointed at v1, no bake.
- status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/kass/canary",
+ status, loc, _ := postForm(t, c, fx.u("/apps/kass/canary"),
url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}})
- if status != http.StatusSeeOther || loc != "/apps/kass" {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
t.Fatalf("canary = %d -> %q", status, loc)
}
if p := fx.channelVersion(t, "kass", "canary/rc"); p.Version != "v1" {
t.Fatalf("canary/rc pointer = %+v, want v1", p)
}
- _, _, body = get(t, c, fx.ts.URL+"/apps/kass")
+ _, _, body = get(t, c, fx.u("/apps/kass"))
for _, want := range []string{"Created canary rc", "no bake", "carlos add -app kass -channel canary/rc", "canary/rc"} {
if !strings.Contains(body, want) {
t.Fatalf("post-create page missing %q in:\n%s", want, body)
@@ -604,9 +620,9 @@ func TestCanaryFlow(t *testing.T) {
}
// New means new: the existing canary refuses re-creation.
- postForm(t, c, fx.ts.URL+"/apps/kass/canary",
+ postForm(t, c, fx.u("/apps/kass/canary"),
url.Values{"csrf": {csrf}, "name": {"x"}, "version": {"v1"}})
- if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "Canary x already exists") {
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Canary x already exists") {
t.Fatal("no already-exists flash")
}
if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" {
@@ -614,17 +630,17 @@ func TestCanaryFlow(t *testing.T) {
}
// Bad names, unshipped versions, CSRF.
- postForm(t, c, fx.ts.URL+"/apps/kass/canary",
+ postForm(t, c, fx.u("/apps/kass/canary"),
url.Values{"csrf": {csrf}, "name": {"Bad Name"}, "version": {"v1"}})
- if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "Invalid canary name") {
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Invalid canary name") {
t.Fatal("no invalid-name flash")
}
- postForm(t, c, fx.ts.URL+"/apps/kass/canary",
+ postForm(t, c, fx.u("/apps/kass/canary"),
url.Values{"csrf": {csrf}, "name": {"ok-name"}, "version": {"v9"}})
- if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "not a shipped release") {
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "not a shipped release") {
t.Fatal("no unshipped-version flash")
}
- if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/canary",
+ if status, _, _ := postForm(t, c, fx.u("/apps/kass/canary"),
url.Values{"csrf": {"wrong"}, "name": {"rc2"}, "version": {"v1"}}); status != http.StatusForbidden {
t.Fatalf("bad csrf canary = %d, want 403", status)
}
@@ -633,7 +649,7 @@ func TestCanaryFlow(t *testing.T) {
fx.setUser("bob@keymail.dev")
cb := newBrowser(t)
signUp(t, fx, cb, "code-bob-c")
- if status, _, _ := postForm(t, cb, fx.ts.URL+"/apps/kass/canary",
+ if status, _, _ := postForm(t, cb, fx.u("/apps/kass/canary"),
url.Values{"csrf": {sessionToken(t, fx, cb)}, "name": {"evil"}, "version": {"v1"}}); status != http.StatusNotFound {
t.Fatalf("non-owner canary = %d, want 404", status)
}
@@ -648,9 +664,9 @@ func TestCanaryURLFromRoute(t *testing.T) {
// creating the canary makes it live there, and the console says so.
fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance",
Status: "ready", App: "kass", Channel: "canary/rc", Canary: true})
- postForm(t, c, fx.ts.URL+"/apps/kass/canary",
+ postForm(t, c, fx.u("/apps/kass/canary"),
url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}})
- _, _, body := get(t, c, fx.ts.URL+"/apps/kass")
+ _, _, body := get(t, c, fx.u("/apps/kass"))
if !strings.Contains(body, "live at https://rc.kass.carlosapp.dev") {
t.Fatalf("create flash has no canary URL:\n%s", body)
}
@@ -668,8 +684,8 @@ func TestRetireCanary(t *testing.T) {
// strand the host.
fx.addRoute(t, registry.Route{Host: "x.kass.carlosapp.dev", Kind: "instance",
Status: "ready", App: "kass", Channel: "canary/x", Canary: true})
- postForm(t, c, fx.ts.URL+"/apps/kass/canary/retire", url.Values{"csrf": {csrf}, "name": {"x"}})
- if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "still has a host") {
+ postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"x"}})
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "still has a host") {
t.Fatal("no still-followed flash")
}
if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" {
@@ -684,8 +700,8 @@ func TestRetireCanary(t *testing.T) {
if err := reg.Delete("x.kass.carlosapp.dev"); err != nil {
t.Fatal(err)
}
- status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/kass/canary/retire", url.Values{"csrf": {csrf}, "name": {"x"}})
- if status != http.StatusSeeOther || loc != "/apps/kass" {
+ status, loc, _ := postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"x"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
t.Fatalf("retire = %d -> %q", status, loc)
}
if _, _, err := fx.rel.Get(context.Background(), release.ChannelKey(release.OpsAccount, "kass", "canary/x")); !errors.Is(err, os.ErrNotExist) {
@@ -694,7 +710,7 @@ func TestRetireCanary(t *testing.T) {
if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "kass", "v2"); err != nil {
t.Fatalf("retire touched a shipped release: %v", err)
}
- _, _, body := get(t, c, fx.ts.URL+"/apps/kass")
+ _, _, body := get(t, c, fx.u("/apps/kass"))
if !strings.Contains(body, "Retired canary x") {
t.Fatal("no retired flash")
}
@@ -703,11 +719,11 @@ func TestRetireCanary(t *testing.T) {
}
// Retiring what isn't there is a polite no.
- postForm(t, c, fx.ts.URL+"/apps/kass/canary/retire", url.Values{"csrf": {csrf}, "name": {"ghost"}})
- if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "No canary named ghost") {
+ postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"ghost"}})
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "No canary named ghost") {
t.Fatal("no no-such-canary flash")
}
- if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/canary/retire",
+ if status, _, _ := postForm(t, c, fx.u("/apps/kass/canary/retire"),
url.Values{"csrf": {"wrong"}, "name": {"x"}}); status != http.StatusForbidden {
t.Fatalf("bad csrf retire = %d, want 403", status)
}
@@ -733,7 +749,7 @@ func TestRetireCanaryFailsClosedOnRegistryError(t *testing.T) {
t.Fatal(err)
}
- status, _, body := postForm(t, c, fx.ts.URL+"/apps/kass/canary/retire", url.Values{"csrf": {csrf}, "name": {"x"}})
+ status, _, body := postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"x"}})
if status != http.StatusInternalServerError {
t.Fatalf("retire with an unreadable registry = %d, want 500 (fail closed)", status)
}
@@ -750,8 +766,8 @@ func TestRetireWithoutDeletePrimitive(t *testing.T) {
})
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
- postForm(t, c, fx.ts.URL+"/apps/kass/canary/retire", url.Values{"csrf": {csrf}, "name": {"x"}})
- if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "retire channels yet") {
+ postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"x"}})
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "retire channels yet") {
t.Fatal("no unsupported-store flash")
}
if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" {
@@ -765,7 +781,7 @@ func TestAppPageInstances(t *testing.T) {
// Before any reporting route exists: no Instances section — the page is
// exactly the route-level view.
- if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); strings.Contains(body, "<h2>Instances</h2>") {
+ if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "<h2>Instances</h2>") {
t.Fatal("Instances section rendered with nothing reporting")
}
@@ -773,7 +789,7 @@ func TestAppPageInstances(t *testing.T) {
app := newInstanceApp(t, kassReport)
fx.addRoute(t, registry.Route{Host: app.host(t), Kind: "instance",
Status: "ready", App: "kass"})
- _, _, body := get(t, c, fx.ts.URL+"/apps/kass")
+ _, _, body := get(t, c, fx.u("/apps/kass"))
for _, want := range []string{
"<h2>Instances</h2>", "reports 3 internal instances",
"riders-2026", "spring-camp", "trial-run",
@@ -791,20 +807,20 @@ func TestRecentActivity(t *testing.T) {
csrf := sessionToken(t, fx, c)
// Fresh app page: no activity yet.
- if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); strings.Contains(body, "Recent activity") {
+ if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "Recent activity") {
t.Fatal("activity section rendered before any action")
}
- postForm(t, c, fx.ts.URL+"/apps/kass/promote",
+ postForm(t, c, fx.u("/apps/kass/promote"),
url.Values{"csrf": {csrf}, "version": {"v2"}, "channel": {"stable"}})
- postForm(t, c, fx.ts.URL+"/apps/kass/rollback",
+ postForm(t, c, fx.u("/apps/kass/rollback"),
url.Values{"csrf": {csrf}, "channel": {"stable"}})
- postForm(t, c, fx.ts.URL+"/apps/kass/canary",
+ postForm(t, c, fx.u("/apps/kass/canary"),
url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}})
- postForm(t, c, fx.ts.URL+"/apps/kass/canary/retire",
+ postForm(t, c, fx.u("/apps/kass/canary/retire"),
url.Values{"csrf": {csrf}, "name": {"rc"}})
- _, _, body := get(t, c, fx.ts.URL+"/apps/kass")
+ _, _, body := get(t, c, fx.u("/apps/kass"))
for _, want := range []string{
"Recent activity", "paul@keymail.dev",
"promoted v2 to stable", "rolled back stable to v1",
@@ -826,7 +842,7 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) {
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
- _, _, body := get(t, c, fx.ts.URL+"/apps/kass")
+ _, _, body := get(t, c, fx.u("/apps/kass"))
if !strings.Contains(body, "Promotions disabled: no signing key on this host.") {
t.Fatal("keyless console is missing the disabled note")
}
@@ -834,23 +850,23 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) {
if !strings.Contains(body, "Domains") || !strings.Contains(body, "kass.carlosapp.dev") {
t.Fatal("keyless console is missing the Domains section")
}
- if strings.Contains(body, `action="/apps/kass/promote"`) || strings.Contains(body, `action="/apps/kass/rollback"`) ||
- strings.Contains(body, `action="/apps/kass/canary"`) || strings.Contains(body, `action="/apps/kass/canary/retire"`) {
+ if strings.Contains(body, `action="/a/bab/apps/kass/promote"`) || strings.Contains(body, `action="/a/bab/apps/kass/rollback"`) ||
+ strings.Contains(body, `action="/a/bab/apps/kass/canary"`) || strings.Contains(body, `action="/a/bab/apps/kass/canary/retire"`) {
t.Fatal("keyless console rendered mutation forms")
}
- if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/promote",
+ if status, _, _ := postForm(t, c, fx.u("/apps/kass/promote"),
url.Values{"csrf": {csrf}, "version": {"v2"}, "channel": {"stable"}}); status != http.StatusServiceUnavailable {
t.Fatalf("keyless promote = %d, want 503", status)
}
- if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/rollback",
+ if status, _, _ := postForm(t, c, fx.u("/apps/kass/rollback"),
url.Values{"csrf": {csrf}, "channel": {"stable"}}); status != http.StatusServiceUnavailable {
t.Fatalf("keyless rollback = %d, want 503", status)
}
- if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/canary",
+ if status, _, _ := postForm(t, c, fx.u("/apps/kass/canary"),
url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}}); status != http.StatusServiceUnavailable {
t.Fatalf("keyless canary create = %d, want 503", status)
}
- if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/canary/retire",
+ if status, _, _ := postForm(t, c, fx.u("/apps/kass/canary/retire"),
url.Values{"csrf": {csrf}, "name": {"x"}}); status != http.StatusServiceUnavailable {
t.Fatalf("keyless canary retire = %d, want 503", status)
}
@@ -864,65 +880,70 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) {
// ---- domains ----------------------------------------------------------------
-// TestClaimWritesAccountManifest: claiming an app writes the account
-// manifest into the deployment bucket — the console originates account
-// truth, and it travels through the bucket (never a console→registry write).
-func TestClaimWritesAccountManifest(t *testing.T) {
+// TestClaimNeedsNoLinkageManifest: the app→account linkage IS the bucket key
+// path now, so claiming an app writes NO apps/<app>/account.json (that
+// manifest is retired) — and the account's own manifest is what the agent
+// materializes from.
+func TestClaimNeedsNoLinkageManifest(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
- if status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/new", url.Values{"csrf": {csrf}, "name": {"zephyr"}}); status != http.StatusSeeOther || loc != "/apps/zephyr" {
+ if status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"zephyr"}}); status != http.StatusSeeOther || loc != fx.path("/apps/zephyr") {
t.Fatalf("claim = %d -> %q", status, loc)
}
- body, _, err := fx.rel.Get(context.Background(), "apps/zephyr/account.json")
- if err != nil {
- t.Fatalf("claim wrote no account manifest: %v", err)
+ if _, _, err := fx.rel.Get(context.Background(), "apps/zephyr/account.json"); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("claim wrote a retired linkage manifest: %v", err)
}
- var m struct{ Account, Owner string }
- if err := json.Unmarshal(body, &m); err != nil {
- t.Fatalf("manifest not JSON: %v (%q)", err, body)
- }
- if m.Account != "paul@keymail.dev" || m.Owner != "paul@keymail.dev" {
- t.Errorf("manifest = %+v, want the claiming address as account and owner", m)
+ if _, _, err := fx.rel.Get(context.Background(), AccountManifestKey(fx.sqid)); err != nil {
+ t.Errorf("no account manifest in the bucket: %v", err)
}
}
-// TestAppPageBackfillsAccountManifest: an already-claimed app with no
-// manifest (claimed before this feature shipped, or CLI-granted) gets one
-// written on the owner's first page view — and an existing manifest is
-// never overwritten by a view.
+// TestAppPageBackfillsAccountManifest: an account whose manifest never
+// reached the bucket (minted while it was unreachable, or the built-in ops
+// account, which predates the feature) gets one written on a page view — and
+// an existing manifest is never overwritten, because rewriting a different id
+// there would re-home every platform domain the account owns.
func TestAppPageBackfillsAccountManifest(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
+ key := AccountManifestKey(fx.sqid)
- if _, _, err := fx.rel.Get(context.Background(), "apps/kass/account.json"); !errors.Is(err, os.ErrNotExist) {
+ if _, _, err := fx.rel.Get(context.Background(), key); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("fixture already has a manifest: %v", err)
}
- if status, _, _ := get(t, c, fx.ts.URL+"/apps/kass"); status != 200 {
- t.Fatalf("/apps/kass = %d", status)
+ if status, _, _ := get(t, c, fx.u("/apps/kass")); status != 200 {
+ t.Fatalf("app page = %d", status)
}
- body, _, err := fx.rel.Get(context.Background(), "apps/kass/account.json")
+ body, _, err := fx.rel.Get(context.Background(), key)
if err != nil {
t.Fatalf("view did not backfill the manifest: %v", err)
}
- if !strings.Contains(string(body), "paul@keymail.dev") {
- t.Errorf("backfilled manifest = %q", body)
+ var m struct {
+ ID int64 `json:"id"`
+ Sqid string `json:"sqid"`
+ }
+ if err := json.Unmarshal(body, &m); err != nil {
+ t.Fatalf("manifest not JSON: %v (%q)", err, body)
+ }
+ if m.ID != OpsAccountID || m.Sqid != fx.sqid {
+ t.Errorf("backfilled manifest = %+v, want the ops account", m)
}
- // A pre-existing manifest is account truth someone already wrote —
- // a page view must not clobber it.
- custom := []byte(`{"account":"someteam","owner":"paul@keymail.dev"}`)
- if _, err := fx.rel.Put(context.Background(), "apps/zephyr/account.json", custom, ""); err != nil {
- t.Fatal(err)
+ // A pre-existing manifest is identity every box has already materialized.
+ custom := []byte(`{"id":1,"sqid":"bab","name":"renamed by hand"}`)
+ if _, err := fx.rel.Put(context.Background(), key, custom, m.Sqid); err == nil {
+ t.Fatal("expected a CAS-guarded overwrite to need the current etag")
}
- if err := fx.st.Grant("paul@keymail.dev", "zephyr"); err != nil {
+ _, etag, _ := fx.rel.Get(context.Background(), key)
+ if _, err := fx.rel.Put(context.Background(), key, custom, etag); err != nil {
t.Fatal(err)
}
- if status, _, _ := get(t, c, fx.ts.URL+"/apps/zephyr"); status != 200 {
- t.Fatalf("/apps/zephyr = %d", status)
+ if status, _, _ := get(t, c, fx.u("/apps/kass")); status != 200 {
+ t.Fatalf("app page = %d", status)
}
- body, _, err = fx.rel.Get(context.Background(), "apps/zephyr/account.json")
+ body, _, err = fx.rel.Get(context.Background(), key)
if err != nil || string(body) != string(custom) {
t.Errorf("view clobbered an existing manifest: %q %v", body, err)
}
@@ -992,7 +1013,7 @@ func TestAppPageRendersDomains(t *testing.T) {
fx.addRoute(t, registry.Route{Host: "kass.bab.carlosapp.dev", Kind: "instance",
Status: "ready", App: "kass", Platform: true, DNSStatus: "pointing", Account: "paul@keymail.dev"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.ts.URL+"/apps/kass")
+ _, _, body := get(t, c, fx.u("/apps/kass"))
for _, want := range []string{
"Domains", "kass.bab.carlosapp.dev", "platform", "pointing",
"kass.carlosapp.dev", "custom", "—",
diff --git a/internal/console/store.go b/internal/console/store.go
index ab87e7f02..e9cf5a966 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -284,6 +284,37 @@ func (s *Store) Owners() (map[string][]string, error) {
// ---- accounts, memberships, invites -----------------------------------------
+// BackfillOpsMemberships maps the pre-accounts world onto the new one, once:
+// the built-in ops account exists, every address that held ANY app grant is
+// an owner of it, and every granted app name is claimed in it. That is the
+// spec's "every existing grant maps to a membership in the account that owns
+// the app" — before accounts, every app was in ops by definition.
+//
+// Idempotent (INSERT OR IGNORE throughout), so it runs on every console
+// start; a deployment that has already moved on gains nothing and loses
+// nothing. It deliberately does NOT delete the owners table: the grants stay
+// readable while the migration is being verified.
+func (s *Store) BackfillOpsMemberships(now time.Time) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if _, err := tx.Exec(`INSERT OR IGNORE INTO accounts (id, sqid, name, created_at)
+VALUES (?, ?, 'ops', ?)`, OpsAccountID, opsSqid, now.Unix()); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`INSERT OR IGNORE INTO memberships (account_id, address, role, added_at)
+SELECT DISTINCT ?, address, ?, ? FROM owners`, OpsAccountID, RoleOwner, now.Unix()); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`INSERT OR IGNORE INTO apps (app, account_id, created_by, created_at)
+SELECT app, ?, MIN(address), ? FROM owners GROUP BY app`, OpsAccountID, now.Unix()); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
// 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.
@@ -295,6 +326,11 @@ const (
// InviteTTL bounds a pending invite.
const InviteTTL = 7 * 24 * time.Hour
+// opsSqid is the ops account's frozen public identity. Spelled out rather
+// than imported from release so the store layer stays dependency-free; the
+// two are pinned equal by TestMintedSqidIsTheFrozenEncoding.
+const opsSqid = "bab"
+
// 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 {
diff --git a/internal/console/templates/app.html b/internal/console/templates/app.html
index deef1e511..ca0b07a6b 100644
--- a/internal/console/templates/app.html
+++ b/internal/console/templates/app.html
@@ -59,7 +59,7 @@
<td>{{if .BakedUntil.IsZero}}live{{else}}bakes until ~{{fmtTime .BakedUntil}}{{end}}</td>
{{end}}
<td>{{if and $.CanPromote .HasHistory (not .Err)}}
- <form method="post" action="/apps/{{$.Detail.App}}/rollback">
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/rollback">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="channel" value="{{.Name}}">
<button type="submit" class="linkish">Roll back</button>
@@ -89,7 +89,7 @@
<td>{{if .Hosts}}{{range .Hosts}}<a href="https://{{.}}">{{.}}</a> {{end}}{{else}}no host yet{{end}}</td>
{{end}}
<td>{{if $.CanPromote}}
- <form method="post" action="/apps/{{$.Detail.App}}/canary/retire">
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/canary/retire">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="name" value="{{.Name}}">
<button type="submit" class="linkish">Retire</button>
@@ -101,7 +101,7 @@
</div>
{{else}}<p class="small">No canaries — a canary is a fresh channel serving one chosen release immediately, with no bake window.</p>{{end}}
{{if and .CanPromote .Versions}}
-<form class="signin" method="post" action="/apps/{{.Detail.App}}/canary">
+<form class="signin" method="post" action="{{.Base}}/apps/{{.Detail.App}}/canary">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="text" name="name" placeholder="rc-2">
<select name="version">
@@ -127,7 +127,7 @@
<td>{{if .BuiltAt}}{{.BuiltAt}}{{else}}—{{end}}</td>
{{end}}
{{if $.CanPromote}}<td>
- <form method="post" action="/apps/{{$.Detail.App}}/promote">
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="version" value="{{.Version}}">
<select name="channel">
diff --git a/internal/console/templates/apps.html b/internal/console/templates/apps.html
index 9c6cf08e2..2482956dd 100644
--- a/internal/console/templates/apps.html
+++ b/internal/console/templates/apps.html
@@ -3,13 +3,13 @@
{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{if .Apps}}
-<h1>Your apps</h1>
+<h1>{{.Account.Name}}</h1>
<div class="tablewrap">
<table>
<tr><th>App</th><th>Hosts</th><th>Instances</th><th>Status</th><th>Stable</th><th>Canary</th></tr>
{{range .Apps}}
<tr>
- <td><a href="/apps/{{.App}}">{{.App}}</a></td>
+ <td><a href="{{$.Base}}/apps/{{.App}}">{{.App}}</a></td>
<td>{{.Hosts}}</td>
<td>{{.Instances}}</td>
<td>{{if .Statuses}}{{.Statuses}}{{else}}—{{end}}</td>
@@ -21,13 +21,51 @@
</div>
{{else}}
<h1>Welcome to the CARLOS platform</h1>
-<p>Your account <strong>{{.Address}}</strong> is ready. No apps live here yet — claim a name below and it's yours.</p>
+<p>The account <strong>{{.Account.Name}}</strong> is ready. No apps live here yet — claim a name below and it's yours.</p>
{{end}}
<h2>Create an app</h2>
-<form class="signin" method="post" action="/apps/new">
+<form class="signin" method="post" action="{{.Base}}/apps/new">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="text" name="name" placeholder="my-app">
<button type="submit">Create app</button>
</form>
-<p class="small">Names are lowercase letters, digits, and dashes, starting with a letter — claimed once, yours forever.</p>
+<p class="small">Names are lowercase letters, digits, and dashes, starting with a letter — unique within this account, not across the platform.</p>
+
+<h2>Members</h2>
+<div class="tablewrap">
+<table>
+ <tr><th>Address</th><th>Role</th></tr>
+ {{range .Members}}<tr><td>{{.Name}}</td><td>{{.Role}}</td></tr>{{end}}
+</table>
+</div>
+{{if .IsOwner}}
+<h2>Invite a teammate</h2>
+<form class="signin" method="post" action="{{.Base}}/invite">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="text" name="address" placeholder="them@keymail.dev">
+ <button type="submit">Send invitation</button>
+</form>
+<p class="small">The invitation arrives in their Keymail Apps inbox — the platform never emails anyone. It works once and expires in 7 days.</p>
+{{if .Invites}}
+<div class="tablewrap">
+<table>
+ <tr><th>Invited</th><th>By</th><th>Expires</th><th></th></tr>
+ {{range .Invites}}
+ <tr>
+ <td>{{.Address}}</td>
+ <td>{{.InvitedBy}}</td>
+ <td>{{fmtTime .ExpiresAt}}</td>
+ <td>
+ <form method="post" action="{{$.Base}}/invite/revoke">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="token" value="{{.Token}}">
+ <button class="linkish" type="submit">Revoke</button>
+ </form>
+ </td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{end}}
+{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index e468520d2..ec1fc570a 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -51,6 +51,12 @@
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; font-size: 0.9rem;
}
header .addr { color: var(--muted); }
+ header .switcher {
+ display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap;
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; font-size: 0.9rem;
+ }
+ header .switcher form { display: flex; gap: 0.4rem; align-items: baseline; margin: 0; }
+ header .switcher input[type=text] { font-size: 0.8rem; padding: 0.2rem 0.4rem; width: 8rem; }
main { max-width: 56rem; margin: 0 auto; padding: 2.5rem 0 3rem; min-height: 55vh; }
h1 { font-size: 1.9rem; line-height: 1.15; margin: 0 0 1rem; }
h2 { font-size: 1.25rem; margin: 2rem 0 0.75rem; }
@@ -112,8 +118,21 @@
<body>
<header>
<div class="brand"><a href="/">CARLOS console</a></div>
+ {{if .Account}}
+ <div class="switcher">
+ <span class="small">Account</span>
+ <strong>{{.Account.Name}}</strong>
+ <span class="small">·</span>
+ {{range .Accounts}}{{if ne .Sqid $.Account.Sqid}}<a href="/a/{{.Sqid}}/">{{.Name}}</a> {{end}}{{end}}
+ <form method="post" action="/accounts/new">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="text" name="name" placeholder="New account">
+ <button class="linkish" type="submit">New account</button>
+ </form>
+ </div>
+ {{end}}
{{if .Address}}<nav>
- <a href="/apps">Apps</a>
+ {{if .Account}}<a href="{{.Base}}/">Apps</a>{{end}}
<span class="addr">{{.Address}}</span>
<form method="post" action="/auth/signout">
<input type="hidden" name="csrf" value="{{.CSRF}}">