console: a storage application reaches the people who can decide it
Stage 7 of #381, and the half §15 called not optional. An account applied on 2026-08-29 and waited four days, not because anyone refused but because nothing told anyone. A queue nobody is watching is the bug. The notification goes to the INTERSECTION of the fleet-operator bit and CARLOS_STORE_OPERATORS, which is tighter than the alertRecipients mechanism §15 pointed at. The audience for "someone is waiting on you" should be exactly the set of people who can act, not the wider set who can see the bill. Neither mail carries the operator's half of the record. The application mail says an account applied and links to the queue; the decision mail says approved or declined and links to account settings. The applicant's note and the decline reason stay behind a login, which keeps the 2026-09-08 ruling intact in a channel that ruling never explicitly covered — mail is the one surface where a privacy decision leaks by being forwarded. The console UI follows the same line: Applied.By appears in the operator's approvals view, and nowhere a tenant can reach. StoreProvisioned lands on the daily economics pass because usage.AppStorage could not tell "provisioned, empty" from "no store" — StoreBytes is omitempty, so zero and absent were the same byte. The approvals view shows the count so an operator sees the estate before adding to it. Also corrects §11 of the spec, which was written against AWS's old ~100 bucket soft limit. The default is now 10,000 and raisable well past it, so the count is an inventory figure rather than a hazard light. Surfacing it still earns its place; the alarm it was written in does not. Built by codex (gpt-6-astra). The copy has NOT been through review yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AuiZoEUxEooEfFBAs8XPLB
15 files changed,
+947
−34
docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md+11 −3internal/console/alertwatch.go+13 −0internal/console/apistore_entitlement.go+46 −27internal/console/economicspass.go+1 −0internal/console/economicspass_test.go+40 −0internal/console/server.go+22 −0internal/console/storenotify.go+96 −0internal/console/storenotify_test.go+170 −0internal/console/storepage.go+247 −0internal/console/storepage_test.go+215 −0internal/console/templates/account_settings.html+24 −0internal/console/templates/fleet.html+1 −0internal/console/templates/store_approvals.html+27 −0internal/usage/passes_test.go+28 −0internal/usage/storage.go+6 −4
diff --git a/docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md b/docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md| index 7aa1055e5..a81c16a2e 100644 |
| --- a/docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md |
| +++ b/docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md |
| @@ -441,9 +441,17 @@ follows the app. All of this is accepted and stated rather than fixed. |
| ## 11. The risk this accepts, deliberately |
| -"Unlimited, metered" means **AWS's soft limit of ~100 buckets per account |
| -becomes the deployment's real ceiling**, hit at provision time as a raw AWS |
| -error in front of whichever tenant declares next. |
| +"Unlimited, metered" means **AWS's per-account bucket limit becomes the |
| +deployment's real ceiling**, hit at provision time as a raw AWS error in front |
| +of whichever tenant declares next. |
| + |
| +**(correction, 2026-09-09)** This section was written against a limit of ~100. |
| +AWS's default is now **10,000** buckets per account, raisable far beyond that |
| +on request, so the ceiling is nowhere near as close as the paragraph below |
| +assumes. The surfacing is still worth having — an operator approving an |
| +account should see the size of the estate they are adding to — but it is now |
| +an inventory figure rather than a hazard light, and nothing here should be |
| +read as urgent. |
| **(decision) Do not gate on it. Surface it.** The approvals view shows how many |
| buckets the deployment has minted, so an operator can see the number before |
diff --git a/internal/console/alertwatch.go b/internal/console/alertwatch.go| index e3352610e..2a2f8fde5 100644 |
| --- a/internal/console/alertwatch.go |
| +++ b/internal/console/alertwatch.go |
| @@ -153,8 +153,21 @@ func (s *Server) alertRecipients() (*alertRun, error) { |
| if err != nil { |
| return nil, fmt.Errorf("operators: %w", err) |
| } |
| + return s.grantRecipients(addrs) |
| +} |
| + |
| +// grantRecipients resolves canonical addresses to their own Keymail grants. |
| +// Alert delivery and one-shot console notifications share this lookup so both |
| +// keep the same missing-grant and per-recipient-token behaviour. |
| +func (s *Server) grantRecipients(addrs []string) (*alertRun, error) { |
| run := &alertRun{} |
| + seen := make(map[string]bool) |
| for _, addr := range addrs { |
| + addr = strings.ToLower(strings.TrimSpace(addr)) |
| + if addr == "" || seen[addr] { |
| + continue |
| + } |
| + seen[addr] = true |
| tok, err := s.store.GrantToken(addr) |
| if err != nil { |
| return nil, fmt.Errorf("grant token for %s: %w", addr, err) |
diff --git a/internal/console/apistore_entitlement.go b/internal/console/apistore_entitlement.go| index dc40d6d8b..f1dc467f9 100644 |
| --- a/internal/console/apistore_entitlement.go |
| +++ b/internal/console/apistore_entitlement.go |
| @@ -58,28 +58,35 @@ func (s *Server) handleAPIStoreApply(w http.ResponseWriter, r *http.Request) { |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| defer cancel() |
| + e, status, msg := s.applyStoreEntitlement(ctx, acct, p.Address, req.Note) |
| + if msg != "" { |
| + jsonError(w, status, msg) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, storeEntitlementReply{Account: acct.Sqid, Entitlement: tenantEntitlement(e, ctx)}) |
| +} |
| + |
| +// Browser and CLI mutations share the CAS, audit and notification path. |
| +func (s *Server) applyStoreEntitlement(ctx context.Context, acct *Account, addr, note string) (*objstore.Entitlement, int, string) { |
| e, etag, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid) |
| if err != nil { |
| log.Printf("console: api store apply %s: read entitlement: %v", acct.Sqid, err) |
| - jsonError(w, http.StatusBadGateway, "could not read the storage application. try again") |
| - return |
| + return nil, http.StatusBadGateway, "could not read the storage application. try again" |
| } |
| if e != nil && e.Approved() { |
| - writeJSON(w, http.StatusOK, storeEntitlementReply{Account: acct.Sqid, Entitlement: tenantEntitlement(e, ctx)}) |
| - return |
| + return e, http.StatusOK, "" |
| } |
| - e = &objstore.Entitlement{Applied: &objstore.Applied{By: p.Address, At: time.Now(), Note: strings.TrimSpace(req.Note)}} |
| + e = &objstore.Entitlement{Applied: &objstore.Applied{By: addr, At: time.Now(), Note: strings.TrimSpace(note)}} |
| if _, err := objstore.PutEntitlement(ctx, s.cfg.Releases, acct.Sqid, e, etag); err != nil { |
| if errors.Is(err, release.ErrCASConflict) { |
| - jsonError(w, http.StatusConflict, "the storage application changed. try again") |
| - return |
| + return nil, http.StatusConflict, "the storage application changed. try again" |
| } |
| log.Printf("console: api store apply %s: write entitlement: %v", acct.Sqid, err) |
| - jsonError(w, http.StatusBadGateway, "could not save the storage application. try again") |
| - return |
| + return nil, http.StatusBadGateway, "could not save the storage application. try again" |
| } |
| - s.audit(acct, "", p.Address, "store-apply", e.Applied.Note) |
| - writeJSON(w, http.StatusOK, storeEntitlementReply{Account: acct.Sqid, Entitlement: tenantEntitlement(e, ctx)}) |
| + s.audit(acct, "", addr, "store-apply", e.Applied.Note) |
| + go s.notifyStoreEntitlement(acct.Sqid, e) |
| + return e, http.StatusOK, "" |
| } |
| func (s *Server) storeEntitlementOperator(w http.ResponseWriter, r *http.Request) (string, bool) { |
| @@ -152,15 +159,22 @@ func (s *Server) handleAPIStoreDecision(w http.ResponseWriter, r *http.Request, |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| defer cancel() |
| + e, status, msg := s.decideStoreEntitlement(ctx, acct, addr, approved, reason) |
| + if msg != "" { |
| + jsonError(w, status, msg) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, storeOperatorEntitlementReply{Account: acct.Sqid, Entitlement: e}) |
| +} |
| + |
| +func (s *Server) decideStoreEntitlement(ctx context.Context, acct *Account, addr string, approved bool, reason string) (*objstore.Entitlement, int, string) { |
| e, etag, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid) |
| if err != nil { |
| log.Printf("console: api store decision %s: read entitlement: %v", acct.Sqid, err) |
| - jsonError(w, http.StatusBadGateway, "could not read the storage application. try again") |
| - return |
| + return nil, http.StatusBadGateway, "could not read the storage application. try again" |
| } |
| if e == nil || e.Applied == nil { |
| - jsonError(w, http.StatusConflict, "this account has not applied for object storage") |
| - return |
| + return nil, http.StatusConflict, "this account has not applied for object storage" |
| } |
| e.Decision = &objstore.Decision{Approved: approved, By: addr, At: time.Now(), Reason: reason} |
| if approved { |
| @@ -168,12 +182,10 @@ func (s *Server) handleAPIStoreDecision(w http.ResponseWriter, r *http.Request, |
| } |
| if _, err := objstore.PutEntitlement(ctx, s.cfg.Releases, acct.Sqid, e, etag); err != nil { |
| if errors.Is(err, release.ErrCASConflict) { |
| - jsonError(w, http.StatusConflict, "the storage application changed. try again") |
| - return |
| + return nil, http.StatusConflict, "the storage application changed. try again" |
| } |
| log.Printf("console: api store decision %s: write entitlement: %v", acct.Sqid, err) |
| - jsonError(w, http.StatusBadGateway, "could not save the storage decision. try again") |
| - return |
| + return nil, http.StatusBadGateway, "could not save the storage decision. try again" |
| } |
| detail := reason |
| action := "store-decline" |
| @@ -182,7 +194,8 @@ func (s *Server) handleAPIStoreDecision(w http.ResponseWriter, r *http.Request, |
| action = "store-approve" |
| } |
| s.audit(acct, "", addr, action, detail) |
| - writeJSON(w, http.StatusOK, storeOperatorEntitlementReply{Account: acct.Sqid, Entitlement: e}) |
| + go s.notifyStoreEntitlement(acct.Sqid, e) |
| + return e, http.StatusOK, "" |
| } |
| func (s *Server) handleAPIStoreApprovals(w http.ResponseWriter, r *http.Request) { |
| @@ -195,12 +208,20 @@ func (s *Server) handleAPIStoreApprovals(w http.ResponseWriter, r *http.Request) |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| defer cancel() |
| - keys, err := s.cfg.Releases.List(ctx, "store/entitlements/") |
| + applications, err := s.pendingStoreApplications(ctx) |
| if err != nil { |
| - log.Printf("console: api store approvals: list entitlements: %v", err) |
| jsonError(w, http.StatusBadGateway, "could not list storage applications. try again") |
| return |
| } |
| + writeJSON(w, http.StatusOK, storeApprovalsReply{Applications: applications}) |
| +} |
| + |
| +func (s *Server) pendingStoreApplications(ctx context.Context) ([]*objstore.Entitlement, error) { |
| + keys, err := s.cfg.Releases.List(ctx, "store/entitlements/") |
| + if err != nil { |
| + log.Printf("console: api store approvals: list entitlements: %v", err) |
| + return nil, err |
| + } |
| applications := make([]*objstore.Entitlement, 0) |
| for _, key := range keys { |
| const prefix, suffix = "store/entitlements/", ".json" |
| @@ -214,8 +235,7 @@ func (s *Server) handleAPIStoreApprovals(w http.ResponseWriter, r *http.Request) |
| acct, err := s.store.AccountBySqid(acctID) |
| if err != nil { |
| log.Printf("console: api store approvals: account lookup %s: %v", acctID, err) |
| - jsonError(w, http.StatusBadGateway, "could not list storage applications. try again") |
| - return |
| + return nil, err |
| } |
| if acct == nil { |
| continue |
| @@ -223,8 +243,7 @@ func (s *Server) handleAPIStoreApprovals(w http.ResponseWriter, r *http.Request) |
| e, _, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acctID) |
| if err != nil { |
| log.Printf("console: api store approvals: read %s: %v", key, err) |
| - jsonError(w, http.StatusBadGateway, "could not list storage applications. try again") |
| - return |
| + return nil, err |
| } |
| if e != nil && e.Pending() { |
| e.Account = acctID |
| @@ -237,5 +256,5 @@ func (s *Server) handleAPIStoreApprovals(w http.ResponseWriter, r *http.Request) |
| } |
| return applications[i].Applied.At.Before(applications[j].Applied.At) |
| }) |
| - writeJSON(w, http.StatusOK, storeApprovalsReply{Applications: applications}) |
| + return applications, nil |
| } |
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go| index 08df9644b..7bba86be5 100644 |
| --- a/internal/console/economicspass.go |
| +++ b/internal/console/economicspass.go |
| @@ -179,6 +179,7 @@ func (s *Server) storagePass(ctx context.Context, date string) error { |
| log.Printf("console: storage pass: store record %s/%s: %v", acct, app, gerr) |
| st.StoreUnswept = true |
| } else if rec != nil && rec.Provisioned != nil { |
| + st.StoreProvisioned = true |
| n, serr := s.sizeStore(ctx, rec.Provisioned) |
| if serr != nil { |
| log.Printf("console: storage pass: store %s/%s: %v", acct, app, serr) |
diff --git a/internal/console/economicspass_test.go b/internal/console/economicspass_test.go| index 214690748..4a6819b99 100644 |
| --- a/internal/console/economicspass_test.go |
| +++ b/internal/console/economicspass_test.go |
| @@ -187,6 +187,9 @@ func TestStoragePassSizesAppsAndDegradesStores(t *testing.T) { |
| if app.StoreUnswept { |
| t.Fatal("app with no provisioned store came back StoreUnswept") |
| } |
| + if app.StoreProvisioned { |
| + t.Fatal("app with no provisioned store came back StoreProvisioned") |
| + } |
| // Idempotence: a second run the same day must not error or rewrite. |
| before := string(raw) |
| @@ -199,6 +202,40 @@ func TestStoragePassSizesAppsAndDegradesStores(t *testing.T) { |
| } |
| } |
| +func TestStoragePassMarksEmptyStoreProvisioned(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + |
| + rec := &objstore.Record{ |
| + Provisioned: &objstore.Provisioned{ |
| + Bucket: "test-empty-tenant-bucket", |
| + Region: "us-east-1", |
| + }, |
| + } |
| + if _, err := objstore.Put(ctx, fx.rel, fixtureAcct, fixtureApp, rec, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.storeSizer = func(context.Context, *objstore.Provisioned) (int64, error) { |
| + return 0, nil |
| + } |
| + |
| + if err := fx.srv.storagePass(ctx, "2026-08-12"); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := fx.rel.Get(ctx, usage.StorageDayKey("2026-08-12")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var day usage.StorageDay |
| + if err := json.Unmarshal(raw, &day); err != nil { |
| + t.Fatal(err) |
| + } |
| + app := day.Apps[fixtureAcct+"/"+fixtureApp] |
| + if !app.StoreProvisioned || app.StoreBytes != 0 || app.StoreUnswept { |
| + t.Fatalf("empty provisioned store = %+v", app) |
| + } |
| +} |
| + |
| // TestStoragePassMarksUnreachableStoreUnswept: buildFixtures seeds no |
| // provisioned object store, so this test writes a minimal objstore.Record |
| // with a Provisioned block directly and confirms a sizer failure marks the |
| @@ -236,6 +273,9 @@ func TestStoragePassMarksUnreachableStoreUnswept(t *testing.T) { |
| if !app.StoreUnswept { |
| t.Fatalf("app with unreachable store = %+v, want StoreUnswept", app) |
| } |
| + if !app.StoreProvisioned { |
| + t.Fatalf("app with unreachable provisioned store = %+v, want StoreProvisioned", app) |
| + } |
| if app.StoreBytes != 0 { |
| t.Fatalf("app.StoreBytes = %d, want 0 when unswept", app.StoreBytes) |
| } |
diff --git a/internal/console/server.go b/internal/console/server.go| index 0214895fa..6c9d497f9 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -939,6 +939,9 @@ 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 /fleet/store", s.handleStoreApprovalsPage) |
| + s.mux.HandleFunc("POST /fleet/store/{account}/{decision}", s.handleStoreDecisionPage) |
| + s.mux.HandleFunc("POST /a/{sqid}/store/apply", s.handleStoreApplyPage) |
| s.mux.HandleFunc("GET /fleet/metrics", s.handleFleetMetrics) |
| s.mux.HandleFunc("GET /fleet/economics", s.handleEconomics) |
| // The deployment's map (archpage.go), same operatorOnly gate. BOTH are |
| @@ -1157,6 +1160,11 @@ func normalizeHost(host string) string { |
| // page is the one bag every template reads from; unused fields are ignored. |
| type page struct { |
| + AccountStore *accountStoreView |
| + StoreQueue *storeQueueView |
| + StorePending *int |
| + StoreQueueUnknown bool |
| + |
| Title string |
| Address string // signed-in address, "" when signed out |
| CSRF string // the session token, double-submitted on the signout POST |
| @@ -2121,6 +2129,19 @@ func (s *Server) handleFleet(w http.ResponseWriter, r *http.Request) { |
| hosts = nil |
| } |
| p := page{Title: "Fleet status", Address: addr, CSRF: raw, Fleet: f, Hosts: hosts} |
| + if s.storeOperator(addr) { |
| + p.StoreQueueUnknown = true |
| + if s.cfg.Releases != nil { |
| + ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| + applications, err := s.pendingStoreApplications(ctx) |
| + cancel() |
| + if err == nil { |
| + n := len(applications) |
| + p.StorePending = &n |
| + p.StoreQueueUnknown = false |
| + } |
| + } |
| + } |
| s.render(w, http.StatusOK, "fleet", p) |
| } |
| @@ -2295,6 +2316,7 @@ func (s *Server) handleAccountSettings(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| p.HasApps = n > 0 |
| + p.AccountStore = s.accountStorePane(r.Context(), acct) |
| // The service-credential pane (svcpage.go), owners only — see page's |
| // Services field for why a member gets no section rather than an empty |
| // one. |
diff --git a/internal/console/storenotify.go b/internal/console/storenotify.go| new file mode 100644 |
| index 000000000..0cc09515c |
| --- /dev/null |
| +++ b/internal/console/storenotify.go |
| @@ -0,0 +1,96 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "log" |
| + "net/url" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/objstore" |
| +) |
| + |
| +// notifyStoreEntitlement sends the best-effort nudge attached to a durable |
| +// entitlement write. Callers launch it after PutEntitlement succeeds, off the |
| +// request goroutine. A notification failure never changes the stored result. |
| +func (s *Server) notifyStoreEntitlement(account string, e *objstore.Entitlement) { |
| + if s.sendApp == nil || e == nil || e.Applied == nil { |
| + return |
| + } |
| + if e.Decision == nil { |
| + s.notifyStoreApplied(account) |
| + return |
| + } |
| + s.notifyStoreDecided(account, e) |
| +} |
| + |
| +func (s *Server) notifyStoreApplied(account string) { |
| + run, err := s.storeNotificationRecipients() |
| + if err != nil { |
| + log.Printf("console: store application %s: recipients: %v", account, err) |
| + return |
| + } |
| + if len(run.missing) != 0 { |
| + log.Printf("console: store application %s: no keymail grant for %s — not notified (they must sign in to the console once)", |
| + account, strings.Join(run.missing, ", ")) |
| + } |
| + subject := fmt.Sprintf("[carlos] Object storage application for %s", account) |
| + body := fmt.Sprintf("Account %s applied for object storage.\n\nReview storage applications:\n%s/fleet/store\n", account, s.cfg.Origin) |
| + for _, op := range run.to { |
| + s.sendStoreNotification(op, subject, body, "application "+account) |
| + } |
| +} |
| + |
| +// storeNotificationRecipients is the intersection of the configured storage |
| +// operators and the fleet-operator role. The configured list narrows the role; |
| +// it never grants authority by itself. |
| +func (s *Server) storeNotificationRecipients() (*alertRun, error) { |
| + operators, err := s.store.Operators() |
| + if err != nil { |
| + return nil, fmt.Errorf("operators: %w", err) |
| + } |
| + fleet := make(map[string]bool, len(operators)) |
| + for _, address := range operators { |
| + fleet[strings.ToLower(strings.TrimSpace(address))] = true |
| + } |
| + configured := make([]string, 0, len(s.cfg.StoreOperators)) |
| + for _, address := range s.cfg.StoreOperators { |
| + address = strings.ToLower(strings.TrimSpace(address)) |
| + if fleet[address] { |
| + configured = append(configured, address) |
| + } |
| + } |
| + return s.grantRecipients(configured) |
| +} |
| + |
| +func (s *Server) notifyStoreDecided(account string, e *objstore.Entitlement) { |
| + address := strings.ToLower(strings.TrimSpace(e.Applied.By)) |
| + run, err := s.grantRecipients([]string{address}) |
| + if err != nil { |
| + log.Printf("console: store decision %s: recipient: %v", account, err) |
| + return |
| + } |
| + if len(run.missing) != 0 { |
| + log.Printf("console: store decision %s: no keymail grant for %s — not notified (they must sign in to the console once)", account, address) |
| + return |
| + } |
| + state := "declined" |
| + if e.Decision.Approved { |
| + state = "approved" |
| + } |
| + subject := fmt.Sprintf("[carlos] Object storage application %s", state) |
| + body := fmt.Sprintf("Object storage for account %s was %s.\n\nAccount settings:\n%s/a/%s/settings\n", |
| + account, state, s.cfg.Origin, url.PathEscape(account)) |
| + for _, recipient := range run.to { |
| + s.sendStoreNotification(recipient, subject, body, "decision "+account) |
| + } |
| +} |
| + |
| +func (s *Server) sendStoreNotification(recipient operatorGrant, subject, body, event string) { |
| + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) |
| + defer cancel() |
| + if _, err := s.sendApp(ctx, recipient.token, recipient.address, subject, body); err != nil { |
| + log.Printf("console: store %s: keymail to %s: %v", event, recipient.address, err) |
| + } |
| +} |
diff --git a/internal/console/storenotify_test.go b/internal/console/storenotify_test.go| new file mode 100644 |
| index 000000000..7f2bde1b4 |
| --- /dev/null |
| +++ b/internal/console/storenotify_test.go |
| @@ -0,0 +1,170 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/objstore" |
| +) |
| + |
| +func storeNotifyFixture(t *testing.T) *alertFixture { |
| + t.Helper() |
| + return newCapturedFixture(t) |
| +} |
| + |
| +func TestStoreApplicationNotifiesCanonicalConfiguredFleetOperators(t *testing.T) { |
| + fx := storeNotifyFixture(t) |
| + fx.srv.cfg.StoreOperators = []string{" OPS@Keymail.dev ", "ops@keymail.dev", "listed-only@keymail.dev"} |
| + for _, address := range []string{"ops@keymail.dev", "fleet-only@keymail.dev"} { |
| + if err := fx.st.SetOperator(address, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.SetGrantToken(address, "grant-"+address, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + fx.srv.notifyStoreEntitlement("bab", &objstore.Entitlement{Applied: &objstore.Applied{By: "owner@example.test"}}) |
| + |
| + if len(fx.sent) != 1 { |
| + t.Fatalf("sent %d messages, want one canonical, deduplicated intersection: %+v", len(fx.sent), fx.sent) |
| + } |
| + if got := fx.sent[0]; got.to != "ops@keymail.dev" || got.token != "grant-ops@keymail.dev" { |
| + t.Fatalf("sent to %q with %q, want the configured fleet operator's own token", got.to, got.token) |
| + } |
| +} |
| + |
| +func TestStoreApplicationSkipsAnOperatorWithoutAGrant(t *testing.T) { |
| + fx := storeNotifyFixture(t) |
| + fx.srv.cfg.StoreOperators = []string{"ready@keymail.dev", "missing@keymail.dev"} |
| + for _, address := range fx.srv.cfg.StoreOperators { |
| + if err := fx.st.SetOperator(address, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := fx.st.SetGrantToken("ready@keymail.dev", "ready-token", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + fx.srv.notifyStoreEntitlement("bab", &objstore.Entitlement{Applied: &objstore.Applied{By: "owner@example.test"}}) |
| + if len(fx.sent) != 1 || fx.sent[0].to != "ready@keymail.dev" { |
| + t.Fatalf("sent %+v, want only the operator with a grant", fx.sent) |
| + } |
| +} |
| + |
| +func TestStoreDecisionTellsApplicantWithoutLeakingOperatorFields(t *testing.T) { |
| + fx := storeNotifyFixture(t) |
| + if err := fx.st.SetGrantToken("owner@example.test", "owner-token", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + e := &objstore.Entitlement{ |
| + Applied: &objstore.Applied{By: "owner@example.test", Note: "secret applicant note"}, |
| + Decision: &objstore.Decision{Approved: false, By: "decider@example.test", Reason: "secret decline reason"}, |
| + } |
| + |
| + fx.srv.notifyStoreEntitlement("bab", e) |
| + if len(fx.sent) != 1 { |
| + t.Fatalf("sent %d messages, want one: %+v", len(fx.sent), fx.sent) |
| + } |
| + got := fx.sent[0] |
| + if got.to != "owner@example.test" || got.token != "owner-token" { |
| + t.Fatalf("sent to %q with %q, want the applicant's own grant", got.to, got.token) |
| + } |
| + for _, secret := range []string{"secret applicant note", "secret decline reason", "decider@example.test", "owner-token"} { |
| + if strings.Contains(got.subject+got.body, secret) { |
| + t.Fatalf("tenant mail leaked %q: %q %q", secret, got.subject, got.body) |
| + } |
| + } |
| + for _, want := range []string{"declined", "bab", fx.srv.cfg.Origin + "/a/bab/settings"} { |
| + if !strings.Contains(got.subject+got.body, want) { |
| + t.Fatalf("tenant mail is missing %q: %q %q", want, got.subject, got.body) |
| + } |
| + } |
| +} |
| + |
| +func TestStoreNotificationIsBestEffortAndNilSafe(t *testing.T) { |
| + fx := storeNotifyFixture(t) |
| + fx.srv.cfg.StoreOperators = []string{"ops@keymail.dev"} |
| + if err := fx.st.SetOperator("ops@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.SetGrantToken("ops@keymail.dev", "ops-token", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.err = errors.New("send failed") |
| + |
| + fx.srv.notifyStoreEntitlement("bab", &objstore.Entitlement{Applied: &objstore.Applied{By: "owner@example.test"}}) |
| + if len(fx.sent) != 1 { |
| + t.Fatalf("send failure attempted %d messages, want one", len(fx.sent)) |
| + } |
| + fx.srv.sendApp = nil |
| + fx.srv.notifyStoreEntitlement("bab", &objstore.Entitlement{Applied: &objstore.Applied{By: "owner@example.test"}}) |
| +} |
| + |
| +func TestStoreNotificationUsesABackgroundTimeout(t *testing.T) { |
| + fx := storeNotifyFixture(t) |
| + fx.srv.cfg.StoreOperators = []string{"ops@keymail.dev"} |
| + if err := fx.st.SetOperator("ops@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.SetGrantToken("ops@keymail.dev", "ops-token", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.sendApp = func(ctx context.Context, _, _, _, _ string) (string, error) { |
| + deadline, ok := ctx.Deadline() |
| + if !ok || time.Until(deadline) > 15*time.Second || time.Until(deadline) < 14*time.Second { |
| + t.Errorf("send context deadline = %v, want about 15 seconds", deadline) |
| + } |
| + return "message", nil |
| + } |
| + |
| + fx.srv.notifyStoreEntitlement("bab", &objstore.Entitlement{Applied: &objstore.Applied{By: "owner@example.test"}}) |
| +} |
| + |
| +func TestStoreWritesFinishWhileNotificationWaits(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := entitlementOperatorToken(t, fx, "paul@keymail.dev", "notify-async") |
| + if err := fx.st.SetGrantToken("paul@keymail.dev", "notification-grant", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + entered := make(chan struct{}, 2) |
| + releaseSend := make(chan struct{}) |
| + finished := make(chan struct{}, 2) |
| + fx.srv.sendApp = func(ctx context.Context, _, _, _, _ string) (string, error) { |
| + entered <- struct{}{} |
| + select { |
| + case <-releaseSend: |
| + case <-ctx.Done(): |
| + } |
| + finished <- struct{}{} |
| + return "", errors.New("delivery failed") |
| + } |
| + defer close(releaseSend) |
| + status, _ := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "uploads"}, token) |
| + if status != 200 || !storedEntitlement(t, fx, fx.sqid).Pending() { |
| + t.Fatalf("apply=%d", status) |
| + } |
| + select { |
| + case <-entered: |
| + case <-time.After(time.Second): |
| + t.Fatal("apply notification never started") |
| + } |
| + status, _ = postJSON(t, entitlementDecisionURL(fx, fx.sqid, "decline"), map[string]any{"reason": "private reason"}, token) |
| + if status != 200 || storedEntitlement(t, fx, fx.sqid).Decision == nil { |
| + t.Fatalf("decision=%d", status) |
| + } |
| + select { |
| + case <-entered: |
| + case <-time.After(time.Second): |
| + t.Fatal("decision notification never started") |
| + } |
| + // Both writes returned while their sends were blocked. Release the callbacks |
| + // before the fixture closes its database and listener. |
| + releaseSend <- struct{}{} |
| + releaseSend <- struct{}{} |
| + <-finished |
| + <-finished |
| +} |
diff --git a/internal/console/storepage.go b/internal/console/storepage.go| new file mode 100644 |
| index 000000000..c4152fb40 |
| --- /dev/null |
| +++ b/internal/console/storepage.go |
| @@ -0,0 +1,247 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "crypto/subtle" |
| + "encoding/json" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "sort" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +// Only state crosses into account settings. In particular, audit detail is |
| +// not safe here: apply stores the note and decline stores the reason. |
| +type accountStoreView struct { |
| + State string |
| + CanApply bool |
| + Unavailable bool |
| + Events []Event |
| +} |
| + |
| +type storeQueueView struct { |
| + Applications []*objstore.Entitlement |
| + Buckets int |
| + SweepDate string |
| + CountKnown bool |
| +} |
| + |
| +func (s *Server) accountStorePane(ctx context.Context, acct *Account) *accountStoreView { |
| + v := &accountStoreView{State: "Not applied", CanApply: true} |
| + if s.cfg.Releases == nil { |
| + v.Unavailable = true |
| + v.CanApply = false |
| + return v |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, storeTimeout) |
| + defer cancel() |
| + e, _, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid) |
| + if err != nil { |
| + log.Printf("console: storage settings %s: %v", acct.Sqid, err) |
| + v.Unavailable = true |
| + v.CanApply = false |
| + } else if e != nil { |
| + switch { |
| + case e.Approved(): |
| + v.State = "Approved" |
| + v.CanApply = false |
| + case e.Pending(): |
| + v.State = "Pending" |
| + v.CanApply = false |
| + case e.Decision != nil: |
| + v.State = "Declined" |
| + } |
| + } |
| + events, err := s.store.Events(acct.ID, "", activityCap) |
| + if err != nil { |
| + log.Printf("console: account storage events %s: %v", acct.Sqid, err) |
| + } |
| + for _, e := range events { |
| + switch e.Action { |
| + case "store-apply", "store-approve", "store-decline": |
| + // The deciding operator's identity also stays out of tenant pages. |
| + detail := map[string]string{"store-apply": "Applied for object storage", "store-approve": "Object storage approved", "store-decline": "Object storage declined"}[e.Action] |
| + v.Events = append(v.Events, Event{Action: e.Action, Detail: detail, CreatedAt: e.CreatedAt}) |
| + } |
| + } |
| + return v |
| +} |
| + |
| +func (s *Server) storeFormCSRF(w http.ResponseWriter, r *http.Request, raw string) bool { |
| + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) |
| + if err := r.ParseForm(); err != nil { |
| + s.renderMessage(w, r, http.StatusBadRequest, "Refused", "Could not read the form. Please try again.") |
| + return false |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +func (s *Server) handleStoreApplyPage(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, ok := s.session(r) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !s.storeFormCSRF(w, r, raw) { |
| + return |
| + } |
| + _, _, acct, ok := s.owner(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.allowWrite(Principal{Address: addr}, time.Now()) { |
| + s.renderMessage(w, r, http.StatusTooManyRequests, "Refused", "too many requests — wait a minute and try again") |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + s.renderMessage(w, r, http.StatusServiceUnavailable, "Error", "Object storage applications are unavailable on this deployment.") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| + defer cancel() |
| + _, status, msg := s.applyStoreEntitlement(ctx, acct, addr, r.FormValue("note")) |
| + if msg != "" { |
| + s.renderMessage(w, r, status, "Error", msg) |
| + return |
| + } |
| + http.Redirect(w, r, accountSettingsURL(acct.Sqid), http.StatusSeeOther) |
| +} |
| + |
| +func (s *Server) storePageOperator(w http.ResponseWriter, r *http.Request) (string, string, bool) { |
| + addr, raw, ok := s.operatorOnly(w, r) |
| + if !ok { |
| + return "", "", false |
| + } |
| + if !s.storeOperator(addr) { |
| + http.NotFound(w, r) |
| + return "", "", false |
| + } |
| + return addr, raw, true |
| +} |
| + |
| +func (s *Server) handleStoreApprovalsPage(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, ok := s.storePageOperator(w, r) |
| + if !ok { |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + s.renderMessage(w, r, http.StatusServiceUnavailable, "Error", "Object storage applications are unavailable on this deployment.") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| + defer cancel() |
| + applications, err := s.pendingStoreApplications(ctx) |
| + if err != nil { |
| + s.renderMessage(w, r, http.StatusBadGateway, "Error", "could not list storage applications. try again") |
| + return |
| + } |
| + v := &storeQueueView{Applications: applications} |
| + v.Buckets, v.SweepDate, v.CountKnown, err = s.storeBucketCount(ctx) |
| + if err != nil { |
| + log.Printf("console: storage bucket count: %v", err) |
| + } |
| + s.render(w, http.StatusOK, "store_approvals", page{Title: "Object storage applications", Address: addr, CSRF: raw, StoreQueue: v}) |
| +} |
| + |
| +func (s *Server) handleStoreDecisionPage(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, ok := s.storePageOperator(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.storeFormCSRF(w, r, raw) { |
| + return |
| + } |
| + acct, ok := s.storeEntitlementTarget(w, r) |
| + if !ok { |
| + return |
| + } |
| + approved := r.PathValue("decision") == "approve" |
| + if !approved && r.PathValue("decision") != "decline" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + reason := strings.TrimSpace(r.FormValue("reason")) |
| + if !approved && reason == "" { |
| + s.renderMessage(w, r, http.StatusBadRequest, "Refused", "a reason is required to decline storage") |
| + return |
| + } |
| + // Approval grants future cloud spend; prove presence after every authority |
| + // check. Decline only removes that authority and needs no step-up. |
| + if approved && !s.requireFreshAuth(w, r, addr, raw) { |
| + return |
| + } |
| + if !s.allowWrite(Principal{Address: addr}, time.Now()) { |
| + s.renderMessage(w, r, http.StatusTooManyRequests, "Refused", "too many requests — wait a minute and try again") |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + s.renderMessage(w, r, http.StatusServiceUnavailable, "Error", "Object storage applications are unavailable on this deployment.") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| + defer cancel() |
| + _, status, msg := s.decideStoreEntitlement(ctx, acct, addr, approved, reason) |
| + if msg != "" { |
| + s.renderMessage(w, r, status, "Error", msg) |
| + return |
| + } |
| + http.Redirect(w, r, "/fleet/store", http.StatusSeeOther) |
| +} |
| + |
| +// A dated occupancy snapshot is not a live cloud inventory. Older snapshots |
| +// lack the boolean, so they cannot establish even an empty-store count. |
| +func (s *Server) storeBucketCount(ctx context.Context) (int, string, bool, error) { |
| + keys, err := s.cfg.Releases.List(ctx, usage.StoragePrefix) |
| + if err != nil { |
| + return 0, "", false, err |
| + } |
| + var dates []string |
| + for _, key := range keys { |
| + date := strings.TrimSuffix(strings.TrimPrefix(key, usage.StoragePrefix), ".json") |
| + if _, err := time.Parse("2006-01-02", date); err == nil && usage.StorageDayKey(date) == key { |
| + dates = append(dates, date) |
| + } |
| + } |
| + if len(dates) == 0 { |
| + return 0, "", false, nil |
| + } |
| + sort.Strings(dates) |
| + date := dates[len(dates)-1] |
| + raw, _, err := s.cfg.Releases.Get(ctx, usage.StorageDayKey(date)) |
| + if err != nil { |
| + return 0, date, false, err |
| + } |
| + var day struct { |
| + V int `json:"v"` |
| + Date string `json:"date"` |
| + Apps map[string]struct { |
| + Provisioned *bool `json:"store_provisioned"` |
| + Unswept bool `json:"store_unswept"` |
| + } `json:"apps"` |
| + } |
| + if err := json.Unmarshal(raw, &day); err != nil { |
| + return 0, date, false, err |
| + } |
| + if day.V != 1 || day.Date != date || day.Apps == nil { |
| + return 0, date, false, fmt.Errorf("invalid storage snapshot %s", date) |
| + } |
| + n := 0 |
| + for _, app := range day.Apps { |
| + if app.Provisioned == nil || (!*app.Provisioned && app.Unswept) { |
| + return 0, date, false, nil |
| + } |
| + if *app.Provisioned { |
| + n++ |
| + } |
| + } |
| + return n, date, true, nil |
| +} |
diff --git a/internal/console/storepage_test.go b/internal/console/storepage_test.go| new file mode 100644 |
| index 000000000..ca6e300d5 |
| --- /dev/null |
| +++ b/internal/console/storepage_test.go |
| @@ -0,0 +1,215 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +func TestStoreAccountPanelAndPrivateAudit(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "store-panel") |
| + putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now(), Note: "PRIVATE-NOTE"}, Decision: &objstore.Decision{By: "PRIVATE-OPERATOR", Reason: "PRIVATE-REASON"}}) |
| + for _, e := range []struct{ action, detail string }{{"store-apply", "PRIVATE-NOTE"}, {"store-decline", "PRIVATE-REASON"}, {"store-approve", "approved"}} { |
| + if err := fx.st.RecordEvent(OpsAccountID, "", "PRIVATE-OPERATOR", e.action, e.detail, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + status, _, body := get(t, c, fx.u("/settings")) |
| + if status != 200 || !strings.Contains(body, "Object storage") || !strings.Contains(body, "Declined") || !strings.Contains(body, "Applied for object storage") || !strings.Contains(body, "Object storage declined") || !strings.Contains(body, "Object storage approved") { |
| + t.Fatalf("panel = %d %s", status, body) |
| + } |
| + if strings.Contains(body, "PRIVATE-") { |
| + t.Fatal("tenant settings leaked private entitlement/audit fields") |
| + } |
| + if !strings.Contains(body, `/store/apply`) { |
| + t.Fatal("owner has no apply control") |
| + } |
| + if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("member@keymail.dev") |
| + member := newBrowser(t) |
| + signUp(t, fx, member, "store-member") |
| + _, _, body = get(t, member, fx.u("/settings")) |
| + if strings.Contains(body, `/store/apply`) || strings.Contains(body, "PRIVATE-") || !strings.Contains(body, "Declined") { |
| + t.Fatal("member panel is not private and read-only") |
| + } |
| + status, _, _ = postForm(t, member, fx.u("/store/apply"), url.Values{"csrf": {sessionToken(t, fx, member)}}) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("member apply=%d", status) |
| + } |
| + status, _, _ = postForm(t, c, fx.u("/store/apply"), url.Values{"csrf": {"wrong"}}) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("bad csrf=%d", status) |
| + } |
| + status, _, body = postForm(t, c, fx.u("/store/apply"), url.Values{"csrf": {sessionToken(t, fx, c)}, "note": {"replacement"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("apply=%d %s", status, body) |
| + } |
| + if e := storedEntitlement(t, fx, fx.sqid); !e.Pending() || e.Applied.Note != "replacement" { |
| + t.Fatalf("apply=%+v", e) |
| + } |
| +} |
| + |
| +func TestStoreQueueGateAndNonmemberDecision(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now(), Note: "operator-only-note"}}) |
| + c := newBrowser(t) |
| + fx.setUser("reviewer@keymail.dev") |
| + signUp(t, fx, c, "store-reviewer") |
| + for _, auth := range []struct{ fleet, listed bool }{{false, false}, {false, true}, {true, false}, {true, true}} { |
| + if err := fx.st.SetOperator("reviewer@keymail.dev", auth.fleet); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cfg.StoreOperators = nil |
| + if auth.listed { |
| + fx.srv.cfg.StoreOperators = []string{" REVIEWER@KEYMAIL.DEV "} |
| + } |
| + status, _, body := get(t, c, fx.ts.URL+"/fleet/store") |
| + if !auth.fleet || !auth.listed { |
| + if status != 404 || body != "404 page not found\n" { |
| + t.Fatalf("gate=%d %q", status, body) |
| + } |
| + continue |
| + } |
| + if status != 200 || !strings.Contains(body, "operator-only-note") { |
| + t.Fatalf("queue=%d %s", status, body) |
| + } |
| + _, _, body = get(t, c, fx.ts.URL+"/fleet") |
| + if !strings.Contains(body, "1 pending") { |
| + t.Fatal("fleet has no pending count") |
| + } |
| + } |
| + target := fx.ts.URL + "/fleet/store/" + fx.sqid + "/approve" |
| + status, _, _ := postForm(t, c, target, url.Values{"csrf": {"wrong"}}) |
| + if status != 403 { |
| + t.Fatalf("decision csrf=%d", status) |
| + } |
| + status, loc, _ := postForm(t, c, target, url.Values{"csrf": {sessionToken(t, fx, c)}}) |
| + if status != 303 || !strings.Contains(loc, "/oauth/") || storedEntitlement(t, fx, fx.sqid).Approved() { |
| + t.Fatalf("approval did not wait for fresh auth: %d %s", status, loc) |
| + } |
| + status, _, body := completeStepUp(t, fx, c, loc) |
| + if status != 303 { |
| + t.Fatalf("decision=%d %s", status, body) |
| + } |
| + if e := storedEntitlement(t, fx, fx.sqid); !e.Approved() || e.Decision.By != "reviewer@keymail.dev" { |
| + t.Fatalf("decision=%+v", e) |
| + } |
| +} |
| + |
| +func TestStoreBucketCountIsDatedAndNeverInventsZero(t *testing.T) { |
| + for _, tc := range []struct { |
| + name, body string |
| + count int |
| + known bool |
| + }{ |
| + {"empty", `{"v":1,"date":"2026-09-08","apps":{}}`, 0, true}, |
| + {"provisioned including empty and unswept", `{"v":1,"date":"2026-09-08","apps":{"bab/a":{"store_provisioned":true},"bab/b":{"store_provisioned":true,"store_unswept":true},"bab/c":{"store_provisioned":false}}}`, 2, true}, |
| + {"legacy", `{"v":1,"date":"2026-09-08","apps":{"bab/a":{"store_bytes":20}}}`, 0, false}, |
| + {"record unreadable", `{"v":1,"date":"2026-09-08","apps":{"bab/a":{"store_provisioned":false,"store_unswept":true}}}`, 0, false}, |
| + {"malformed", `{`, 0, false}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.rel.Put(context.Background(), "usage/storage/2026-09-08.json", []byte(tc.body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + n, date, known, _ := fx.srv.storeBucketCount(context.Background()) |
| + if n != tc.count || known != tc.known || date != "2026-09-08" { |
| + t.Fatalf("count=%d %s %v", n, date, known) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestStoreAccountPanelStatesAndIsolation(t *testing.T) { |
| + for _, state := range []string{"Not applied", "Pending", "Approved"} { |
| + t.Run(state, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "store-states") |
| + if state != "Not applied" { |
| + e := &objstore.Entitlement{Applied: &objstore.Applied{By: "private-owner", Note: "private-note", At: time.Now()}} |
| + if state == "Approved" { |
| + e.Decision = &objstore.Decision{Approved: true, By: "private-operator"} |
| + } |
| + putEntitlement(t, fx, fx.sqid, e) |
| + } |
| + if err := fx.st.RecordEvent(OpsAccountID+999, "", "other-account", "store-apply", "other-account-note", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, _, body := get(t, c, fx.u("/settings")) |
| + if status != 200 || !strings.Contains(body, state) || strings.Contains(body, "private-") || strings.Contains(body, "other-account") { |
| + t.Fatalf("state=%d %s", status, state) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestStoreApprovalReplayRechecksOperatorAuthority(t *testing.T) { |
| + for _, revoke := range []string{"fleet", "allowlist"} { |
| + t.Run(revoke, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := newBrowser(t) |
| + fx.setUser("reviewer@keymail.dev") |
| + signUp(t, fx, c, "replay") |
| + if err := fx.st.SetOperator("reviewer@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cfg.StoreOperators = []string{"reviewer@keymail.dev"} |
| + putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "owner@keymail.dev", At: time.Now()}}) |
| + status, loc, _ := postForm(t, c, fx.ts.URL+"/fleet/store/"+fx.sqid+"/approve", url.Values{"csrf": {sessionToken(t, fx, c)}}) |
| + if status != 303 { |
| + t.Fatalf("gate=%d", status) |
| + } |
| + if revoke == "fleet" { |
| + if err := fx.st.SetOperator("reviewer@keymail.dev", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else { |
| + fx.srv.cfg.StoreOperators = nil |
| + } |
| + status, _, body := completeStepUp(t, fx, c, loc) |
| + if status != 404 || body != "404 page not found\n" || storedEntitlement(t, fx, fx.sqid).Approved() { |
| + t.Fatalf("revoked approval=%d %q", status, body) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestStoreQueueRendersLatestCountWithoutCappingApproval(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "queue-count") |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cfg.StoreOperators = []string{"paul@keymail.dev"} |
| + day := usage.StorageDay{V: 1, Date: "2026-09-08", Apps: map[string]usage.AppStorage{}} |
| + for i := 0; i < 101; i++ { |
| + day.Apps[fmt.Sprintf("bab/app%d", i)] = usage.AppStorage{StoreProvisioned: true} |
| + } |
| + putStoreJSON(t, fx.rel, usage.StorageDayKey(day.Date), day) |
| + putStoreJSON(t, fx.rel, usage.StorageDayKey("2026-09-07"), usage.StorageDay{V: 1, Date: "2026-09-07", Apps: map[string]usage.AppStorage{}}) |
| + putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now()}}) |
| + status, _, body := get(t, c, fx.ts.URL+"/fleet/store") |
| + for _, want := range []string{"Provisioned stores: 101", "2026-09-08", "incomplete provisions", ">Approve</button>"} { |
| + if status != 200 || !strings.Contains(body, want) { |
| + t.Fatalf("queue missing %q (status %d)", want, status) |
| + } |
| + } |
| + status, _, body = postGated(t, fx, c, fx.ts.URL+"/fleet/store/"+fx.sqid+"/approve", url.Values{"csrf": {sessionToken(t, fx, c)}}) |
| + if status != 303 || !storedEntitlement(t, fx, fx.sqid).Approved() { |
| + t.Fatalf("count capped approval: %d %s", status, body) |
| + } |
| +} |
diff --git a/internal/console/templates/account_settings.html b/internal/console/templates/account_settings.html| index da5fbf3e2..67ecc5e69 100644 |
| --- a/internal/console/templates/account_settings.html |
| +++ b/internal/console/templates/account_settings.html |
| @@ -13,6 +13,30 @@ |
| <tr><td>{{.Account.Name}}</td><td><code>{{.Account.Sqid}}</code></td><td>{{.Role}}</td></tr> |
| </table> |
| </div> |
| +{{with .AccountStore}} |
| +<h2>Object storage</h2> |
| +{{if .Unavailable}}<p>Object storage applications are unavailable on this deployment.</p> |
| +{{else}} |
| +<p>{{.State}}</p> |
| +{{if eq .State "Approved"}}<p>Run <code>carlos store create --app <app></code> to provision a store when this deployment has a backend. Usage is metered.</p>{{end}} |
| +{{if eq .State "Pending"}}<p>Waiting for a deployment operator.</p>{{end}} |
| +{{if eq .State "Declined"}}<p>An account owner may apply again.</p>{{end}} |
| +{{if and $.IsOwner .CanApply}} |
| +<form method="post" action="{{$.Base}}/store/apply"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <label>What will you use storage for? (optional) <input type="text" name="note"></label> |
| + <button type="submit">Apply for object storage</button> |
| +</form> |
| +{{end}} |
| +{{end}} |
| +{{if .Events}} |
| +<h3>Storage activity</h3> |
| +<div class="tablewrap"><table> |
| +<tr><th>When</th><th>What</th></tr> |
| +{{range .Events}}<tr><td>{{fmtTime .CreatedAt}}</td><td>{{.Detail}}</td></tr>{{end}} |
| +</table></div> |
| +{{end}} |
| +{{end}} |
| {{if .IsOwner}} |
| <h2>Service credentials</h2> |
| <p class="small">Credentials a SERVER holds — a CI runner, a router, a job — not a person. Each one |
diff --git a/internal/console/templates/fleet.html b/internal/console/templates/fleet.html| index e4d6e1ef1..c956f6f0a 100644 |
| --- a/internal/console/templates/fleet.html |
| +++ b/internal/console/templates/fleet.html |
| @@ -1,5 +1,6 @@ |
| {{define "fleet"}}{{template "top" .}} |
| <h1>Fleet status</h1> |
| +{{if .StorePending}}<p><a href="/fleet/store">Object storage applications: {{.StorePending}} pending</a></p>{{else if .StoreQueueUnknown}}<p><a href="/fleet/store">Object storage applications: count unavailable</a></p>{{end}} |
| <p class="small"><a href="/fleet/architecture">See the architecture →</a></p> |
| {{if .Fleet}} |
| <p class="small">Generated {{fmtTime .Fleet.GeneratedAt}}</p> |
diff --git a/internal/console/templates/store_approvals.html b/internal/console/templates/store_approvals.html| new file mode 100644 |
| index 000000000..cf9df12d8 |
| --- /dev/null |
| +++ b/internal/console/templates/store_approvals.html |
| @@ -0,0 +1,27 @@ |
| +{{define "store_approvals"}}{{template "top" .}} |
| +<h1>Object storage applications</h1> |
| +<p><a href="/fleet">Fleet status</a></p> |
| +{{with .StoreQueue}} |
| +{{if .CountKnown}}<p>Provisioned stores: {{.Buckets}} (daily sweep: {{.SweepDate}}).</p> |
| +{{else}}<p>Bucket count unavailable. A complete storage sweep is needed.</p>{{end}} |
| +<p>Each provisioned store names a bucket. This daily count includes adopted or shared buckets, excludes incomplete provisions, and can lag changes. AWS bucket quotas still apply; CARLOS does not cap stores per account.</p> |
| +<p>AWS now defaults to 10,000 general-purpose buckets per cloud account; the earlier default was 100. This deployment's quota may differ.</p> |
| +<p>{{len .Applications}} pending applications. Oldest first.</p> |
| +{{range .Applications}} |
| +<section> |
| +<h2>Account {{.Account}}</h2> |
| +<p>Applied by {{.Applied.By}} on {{fmtTime .Applied.At}}</p> |
| +{{if .Applied.Note}}<p>{{.Applied.Note}}</p>{{end}} |
| +<form method="post" action="/fleet/store/{{.Account}}/approve"> |
| +<input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| +<button type="submit">Approve</button> |
| +</form> |
| +<form method="post" action="/fleet/store/{{.Account}}/decline"> |
| +<input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| +<label>Reason <input type="text" name="reason" required></label> |
| +<button type="submit">Decline</button> |
| +</form> |
| +</section> |
| +{{else}}<p>No pending storage applications.</p>{{end}} |
| +{{end}} |
| +{{template "bottom" .}}{{end}} |
diff --git a/internal/usage/passes_test.go b/internal/usage/passes_test.go| index 2beb0d303..e993585c0 100644 |
| --- a/internal/usage/passes_test.go |
| +++ b/internal/usage/passes_test.go |
| @@ -21,6 +21,34 @@ func TestKeys(t *testing.T) { |
| } |
| } |
| +func TestAppStorageStoreProvisionedWireShapeIsAdditive(t *testing.T) { |
| + raw, err := json.Marshal(AppStorage{BucketBytes: 12, StoreProvisioned: true}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, want := string(raw), `{"bucket_bytes":12,"store_provisioned":true}`; got != want { |
| + t.Fatalf("provisioned app storage = %s, want %s", got, want) |
| + } |
| + |
| + // Objects written before StoreProvisioned existed remain valid and read as |
| + // unprovisioned. New objects always carry the field so a consumer inspecting |
| + // the raw shape can distinguish a legacy snapshot from a known zero count. |
| + var legacy AppStorage |
| + if err := json.Unmarshal([]byte(`{"bucket_bytes":7,"store_bytes":3}`), &legacy); err != nil { |
| + t.Fatal(err) |
| + } |
| + if legacy.StoreProvisioned { |
| + t.Fatal("legacy app storage read as provisioned") |
| + } |
| + raw, err = json.Marshal(AppStorage{BucketBytes: 12}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, want := string(raw), `{"bucket_bytes":12,"store_provisioned":false}`; got != want { |
| + t.Fatalf("unprovisioned app storage = %s, want %s", got, want) |
| + } |
| +} |
| + |
| func TestCreateDayObjectIsCreateOnly(t *testing.T) { |
| s := release.NewDirStore(t.TempDir()) |
| ctx := context.Background() |
diff --git a/internal/usage/storage.go b/internal/usage/storage.go| index 33f67a756..ceda9a973 100644 |
| --- a/internal/usage/storage.go |
| +++ b/internal/usage/storage.go |
| @@ -2,12 +2,14 @@ package usage |
| // AppStorage is one app's occupancy on one day: deployment-bucket bytes |
| // under the app's prefix, plus its tenant object-store bucket when one is |
| -// provisioned. StoreUnswept marks a store the pass could not size (bad |
| +// provisioned. StoreProvisioned distinguishes an empty provisioned store from |
| +// no store; StoreUnswept marks a store the pass could not size (bad |
| // credentials, endpoint down) — the dashboard must show "unswept", never 0. |
| type AppStorage struct { |
| - BucketBytes int64 `json:"bucket_bytes"` |
| - StoreBytes int64 `json:"store_bytes,omitempty"` |
| - StoreUnswept bool `json:"store_unswept,omitempty"` |
| + BucketBytes int64 `json:"bucket_bytes"` |
| + StoreBytes int64 `json:"store_bytes,omitempty"` |
| + StoreUnswept bool `json:"store_unswept,omitempty"` |
| + StoreProvisioned bool `json:"store_provisioned"` |
| } |
| // StorageDay is usage/storage/<date>.json, keyed "<acct>/<app>". |