From 1efb0e84ec2550c8a2c2a5c02892001c45e078d9 Mon Sep 17 00:00:00 2001 From: Victor Soria Date: Mon, 6 Jul 2026 17:00:40 -0500 Subject: [PATCH] feat(demo): cap public demo user at 5 owned workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents pollution of the shared public demo instance from viral traffic after the anchor video launches. When the demo user tries to create a sixth workspace, the API returns 429 with a friendly CTA pointing to GitHub for self-hosted deployment. This is intentional tech debt hardcoded to the demo email — see issue #102 for the planned refactor to a proper plans/quotas system that applies to all users, not just demo. - workspace.Store: new CountOwnedByUser method for owner-role counting. - workspace_handler.create: quota gate before Store.Create when the caller is the configured demo user (svc.Cfg.DemoEmail) and demo mode is enabled. Returns HTTP 429 with a message that converts the limit into an adoption CTA ("star us on GitHub"). - Quota is a package-level constant (demoUserWorkspaceQuota = 5) that will be removed together with the hardcode when #102 lands. --- internal/api/workspace_handler.go | 24 +++++++++++++++++++++++- internal/workspace/workspace.go | 15 +++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/internal/api/workspace_handler.go b/internal/api/workspace_handler.go index d7a0e44..5af33b5 100644 --- a/internal/api/workspace_handler.go +++ b/internal/api/workspace_handler.go @@ -64,6 +64,11 @@ func (h *workspaceHandler) get(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, resp) } +// demoUserWorkspaceQuota caps how many workspaces the shared public-demo +// user can own concurrently. Prevents pollution of the demo instance from +// viral traffic. TODO(#102): replace with proper plans/quotas system. +const demoUserWorkspaceQuota = 5 + func (h *workspaceHandler) create(w http.ResponseWriter, r *http.Request) { var body struct { Name string `json:"name"` @@ -78,13 +83,30 @@ func (h *workspaceHandler) create(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusBadRequest, errorf("name and purpose are required")) return } + + // Enforce demo user workspace quota (see #102 for planned refactor). + claims := auth.ClaimsFromCtx(r.Context()) + if claims != nil && h.svc.Cfg.DemoMode && claims.Email == h.svc.Cfg.DemoEmail { + n, err := h.store.CountOwnedByUser(claims.UserID) + if err != nil { + respondError(w, http.StatusInternalServerError, err) + return + } + if n >= demoUserWorkspaceQuota { + respondError(w, http.StatusTooManyRequests, errorf( + "demo limit reached (%d workspaces). Deploy your own instance for unlimited workspaces — star us on GitHub: https://github.com/DisruptiveWorks/archipulse", + demoUserWorkspaceQuota)) + return + } + } + ws, err := h.store.Create(body.Name, body.Purpose, body.Description) if err != nil { respondError(w, http.StatusInternalServerError, err) return } // Seed the creating user as workspace owner. - if claims := auth.ClaimsFromCtx(r.Context()); claims != nil { + if claims != nil { _ = h.svc.Enforcer.SeedOwner(ws.ID.String(), claims.UserID) } respondJSON(w, http.StatusCreated, ws) diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 334699c..c485efe 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -89,6 +89,21 @@ func (s *Store) ListForUser(userID string, isAdmin bool) ([]Workspace, error) { return out, rows.Err() } +// CountOwnedByUser returns the number of workspaces where the user has the +// 'owner' role. Used by handlers that need to enforce per-user quotas +// (e.g. the demo user workspace limit). See issue #102 for the planned +// refactor to a proper plans/quotas system. +func (s *Store) CountOwnedByUser(userID string) (int, error) { + var n int + err := s.db.QueryRow(` + SELECT COUNT(*) FROM workspace_members + WHERE user_id = $1 AND role = 'owner'`, userID).Scan(&n) + if err != nil { + return 0, fmt.Errorf("count workspaces owned by user: %w", err) + } + return n, nil +} + // Get returns a single workspace by ID. func (s *Store) Get(id uuid.UUID) (*Workspace, error) { var w Workspace