From fc415bad83f087f54bd93d399ed166614f34a39e Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 14:50:27 +0300 Subject: [PATCH 01/36] Add billing diagnostics and production project backend --- internal/domain/types.go | 51 ++ internal/likeable/admin_handlers.go | 132 ++++- internal/likeable/notifications.go | 15 + internal/likeable/project_binding.go | 3 + internal/likeable/project_handlers.go | 146 +++++- internal/likeable/project_quota.go | 75 ++- internal/likeable/routes.go | 2 + internal/likeable/server_test.go | 722 +++++++++++++++++++++++++- internal/likeable/stripe.go | 138 ++++- internal/likeable/types.go | 1 + internal/store/store.go | 21 + internal/store/store_admin.go | 38 ++ internal/store/store_billing.go | 198 +++++++ internal/store/store_projects.go | 120 ++++- internal/store/types.go | 4 + 15 files changed, 1615 insertions(+), 51 deletions(-) diff --git a/internal/domain/types.go b/internal/domain/types.go index a345c5f..df23299 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -39,12 +39,20 @@ type Project struct { CleanupLastError string `json:"-"` PlaygroundLastUsedAt string `json:"playgroundLastUsedAt,omitempty"` PlaygroundIdleStopAt string `json:"playgroundIdleStopAt,omitempty"` + ProductionExpiresAt string `json:"productionExpiresAt,omitempty"` + CustomDomain string `json:"customDomain,omitempty"` + CustomDomainStatus string `json:"customDomainStatus,omitempty"` + CustomDomainTarget string `json:"customDomainTarget,omitempty"` + CustomDomainUpdatedAt string `json:"customDomainUpdatedAt,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } func (p *Project) RefreshComputedFields() { p.PlaygroundIdleStopAt = "" + if strings.TrimSpace(p.ProductionExpiresAt) != "" { + return + } if p.Status != "ready" || strings.TrimSpace(p.PlaygroundLastUsedAt) == "" { return } @@ -236,6 +244,49 @@ type AdminProjectSummary struct { Assignment AgentAssignmentSummary `json:"assignment"` } +type AdminBillingPayment struct { + ID string `json:"id"` + UserID string `json:"userId"` + UserEmail string `json:"userEmail"` + ProviderPaymentID string `json:"providerPaymentId"` + AmountCents int64 `json:"amountCents"` + Currency string `json:"currency"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` +} + +type AdminHourCreditLedgerEntry struct { + ID string `json:"id"` + UserID string `json:"userId"` + DeltaMs int64 `json:"deltaMs"` + Reason string `json:"reason"` + PaymentID string `json:"paymentId,omitempty"` + WorkSessionKey string `json:"workSessionKey,omitempty"` + CreatedAt string `json:"createdAt"` +} + +type AdminProjectInternal struct { + UserID string `json:"userId"` + ConversationID string `json:"conversationId,omitempty"` + AgentID string `json:"agentId,omitempty"` + ServerID string `json:"serverId,omitempty"` + PlaygroundID string `json:"playgroundId,omitempty"` + PlaygroundName string `json:"playgroundName,omitempty"` + PlayspecID string `json:"playspecId,omitempty"` + PropID string `json:"propId,omitempty"` + RepoURL string `json:"repoUrl,omitempty"` + ProvisioningLockUntil string `json:"provisioningLockUntil,omitempty"` + CleanupLastError string `json:"cleanupLastError,omitempty"` +} + +type AdminProjectDiagnostics struct { + Project Project `json:"project"` + Internal AdminProjectInternal `json:"internal"` + WorkSessions []ProjectWorkSession `json:"workSessions"` + HourLedger []AdminHourCreditLedgerEntry `json:"hourLedger"` + Payments []AdminBillingPayment `json:"payments"` +} + type AdminUserDetail struct { Summary AdminUserSummary `json:"summary"` Projects []AdminProjectSummary `json:"projects"` diff --git a/internal/likeable/admin_handlers.go b/internal/likeable/admin_handlers.go index 1c0e648..908fb40 100644 --- a/internal/likeable/admin_handlers.go +++ b/internal/likeable/admin_handlers.go @@ -151,6 +151,68 @@ func (s *Server) handleAdminRecovery(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleAdminBillingHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + cfg, err := s.store.ConfigMap(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + stripeCfg := stripeConfigFromMap(cfg) + payments, err := s.store.RecentPayments(r.Context(), 10) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + priceStatus := map[string]bool{ + "oneHour": strings.TrimSpace(stripeCfg["price_1_hour"]) != "", + "tenHours": strings.TrimSpace(stripeCfg["price_10_hours"]) != "", + "hundredHours": strings.TrimSpace(stripeCfg["price_100_hours"]) != "", + "projectQuota": strings.TrimSpace(stripeCfg["project_quota_price"]) != "", + "productionProject": strings.TrimSpace(stripeCfg["production_project_price"]) != "", + } + issues := []string{} + if strings.TrimSpace(cfg["stripe_publishable_key"]) == "" { + issues = append(issues, "stripe_publishable_missing") + } + if strings.TrimSpace(stripeCfg["secret"]) == "" { + issues = append(issues, "stripe_secret_missing") + } + if strings.TrimSpace(stripeCfg["webhook"]) == "" { + issues = append(issues, "stripe_webhook_missing") + } + if !priceStatus["oneHour"] && !priceStatus["tenHours"] && !priceStatus["hundredHours"] { + issues = append(issues, "stripe_hour_prices_missing") + } + if !priceStatus["projectQuota"] { + issues = append(issues, "stripe_project_quota_price_missing") + } + if !priceStatus["productionProject"] { + issues = append(issues, "stripe_production_project_price_missing") + } + products := billingProductsFromConfig(stripeCfg) + products["projectQuotaDays"] = s.projectQuotaDays(r.Context()) + products["productionProjectDays"] = s.productionProjectDays(r.Context()) + writeJSON(w, http.StatusOK, map[string]any{ + "health": map[string]any{ + "checkedAt": time.Now().UTC().Format(time.RFC3339Nano), + "configured": map[string]bool{ + "publishableKey": strings.TrimSpace(cfg["stripe_publishable_key"]) != "", + "secretKey": strings.TrimSpace(stripeCfg["secret"]) != "", + "webhookSecret": strings.TrimSpace(stripeCfg["webhook"]) != "", + }, + "products": products, + "prices": priceStatus, + "free": map[string]any{"minutes": s.freeBuildLimitMinutes(r.Context()), "windowHours": s.freeHourWindowHours(r.Context())}, + "issues": issues, + "recentPayments": payments, + }, + }) +} + type adminRecoveryProject struct { ID string `json:"id"` UserID string `json:"userId"` @@ -289,6 +351,8 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { s.handleAdminUserGrantHours(w, r, userID) case len(parts) == 3 && parts[1] == "projects" && r.Method == http.MethodDelete: s.handleAdminUserProjectDelete(w, r, userID, parts[2]) + case len(parts) == 4 && parts[1] == "projects" && parts[3] == "diagnostics" && r.Method == http.MethodGet: + s.handleAdminUserProjectDiagnostics(w, r, userID, parts[2]) case len(parts) == 4 && parts[1] == "projects" && parts[3] == "assignment" && r.Method == http.MethodPatch: s.handleAdminUserProjectAssignment(w, r, userID, parts[2]) default: @@ -498,6 +562,19 @@ func (s *Server) handleAdminUserProjectDelete(w http.ResponseWriter, r *http.Req writeJSON(w, http.StatusAccepted, map[string]any{"project": project}) } +func (s *Server) handleAdminUserProjectDiagnostics(w http.ResponseWriter, r *http.Request, userID, projectID string) { + diagnostics, err := s.store.AdminProjectDiagnostics(r.Context(), userID, projectID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "project not found") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"diagnostics": diagnostics}) +} + func (s *Server) handleAdminUserProjectAssignment(w http.ResponseWriter, r *http.Request, userID, projectID string) { var body struct { AgentID string `json:"agent_id"` @@ -691,9 +768,21 @@ func firstNonEmptyString(values ...string) string { func publicAdminConfig(cfg map[string]string) map[string]any { out := map[string]any{} - for _, key := range []string{"fibe_base_url", "fibe_agent_server_pool", "fibe_template_version_id", "free_hours", "free_hour_window_hours", "prompt_improve_charge_minutes", "project_cap", "signup_mode", "signup_allowed_emails", "stripe_publishable_key", "stripe_price_id_1_hour", "stripe_price_id_10_hours", "stripe_price_id_100_hours", "stripe_project_quota_price_id", "github_client_id", "github_username", "google_client_id", "smtp_host", "smtp_port", "smtp_username", "smtp_from_email", "smtp_from_name", "smtp_tls_mode"} { + for _, key := range []string{"fibe_base_url", "fibe_agent_server_pool", "fibe_template_version_id", "free_minutes", "free_hour_window_hours", "prompt_improve_charge_minutes", "project_cap", "project_quota_days", "production_project_days", "signup_mode", "signup_allowed_emails", "stripe_publishable_key", "stripe_price_id_1_hour", "stripe_price_id_10_hours", "stripe_price_id_100_hours", "stripe_project_quota_price_id", "stripe_production_project_price_id", "github_client_id", "github_username", "google_client_id", "smtp_host", "smtp_port", "smtp_username", "smtp_from_email", "smtp_from_name", "smtp_tls_mode"} { value := cfg[key] set := strings.TrimSpace(cfg[key]) != "" + if key == "free_minutes" && strings.TrimSpace(value) == "" { + if legacyHours := strings.TrimSpace(cfg["free_hours"]); legacyHours != "" { + if n, err := strconv.Atoi(legacyHours); err == nil && n >= 0 { + minutes := n * 60 + if minutes > maxFreeBuildMinutes { + minutes = maxFreeBuildMinutes + } + value = strconv.Itoa(minutes) + set = true + } + } + } if key == "fibe_agent_server_pool" && strings.TrimSpace(value) == "" { value = cfg["fibe_agent_marquee_pool"] set = strings.TrimSpace(value) != "" @@ -714,14 +803,18 @@ func publicAdminConfig(cfg map[string]string) map[string]any { func publicConfigDefault(key string) string { switch key { - case "free_hours": - return "5" + case "free_minutes": + return strconv.Itoa(defaultFreeBuildMinutes) case "free_hour_window_hours": return strconv.Itoa(defaultFreeHourWindowHours) case "prompt_improve_charge_minutes": return "0" case "project_cap": return "3" + case "project_quota_days": + return strconv.Itoa(defaultProjectQuotaDays) + case "production_project_days": + return strconv.Itoa(defaultProductionProjectDays) case "signup_mode": return "forbidden" case "fibe_agent_server_pool": @@ -758,6 +851,17 @@ func normalizeAdminConfigValues(values map[string]string) (map[string]string, er } case "smtp_tls_mode": out[key] = normalizeSMTPTLSMode(value) + case "free_minutes": + trimmed := strings.TrimSpace(value) + if trimmed == "" { + out[key] = "" + continue + } + n, err := strconv.Atoi(trimmed) + if err != nil || n < 0 || n > maxFreeBuildMinutes { + return nil, errors.New("free_minutes must be between 0 and 1440") + } + out[key] = strconv.Itoa(n) case "free_hour_window_hours": trimmed := strings.TrimSpace(value) if trimmed == "" { @@ -780,6 +884,28 @@ func normalizeAdminConfigValues(values map[string]string) (map[string]string, er return nil, errors.New("prompt_improve_charge_minutes must be between 0 and 60") } out[key] = strconv.Itoa(n) + case "project_quota_days": + trimmed := strings.TrimSpace(value) + if trimmed == "" { + out[key] = "" + continue + } + n, err := strconv.Atoi(trimmed) + if err != nil || n <= 0 || n > maxProjectQuotaDays { + return nil, errors.New("project_quota_days must be between 1 and 365") + } + out[key] = strconv.Itoa(n) + case "production_project_days": + trimmed := strings.TrimSpace(value) + if trimmed == "" { + out[key] = "" + continue + } + n, err := strconv.Atoi(trimmed) + if err != nil || n <= 0 || n > maxProductionProjectDays { + return nil, errors.New("production_project_days must be between 1 and 365") + } + out[key] = strconv.Itoa(n) default: out[key] = strings.TrimSpace(value) } diff --git a/internal/likeable/notifications.go b/internal/likeable/notifications.go index 6c5bdd4..b3deddf 100644 --- a/internal/likeable/notifications.go +++ b/internal/likeable/notifications.go @@ -156,6 +156,21 @@ func (s *Server) notifyProjectQuotaPurchased(ctx context.Context, userID string, s.addSystemNoticeAndEmail(ctx, user, "info", body, "Likeable project quota added", body+"\n\nManage projects:\n"+s.profileURL()) } +func (s *Server) notifyProductionProjectPurchased(ctx context.Context, userID, projectID string, expiresAt time.Time) { + user, err := s.store.UserByID(ctx, userID) + if err != nil { + log.Printf("load user for production project purchase notice %s: %v", userID, err) + return + } + project, err := s.store.ProjectForUser(ctx, userID, projectID) + if err != nil { + log.Printf("load project for production project purchase notice %s/%s: %v", userID, projectID, err) + return + } + body := fmt.Sprintf("Production project enabled: %q will stay online until %s. Use the project menu for CNAME instructions.", project.Title, expiresAt.UTC().Format("2006-01-02 15:04 UTC")) + s.addSystemNoticeAndEmail(ctx, user, "info", body, "Likeable production project enabled", body+"\n\nOpen Likeable:\n"+s.config.BaseURL) +} + func (s *Server) notifyProjectExportReady(ctx context.Context, user *User, project *Project, repoURL string) { if user == nil || project == nil || strings.TrimSpace(repoURL) == "" { return diff --git a/internal/likeable/project_binding.go b/internal/likeable/project_binding.go index 9cb3ef3..6f91096 100644 --- a/internal/likeable/project_binding.go +++ b/internal/likeable/project_binding.go @@ -101,6 +101,9 @@ func (s *Server) projectBindingStatus(ctx context.Context, project *Project) (st } func developmentBlockedMessage(err error) string { + if errors.Is(err, errProductionProjectCannotStop) { + return errProductionProjectCannotStop.Error() + } if errors.Is(err, errProjectRetiring) { return errProjectRetiring.Error() } diff --git a/internal/likeable/project_handlers.go b/internal/likeable/project_handlers.go index dabe66f..8047442 100644 --- a/internal/likeable/project_handlers.go +++ b/internal/likeable/project_handlers.go @@ -8,6 +8,7 @@ import ( "fmt" "log" "net/http" + "net/url" "strings" "time" @@ -16,8 +17,9 @@ import ( ) var ( - errInvalidPlaygroundAction = errors.New("invalid playground action") - errProjectPlaygroundMissing = errors.New("project has no playground") + errInvalidPlaygroundAction = errors.New("invalid playground action") + errProjectPlaygroundMissing = errors.New("project has no playground") + errProductionProjectCannotStop = errors.New("production project cannot be stopped") ) func (s *Server) handleProjects(w http.ResponseWriter, r *http.Request) { @@ -174,6 +176,8 @@ func (s *Server) handleProjectRoute(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "not found") case "playground": s.handleProjectPlaygroundAction(w, r, user, project) + case "domain": + s.handleProjectDomain(w, r, user, project) case "attachments": if len(parts) != 3 { writeError(w, http.StatusNotFound, "not found") @@ -291,7 +295,7 @@ func (s *Server) handleProjectPlaygroundAction(w http.ResponseWriter, r *http.Re } updated, err := s.controlProjectPlayground(r.Context(), user, project, strings.ToLower(strings.TrimSpace(body.Action))) if err != nil { - if errors.Is(err, errProjectExportOnly) || errors.Is(err, errProjectRetiring) { + if errors.Is(err, errProjectExportOnly) || errors.Is(err, errProjectRetiring) || errors.Is(err, errProductionProjectCannotStop) { writeError(w, http.StatusConflict, developmentBlockedMessage(err)) return } @@ -321,6 +325,9 @@ func (s *Server) controlProjectPlayground(ctx context.Context, user *User, proje if project.Status == "deleting" { return nil, errInvalidPlaygroundAction } + if action == "stop" && strings.TrimSpace(project.ProductionExpiresAt) != "" { + return nil, errProductionProjectCannotStop + } playgroundID := strings.TrimSpace(project.PlaygroundID) if playgroundID == "" { return nil, errProjectPlaygroundMissing @@ -365,6 +372,139 @@ func (s *Server) controlProjectPlayground(ctx context.Context, user *User, proje return s.store.ProjectForUser(ctx, user.ID, project.ID) } +func (s *Server) handleProjectDomain(w http.ResponseWriter, r *http.Request, user *User, project *Project) { + switch r.Method { + case http.MethodPut: + if strings.TrimSpace(project.ProductionExpiresAt) == "" { + writeError(w, http.StatusConflict, "production project is required before adding a custom domain") + return + } + var body struct { + Domain string `json:"domain"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + domain, err := normalizeProjectCustomDomain(body.Domain) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + target := projectCustomDomainTarget(project) + if target == "" { + writeError(w, http.StatusConflict, "project CNAME target is not available") + return + } + if err := s.store.UpsertProjectDomain(r.Context(), user.ID, project.ID, domain, target); err != nil { + log.Printf("project domain upsert for project %s: %v", project.ID, err) + writeError(w, http.StatusConflict, "custom domain is already linked to another project") + return + } + case http.MethodDelete: + if err := s.store.DeleteProjectDomain(r.Context(), user.ID, project.ID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + updated, err := s.store.ProjectForUser(r.Context(), user.ID, project.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"project": updated}) +} + +func normalizeProjectCustomDomain(value string) (string, error) { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "", errors.New("custom domain is required") + } + if strings.Contains(value, "://") { + parsed, err := url.Parse(value) + if err != nil || parsed.Hostname() == "" { + return "", errors.New("custom domain must be a valid hostname") + } + if parsed.Path != "" && parsed.Path != "/" { + return "", errors.New("custom domain must not include a path") + } + value = parsed.Hostname() + } + value = strings.Trim(value, ".") + if strings.ContainsAny(value, "/:?#[]@") || strings.Contains(value, "*") { + return "", errors.New("custom domain must be a hostname without path, port, or wildcard") + } + if len(value) > 253 || !strings.Contains(value, ".") { + return "", errors.New("custom domain must be a fully qualified hostname") + } + labels := strings.Split(value, ".") + for _, label := range labels { + if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { + return "", errors.New("custom domain contains an invalid label") + } + for _, r := range label { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return "", errors.New("custom domain must use DNS-safe ASCII labels") + } + } + } + if allDigits(labels[len(labels)-1]) { + return "", errors.New("custom domain top-level label must not be numeric") + } + return value, nil +} + +func allDigits(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func projectCustomDomainTarget(project *Project) string { + if project == nil { + return "" + } + selected := strings.TrimSpace(project.SelectedService) + for _, service := range project.Services { + if selected != "" && service.Name != selected { + continue + } + if target := projectURLHost(service.URL); target != "" { + return target + } + } + if target := projectURLHost(project.PreviewURL); target != "" { + return target + } + for _, service := range project.Services { + if target := projectURLHost(service.URL); target != "" { + return target + } + } + return "" +} + +func projectURLHost(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" + } + if parsed, err := url.Parse(rawURL); err == nil && parsed.Host != "" { + return parsed.Host + } + rawURL = strings.TrimPrefix(strings.TrimPrefix(rawURL, "https://"), "http://") + return strings.Split(rawURL, "/")[0] +} + func (s *Server) handleProjectFeed(w http.ResponseWriter, r *http.Request, user *User, project *Project) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") diff --git a/internal/likeable/project_quota.go b/internal/likeable/project_quota.go index 978434c..fe6a146 100644 --- a/internal/likeable/project_quota.go +++ b/internal/likeable/project_quota.go @@ -8,11 +8,18 @@ import ( ) const ( - defaultFreeHourWindowHours = 5 - maxFreeHourWindowHours = 24 - maxPromptImproveChargeMin = 60 - freeHourWindow = time.Duration(defaultFreeHourWindowHours) * time.Hour - msPerHour = int64(time.Hour / time.Millisecond) + defaultFreeBuildMinutes = 30 + maxFreeBuildMinutes = 24 * 60 + defaultFreeHourWindowHours = 5 + maxFreeHourWindowHours = 24 + defaultProjectQuotaDays = 30 + maxProjectQuotaDays = 365 + defaultProductionProjectDays = 30 + maxProductionProjectDays = 365 + maxPromptImproveChargeMin = 60 + freeHourWindow = time.Duration(defaultFreeHourWindowHours) * time.Hour + msPerHour = int64(time.Hour / time.Millisecond) + msPerMinute = int64(time.Minute / time.Millisecond) ) func (s *Server) canSendMessage(ctx context.Context, user *User) bool { @@ -105,21 +112,37 @@ func fixedUTCHourWindow(now time.Time, interval time.Duration) (time.Time, time. return start, end } -func (s *Server) freeHourLimit(ctx context.Context) int { +func (s *Server) freeBuildLimitMinutes(ctx context.Context) int { cfg, _ := s.store.ConfigMap(ctx) - raw := strings.TrimSpace(cfg["free_hours"]) + raw := strings.TrimSpace(cfg["free_minutes"]) if raw == "" { - raw = "5" + legacyHours := strings.TrimSpace(cfg["free_hours"]) + if legacyHours != "" { + n, err := strconv.Atoi(legacyHours) + if err == nil && n >= 0 { + minutes := n * 60 + if minutes > maxFreeBuildMinutes { + return maxFreeBuildMinutes + } + return minutes + } + } + } + if raw == "" { + raw = strconv.Itoa(defaultFreeBuildMinutes) } n, err := strconv.Atoi(raw) if err != nil || n < 0 { - return 5 + return defaultFreeBuildMinutes + } + if n > maxFreeBuildMinutes { + return maxFreeBuildMinutes } return n } func (s *Server) freeHourLimitMs(ctx context.Context) int64 { - return int64(s.freeHourLimit(ctx)) * msPerHour + return int64(s.freeBuildLimitMinutes(ctx)) * msPerMinute } func (s *Server) freeHourWindowHours(ctx context.Context) int { @@ -151,6 +174,38 @@ func (s *Server) promptImproveChargeMinutes(ctx context.Context) int { return n } +func (s *Server) projectQuotaDays(ctx context.Context) int { + cfg, _ := s.store.ConfigMap(ctx) + raw := strings.TrimSpace(cfg["project_quota_days"]) + if raw == "" { + return defaultProjectQuotaDays + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return defaultProjectQuotaDays + } + if n > maxProjectQuotaDays { + return maxProjectQuotaDays + } + return n +} + +func (s *Server) productionProjectDays(ctx context.Context) int { + cfg, _ := s.store.ConfigMap(ctx) + raw := strings.TrimSpace(cfg["production_project_days"]) + if raw == "" { + return defaultProductionProjectDays + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return defaultProductionProjectDays + } + if n > maxProductionProjectDays { + return maxProductionProjectDays + } + return n +} + func (s *Server) projectCap(ctx context.Context) int { return s.baseProjectCap(ctx) } diff --git a/internal/likeable/routes.go b/internal/likeable/routes.go index c050b0b..1d011a0 100644 --- a/internal/likeable/routes.go +++ b/internal/likeable/routes.go @@ -116,6 +116,8 @@ func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) { s.withAdmin(s.handleAdminConfig)(w, r) case r.URL.Path == "/api/admin/recovery": s.withAdmin(s.handleAdminRecovery)(w, r) + case r.URL.Path == "/api/admin/billing/health": + s.withAdmin(s.handleAdminBillingHealth)(w, r) case r.URL.Path == "/api/admin/agent-pool/retire": s.withAdmin(s.handleAdminAgentPoolRetire)(w, r) case r.URL.Path == "/api/admin/users" || strings.HasPrefix(r.URL.Path, "/api/admin/users/"): diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index ed2b610..c596c1a 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -583,8 +583,11 @@ func TestMeIncludesOnlyConfiguredBillingProducts(t *testing.T) { t.Fatal(err) } readProducts := func() struct { - HourPacks []int `json:"hourPacks"` - ProjectQuota bool `json:"projectQuota"` + HourPacks []int `json:"hourPacks"` + ProjectQuota bool `json:"projectQuota"` + ProjectQuotaDays int `json:"projectQuotaDays"` + ProductionProject bool `json:"productionProject"` + ProductionProjectDays int `json:"productionProjectDays"` } { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/me", nil) @@ -596,8 +599,11 @@ func TestMeIncludesOnlyConfiguredBillingProducts(t *testing.T) { } var body struct { BillingProducts struct { - HourPacks []int `json:"hourPacks"` - ProjectQuota bool `json:"projectQuota"` + HourPacks []int `json:"hourPacks"` + ProjectQuota bool `json:"projectQuota"` + ProjectQuotaDays int `json:"projectQuotaDays"` + ProductionProject bool `json:"productionProject"` + ProductionProjectDays int `json:"productionProjectDays"` } `json:"billingProducts"` } if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { @@ -607,19 +613,22 @@ func TestMeIncludesOnlyConfiguredBillingProducts(t *testing.T) { } products := readProducts() - if len(products.HourPacks) != 0 || products.ProjectQuota { + if len(products.HourPacks) != 0 || products.ProjectQuota || products.ProductionProject { t.Fatalf("billing products=%+v before Stripe config, want none", products) } if err := store.UpsertConfig(t.Context(), map[string]string{ - "stripe_secret_key": "sk_test", - "stripe_price_id_1_hour": "price_1h", - "stripe_price_id_100_hours": "price_100h", - "stripe_project_quota_price_id": "price_project_slot", + "stripe_secret_key": "sk_test", + "stripe_price_id_1_hour": "price_1h", + "stripe_price_id_100_hours": "price_100h", + "stripe_project_quota_price_id": "price_project_slot", + "stripe_production_project_price_id": "price_production_project", + "project_quota_days": "21", + "production_project_days": "45", }, secretConfigKeys); err != nil { t.Fatal(err) } products = readProducts() - if !reflect.DeepEqual(products.HourPacks, []int{1, 100}) || !products.ProjectQuota { + if !reflect.DeepEqual(products.HourPacks, []int{1, 100}) || !products.ProjectQuota || products.ProjectQuotaDays != 21 || !products.ProductionProject || products.ProductionProjectDays != 45 { t.Fatalf("billing products=%+v, want configured packs and project quota", products) } } @@ -3766,6 +3775,167 @@ func TestProjectPlaygroundLifecycleActions(t *testing.T) { } } +func TestProductionProjectPlaygroundCannotBeStopped(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + cliPath, logPath, _ := fakeFibeCLI(t) + if err := store.UpsertConfig(t.Context(), map[string]string{ + "fibe_base_url": "server.test:3000", + "fibe_api_key": "test-key", + "fibe_cli_path": cliPath, + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient} + user, err := store.UpsertUser(t.Context(), "production-stop@example.com", "Production Stop", "") + if err != nil { + t.Fatal(err) + } + if err := store.CreateSession(t.Context(), user.ID, "production-stop-token", time.Hour); err != nil { + t.Fatal(err) + } + project := &Project{ID: "project-production-stop", UserID: user.ID, Title: "Production", ConversationID: "conv-production-stop", AgentID: "agent-1", PlaygroundID: "playground-production-stop", Status: "ready"} + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + if granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_production_stop", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted { + t.Fatalf("production grant=%v err=%v, want granted", granted, err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/projects/project-production-stop/playground", strings.NewReader(`{"action":"stop"}`)) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "production-stop-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("stop returned %d, want 409; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "production project cannot be stopped") { + t.Fatalf("body=%s, want production stop error", rec.Body.String()) + } + stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID) + if err != nil { + t.Fatal(err) + } + if stored.Status != "ready" || stored.ProductionExpiresAt == "" { + t.Fatalf("project=%+v, want ready production project", stored) + } + if log, err := os.ReadFile(logPath); err == nil && strings.Contains(string(log), "playgrounds stop playground-production-stop") { + t.Fatalf("unexpected stop command for production project; log=%s", string(log)) + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } +} + +func TestProjectCustomDomainRequiresProductionProject(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient} + user, err := store.UpsertUser(t.Context(), "domain-user@example.com", "Domain User", "") + if err != nil { + t.Fatal(err) + } + if err := store.CreateSession(t.Context(), user.ID, "domain-token", time.Hour); err != nil { + t.Fatal(err) + } + project := &Project{ID: "project-domain-locked", UserID: user.ID, Title: "Domain", ConversationID: "conv-domain-locked", Status: "ready", PreviewURL: "https://target.example.test"} + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPut, "/api/projects/project-domain-locked/domain", strings.NewReader(`{"domain":"app.example.com"}`)) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("domain returned %d, want 409; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestProjectCustomDomainCanBeSavedAndDeleted(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient} + user, err := store.UpsertUser(t.Context(), "domain-buyer@example.com", "Domain Buyer", "") + if err != nil { + t.Fatal(err) + } + if err := store.CreateSession(t.Context(), user.ID, "domain-buyer-token", time.Hour); err != nil { + t.Fatal(err) + } + project := &Project{ID: "project-domain", UserID: user.ID, Title: "Domain", ConversationID: "conv-domain", Status: "ready", PreviewURL: "https://fallback.example.test", SelectedService: "app"} + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + if err := store.ReplaceProjectResources(t.Context(), project.ID, nil, []ProjectService{ + {ProjectID: project.ID, Name: "app", URL: "https://app-target.example.test", Type: "dynamic", Visibility: "external"}, + }); err != nil { + t.Fatal(err) + } + if granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_domain_project", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted { + t.Fatalf("production grant=%v err=%v, want granted", granted, err) + } + + req := httptest.NewRequest(http.MethodPut, "/api/projects/project-domain/domain", strings.NewReader(`{"domain":"HTTPS://App.Customer.Example/"}`)) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-buyer-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("domain save returned %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Project Project `json:"project"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Project.CustomDomain != "app.customer.example" || body.Project.CustomDomainStatus != "pending_dns" || body.Project.CustomDomainTarget != "app-target.example.test" { + t.Fatalf("project domain=%+v, want normalized pending domain with target", body.Project) + } + + req = httptest.NewRequest(http.MethodGet, "/api/projects/project-domain", nil) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-buyer-token"}) + rec = httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("project get returned %d: %s", rec.Code, rec.Body.String()) + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Project.CustomDomain != "app.customer.example" || body.Project.CustomDomainTarget != "app-target.example.test" { + t.Fatalf("project get domain=%+v, want persisted domain", body.Project) + } + + req = httptest.NewRequest(http.MethodDelete, "/api/projects/project-domain/domain", nil) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-buyer-token"}) + rec = httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("domain delete returned %d: %s", rec.Code, rec.Body.String()) + } + body = struct { + Project Project `json:"project"` + }{} + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Project.CustomDomain != "" || body.Project.CustomDomainTarget != "" { + t.Fatalf("project domain=%+v, want cleared domain", body.Project) + } +} + func TestProjectPassiveActionsDoNotTouchPlaygroundUsage(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { @@ -4034,6 +4204,54 @@ func TestIdleProjectStopTaskSkipsAfterRecentUsageReset(t *testing.T) { } } +func TestIdleProjectStopTaskSkipsProductionProject(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + cliPath, logPath, _ := fakeFibeCLI(t) + if err := os.WriteFile(logPath, nil, 0o644); err != nil { + t.Fatal(err) + } + if err := store.UpsertConfig(t.Context(), map[string]string{ + "fibe_base_url": "server.test:3000", + "fibe_api_key": "test-key", + "fibe_cli_path": cliPath, + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient} + user, err := store.UpsertUser(t.Context(), "idle-production@example.com", "Idle Production", "") + if err != nil { + t.Fatal(err) + } + project := &Project{ID: "project-idle-production", UserID: user.ID, Title: "Production", ConversationID: "conv-idle-production", AgentID: "agent-1", PlaygroundID: "playground-idle-production", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-idleProjectStopAfter - time.Minute).Format(time.RFC3339Nano)} + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + expiresAt := time.Now().UTC().Add(30 * 24 * time.Hour) + if granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_production_idle", expiresAt); err != nil || !granted { + t.Fatalf("production grant=%v err=%v, want granted", granted, err) + } + payload, _ := json.Marshal(projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID}) + + if err := server.handleStopIdleProjectTask(t.Context(), asynq.NewTask(taskStopIdleProject, payload)); err != nil { + t.Fatal(err) + } + + stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID) + if err != nil { + t.Fatal(err) + } + if stored.Status != "ready" || stored.PlaygroundIdleStopAt != "" || stored.ProductionExpiresAt == "" { + t.Fatalf("project=%+v, want ready production project without idle stop deadline", stored) + } + if log := readFile(t, logPath); strings.Contains(log, "playgrounds stop playground-idle-production") { + t.Fatalf("unexpected stop command for production project; log=%s", log) + } +} + func TestIdleProjectStopTaskTreatsAlreadyStoppedAsSuccess(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { @@ -5605,6 +5823,324 @@ func TestFixedUTCFreeHoursAndPaidBalance(t *testing.T) { } } +func TestFreeBuildMinutesConfig(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient} + + if got := server.freeBuildLimitMinutes(t.Context()); got != defaultFreeBuildMinutes { + t.Fatalf("default free minutes=%d, want %d", got, defaultFreeBuildMinutes) + } + if got := server.freeHourLimitMs(t.Context()); got != int64(30*time.Minute/time.Millisecond) { + t.Fatalf("default free limit ms=%d, want 30m", got) + } + + if err := store.UpsertConfig(t.Context(), map[string]string{"free_hours": "2"}, secretConfigKeys); err != nil { + t.Fatal(err) + } + if got := server.freeBuildLimitMinutes(t.Context()); got != 120 { + t.Fatalf("legacy free hours as minutes=%d, want 120", got) + } + + if err := store.UpsertConfig(t.Context(), map[string]string{"free_minutes": "45"}, secretConfigKeys); err != nil { + t.Fatal(err) + } + if got := server.freeBuildLimitMinutes(t.Context()); got != 45 { + t.Fatalf("configured free minutes=%d, want 45", got) + } + if got := server.freeHourLimitMs(t.Context()); got != int64(45*time.Minute/time.Millisecond) { + t.Fatalf("configured free limit ms=%d, want 45m", got) + } +} + +func TestProjectQuotaDaysConfig(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient} + + if got := server.projectQuotaDays(t.Context()); got != defaultProjectQuotaDays { + t.Fatalf("default project quota days=%d, want %d", got, defaultProjectQuotaDays) + } + + if err := store.UpsertConfig(t.Context(), map[string]string{"project_quota_days": "14"}, secretConfigKeys); err != nil { + t.Fatal(err) + } + if got := server.projectQuotaDays(t.Context()); got != 14 { + t.Fatalf("configured project quota days=%d, want 14", got) + } + + if err := store.UpsertConfig(t.Context(), map[string]string{"project_quota_days": "900"}, secretConfigKeys); err != nil { + t.Fatal(err) + } + if got := server.projectQuotaDays(t.Context()); got != maxProjectQuotaDays { + t.Fatalf("clamped project quota days=%d, want %d", got, maxProjectQuotaDays) + } + + if err := store.UpsertConfig(t.Context(), map[string]string{"project_quota_days": "nope"}, secretConfigKeys); err != nil { + t.Fatal(err) + } + if got := server.projectQuotaDays(t.Context()); got != defaultProjectQuotaDays { + t.Fatalf("invalid project quota days=%d, want default %d", got, defaultProjectQuotaDays) + } +} + +func TestProductionProjectDaysConfig(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient} + + if got := server.productionProjectDays(t.Context()); got != defaultProductionProjectDays { + t.Fatalf("default production project days=%d, want %d", got, defaultProductionProjectDays) + } + + if err := store.UpsertConfig(t.Context(), map[string]string{"production_project_days": "45"}, secretConfigKeys); err != nil { + t.Fatal(err) + } + if got := server.productionProjectDays(t.Context()); got != 45 { + t.Fatalf("configured production project days=%d, want 45", got) + } + + if err := store.UpsertConfig(t.Context(), map[string]string{"production_project_days": "900"}, secretConfigKeys); err != nil { + t.Fatal(err) + } + if got := server.productionProjectDays(t.Context()); got != maxProductionProjectDays { + t.Fatalf("clamped production project days=%d, want %d", got, maxProductionProjectDays) + } +} + +func TestPublicAdminConfigExposesLegacyFreeHoursAsMinutes(t *testing.T) { + cfg := publicAdminConfig(map[string]string{"free_hours": "2"}) + entry, ok := cfg["free_minutes"].(map[string]any) + if !ok { + t.Fatalf("free_minutes entry=%T, want map", cfg["free_minutes"]) + } + if entry["value"] != "120" || entry["set"] != true { + t.Fatalf("free_minutes entry=%+v, want legacy 2h exposed as 120 minutes and set", entry) + } +} + +func TestAdminBillingHealthReportsStripeConfigAndRecentPayments(t *testing.T) { + appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer appStore.Close() + server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient} + admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "") + if err != nil { + t.Fatal(err) + } + customer, err := appStore.UpsertUser(t.Context(), "payer@example.com", "Payer", "") + if err != nil { + t.Fatal(err) + } + if err := appStore.CreateSession(t.Context(), admin.ID, "admin-billing-token", time.Hour); err != nil { + t.Fatal(err) + } + if err := appStore.UpsertConfig(t.Context(), map[string]string{ + "stripe_publishable_key": "pk_test_health", + "stripe_secret_key": "sk_test_health", + "stripe_webhook_secret": "whsec_health", + "stripe_price_id_1_hour": "price_hour_1", + "stripe_project_quota_price_id": "price_project_quota", + "stripe_production_project_price_id": "price_production_project", + "free_minutes": "30", + "free_hour_window_hours": "5", + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + if err := appStore.UpsertPayment(t.Context(), store.Payment{ + ID: "payment-health", + UserID: customer.ID, + ProviderPaymentID: "cs_health", + AmountCents: 1500, + Currency: "usd", + Status: "paid", + CreatedAt: "2026-05-27T10:00:00Z", + }); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/admin/billing/health", nil) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-billing-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("billing health returned %d: %s", rec.Code, rec.Body.String()) + } + var resp struct { + Health struct { + Configured struct { + PublishableKey bool `json:"publishableKey"` + SecretKey bool `json:"secretKey"` + WebhookSecret bool `json:"webhookSecret"` + } `json:"configured"` + Products struct { + HourPacks []int `json:"hourPacks"` + ProjectQuota bool `json:"projectQuota"` + ProductionProject bool `json:"productionProject"` + } `json:"products"` + Free struct { + Minutes int `json:"minutes"` + WindowHours int `json:"windowHours"` + } `json:"free"` + Issues []string `json:"issues"` + RecentPayments []struct { + UserEmail string `json:"userEmail"` + ProviderPaymentID string `json:"providerPaymentId"` + AmountCents int64 `json:"amountCents"` + Status string `json:"status"` + } `json:"recentPayments"` + } `json:"health"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + if !resp.Health.Configured.PublishableKey || !resp.Health.Configured.SecretKey || !resp.Health.Configured.WebhookSecret { + t.Fatalf("configured=%+v, want all Stripe keys set", resp.Health.Configured) + } + if !reflect.DeepEqual(resp.Health.Products.HourPacks, []int{1}) || !resp.Health.Products.ProjectQuota || !resp.Health.Products.ProductionProject { + t.Fatalf("products=%+v, want 1h pack, project quota, and production project", resp.Health.Products) + } + if resp.Health.Free.Minutes != 30 || resp.Health.Free.WindowHours != 5 { + t.Fatalf("free=%+v, want 30m/5h", resp.Health.Free) + } + if len(resp.Health.Issues) != 0 { + t.Fatalf("issues=%+v, want none", resp.Health.Issues) + } + if len(resp.Health.RecentPayments) != 1 || resp.Health.RecentPayments[0].UserEmail != "payer@example.com" || resp.Health.RecentPayments[0].ProviderPaymentID != "cs_health" { + t.Fatalf("recent payments=%+v, want payer payment", resp.Health.RecentPayments) + } +} + +func TestAdminBillingHealthReturnsEmptyRecentPayments(t *testing.T) { + appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer appStore.Close() + server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient} + admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "") + if err != nil { + t.Fatal(err) + } + if err := appStore.CreateSession(t.Context(), admin.ID, "admin-empty-billing-token", time.Hour); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/admin/billing/health", nil) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-empty-billing-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("billing health returned %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"recentPayments":[]`) { + t.Fatalf("billing health body=%s, want empty recentPayments array", rec.Body.String()) + } +} + +func TestAdminProjectDiagnosticsExposeSupportContext(t *testing.T) { + appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer appStore.Close() + server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient} + admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "") + if err != nil { + t.Fatal(err) + } + user, err := appStore.UpsertUser(t.Context(), "support@example.com", "Support", "") + if err != nil { + t.Fatal(err) + } + if err := appStore.CreateSession(t.Context(), admin.ID, "admin-diagnostics-token", time.Hour); err != nil { + t.Fatal(err) + } + project := &Project{ + ID: "project-diagnostics", + UserID: user.ID, + Title: "Diagnostics", + ConversationID: "conv-diagnostics", + AgentID: "agent-diag", + MarqueeID: "server-diag", + PlaygroundID: "playground-diag", + PlaygroundName: "lk-diag", + PlayspecID: "playspec-diag", + PropID: "prop-diag", + RepoURL: "https://gitea.test/owner/repo.git", + PreviewURL: "https://preview.test", + Status: "ready", + } + if err := appStore.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + if err := appStore.ReplaceProjectResources(t.Context(), project.ID, []store.ProjectRepository{{Role: "source", SourceRepoURL: "https://github.test/source/repo", Provider: "github"}}, []store.ProjectService{{Name: "app", URL: "https://app.test", Type: "dynamic", Visibility: "external"}}); err != nil { + t.Fatal(err) + } + if err := appStore.UpsertProjectDomain(t.Context(), user.ID, project.ID, "app.customer.example", "app.test"); err != nil { + t.Fatal(err) + } + if err := appStore.UpsertPayment(t.Context(), store.Payment{ID: "payment-diag", UserID: user.ID, ProviderPaymentID: "cs_diag", AmountCents: 2000, Currency: "usd", Status: "paid", CreatedAt: "2026-05-27T10:00:00Z"}); err != nil { + t.Fatal(err) + } + if _, err := appStore.GrantHourCredits(t.Context(), user.ID, "cs_diag", 1); err != nil { + t.Fatal(err) + } + startedAt := time.Now().UTC().Add(-10 * time.Minute) + if err := appStore.StartProjectWorkSession(t.Context(), user.ID, project.ID, "turn-diag", startedAt); err != nil { + t.Fatal(err) + } + if _, err := appStore.CompleteAndBillProjectWorkSession(t.Context(), user.ID, project.ID, "turn-diag", startedAt.Add(10*time.Minute), 5, int64(30*time.Minute/time.Millisecond)); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/diagnostics", nil) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-diagnostics-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("diagnostics returned %d: %s", rec.Code, rec.Body.String()) + } + var resp struct { + Diagnostics AdminProjectDiagnostics `json:"diagnostics"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + if resp.Diagnostics.Internal.ConversationID != "conv-diagnostics" || resp.Diagnostics.Internal.PlaygroundID != "playground-diag" || resp.Diagnostics.Internal.RepoURL != "https://gitea.test/owner/repo.git" { + t.Fatalf("internal=%+v, want support ids", resp.Diagnostics.Internal) + } + if len(resp.Diagnostics.Project.Services) != 1 || resp.Diagnostics.Project.Services[0].URL != "https://app.test" { + t.Fatalf("services=%+v, want attached service", resp.Diagnostics.Project.Services) + } + if resp.Diagnostics.Project.CustomDomain != "app.customer.example" || resp.Diagnostics.Project.CustomDomainTarget != "app.test" { + t.Fatalf("project domain=%+v, want custom domain diagnostics", resp.Diagnostics.Project) + } + if len(resp.Diagnostics.WorkSessions) != 1 || resp.Diagnostics.WorkSessions[0].SessionKey != "turn-diag" { + t.Fatalf("work sessions=%+v, want turn", resp.Diagnostics.WorkSessions) + } + if len(resp.Diagnostics.HourLedger) == 0 { + t.Fatalf("hour ledger empty") + } + if len(resp.Diagnostics.Payments) != 1 || resp.Diagnostics.Payments[0].ProviderPaymentID != "cs_diag" { + t.Fatalf("payments=%+v, want checkout payment", resp.Diagnostics.Payments) + } +} + func TestFixedUTCHourWindowAnchorsToMidnight(t *testing.T) { tests := []struct { name string @@ -5741,6 +6277,7 @@ func TestProjectQuotaCheckoutBuildsStripeMetadata(t *testing.T) { if err := store.UpsertConfig(t.Context(), map[string]string{ "stripe_secret_key": "sk_test", "stripe_project_quota_price_id": "price_project_slot", + "project_quota_days": "14", }, secretConfigKeys); err != nil { t.Fatal(err) } @@ -5781,9 +6318,69 @@ func TestProjectQuotaCheckoutBuildsStripeMetadata(t *testing.T) { form.Get("success_url") != "http://example.test/profile?billing=success&session_id={CHECKOUT_SESSION_ID}" || form.Get("metadata[purchase_kind]") != "project_quota" || form.Get("metadata[project_slots]") != "1" || - form.Get("metadata[project_quota_days]") != "30" { + form.Get("metadata[project_quota_days]") != "14" { t.Fatalf("stripe form=%v, want project quota metadata", form) } + assertStripeCheckoutUsesDynamicPaymentMethods(t, form) +} + +func TestProductionProjectCheckoutBuildsStripeMetadata(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err := store.UpsertConfig(t.Context(), map[string]string{ + "stripe_secret_key": "sk_test", + "stripe_production_project_price_id": "price_production_project", + "production_project_days": "45", + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + user, err := store.UpsertUser(t.Context(), "production-buyer@example.com", "Buyer", "") + if err != nil { + t.Fatal(err) + } + if err := store.CreateSession(t.Context(), user.ID, "production-buyer-token", time.Hour); err != nil { + t.Fatal(err) + } + project := &Project{ID: "project-production-checkout", UserID: user.ID, Title: "Production checkout", ConversationID: "conv-production-checkout", Status: "ready"} + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + var form url.Values + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + form, err = url.ParseQuery(string(body)) + if err != nil { + return nil, err + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"url":"https://checkout.stripe.test/session"}`)), + }, nil + })} + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: client} + req := httptest.NewRequest(http.MethodPost, "/api/billing/checkout", strings.NewReader(`{"product":"production_project","projectId":"project-production-checkout"}`)) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "production-buyer-token"}) + rec := httptest.NewRecorder() + + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("checkout returned %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if form.Get("line_items[0][price]") != "price_production_project" || + form.Get("metadata[purchase_kind]") != "production_project" || + form.Get("metadata[project_id]") != project.ID || + form.Get("metadata[production_project_days]") != "45" { + t.Fatalf("stripe form=%v, want production project metadata", form) + } + assertStripeCheckoutUsesDynamicPaymentMethods(t, form) } func TestHourPackCheckoutBuildsStripeMetadata(t *testing.T) { @@ -5837,6 +6434,16 @@ func TestHourPackCheckoutBuildsStripeMetadata(t *testing.T) { form.Get("metadata[pack_hours]") != "10" { t.Fatalf("stripe form=%v, want hour pack metadata", form) } + assertStripeCheckoutUsesDynamicPaymentMethods(t, form) +} + +func assertStripeCheckoutUsesDynamicPaymentMethods(t *testing.T, form url.Values) { + t.Helper() + for key := range form { + if strings.HasPrefix(key, "payment_method_types") { + t.Fatalf("stripe form=%v, want dynamic payment methods so Dashboard-enabled Link can appear", form) + } + } } func TestBillingRefreshAppliesCompletedHourPack(t *testing.T) { @@ -5951,8 +6558,9 @@ func TestStripeWebhookGrantsProjectQuota(t *testing.T) { "payment_status": "paid", "status": "complete", "metadata": map[string]any{ - "purchase_kind": "project_quota", - "project_slots": "1", + "purchase_kind": "project_quota", + "project_slots": "1", + "project_quota_days": "45", }, }}, } @@ -5973,6 +6581,13 @@ func TestStripeWebhookGrantsProjectQuota(t *testing.T) { if slots != 1 || expiresAt == "" { t.Fatalf("slots=%d expires=%q, want active paid project slot", slots, expiresAt) } + expires, err := time.Parse(time.RFC3339Nano, expiresAt) + if err != nil { + t.Fatal(err) + } + if expires.Before(time.Now().UTC().Add(44*24*time.Hour)) || expires.After(time.Now().UTC().Add(46*24*time.Hour)) { + t.Fatalf("expiresAt=%s, want roughly 45 days from now", expiresAt) + } notices, err := store.NoticesForUser(t.Context(), user.ID, 10) if err != nil { t.Fatal(err) @@ -5982,6 +6597,87 @@ func TestStripeWebhookGrantsProjectQuota(t *testing.T) { } } +func TestStripeWebhookGrantsProductionProject(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err := store.UpsertConfig(t.Context(), map[string]string{ + "stripe_secret_key": "sk_test", + "stripe_webhook_secret": "whsec_test", + "stripe_production_project_price_id": "price_production_project", + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + user, err := store.UpsertUser(t.Context(), "production-webhook@example.com", "Buyer", "") + if err != nil { + t.Fatal(err) + } + project := &Project{ID: "project-production-webhook", UserID: user.ID, Title: "Production webhook", ConversationID: "conv-production-webhook", Status: "ready"} + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.Contains(req.URL.Path, "/line_items") { + t.Fatalf("unexpected stripe request %s", req.URL.String()) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"data":[{"price":{"id":"price_production_project"}}]}`)), + }, nil + })} + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: client} + event := map[string]any{ + "type": "checkout.session.completed", + "data": map[string]any{"object": map[string]any{ + "id": "cs_production_project", + "client_reference_id": user.ID, + "amount_total": 2900, + "currency": "usd", + "payment_status": "paid", + "status": "complete", + "metadata": map[string]any{ + "purchase_kind": "production_project", + "project_id": project.ID, + "production_project_days": "45", + }, + }}, + } + payload, _ := json.Marshal(event) + req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", strings.NewReader(string(payload))) + req.Header.Set("Stripe-Signature", testStripeSignature("whsec_test", payload)) + rec := httptest.NewRecorder() + + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("webhook returned %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID) + if err != nil { + t.Fatal(err) + } + if stored.ProductionExpiresAt == "" || stored.PlaygroundIdleStopAt != "" { + t.Fatalf("project=%+v, want active production grant without idle stop", stored) + } + expires, err := time.Parse(time.RFC3339Nano, stored.ProductionExpiresAt) + if err != nil { + t.Fatal(err) + } + if expires.Before(time.Now().UTC().Add(44*24*time.Hour)) || expires.After(time.Now().UTC().Add(46*24*time.Hour)) { + t.Fatalf("production expiresAt=%s, want roughly 45 days from now", stored.ProductionExpiresAt) + } + notices, err := store.NoticesForUser(t.Context(), user.ID, 10) + if err != nil { + t.Fatal(err) + } + if len(notices) == 0 || !strings.Contains(notices[0].Body, "Production project enabled") { + t.Fatalf("notices=%+v, want production project purchase notice", notices) + } +} + func TestStripeWebhookGrantsHourPack(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { diff --git a/internal/likeable/stripe.go b/internal/likeable/stripe.go index 55fe6b7..6274239 100644 --- a/internal/likeable/stripe.go +++ b/internal/likeable/stripe.go @@ -30,12 +30,13 @@ func (s *Server) stripeConfig(r *http.Request) (map[string]string, error) { func stripeConfigFromMap(cfg map[string]string) map[string]string { out := map[string]string{ - "secret": strings.TrimSpace(cfg["stripe_secret_key"]), - "price_1_hour": strings.TrimSpace(cfg["stripe_price_id_1_hour"]), - "price_10_hours": strings.TrimSpace(cfg["stripe_price_id_10_hours"]), - "price_100_hours": strings.TrimSpace(cfg["stripe_price_id_100_hours"]), - "project_quota_price": strings.TrimSpace(cfg["stripe_project_quota_price_id"]), - "webhook": strings.TrimSpace(cfg["stripe_webhook_secret"]), + "secret": strings.TrimSpace(cfg["stripe_secret_key"]), + "price_1_hour": strings.TrimSpace(cfg["stripe_price_id_1_hour"]), + "price_10_hours": strings.TrimSpace(cfg["stripe_price_id_10_hours"]), + "price_100_hours": strings.TrimSpace(cfg["stripe_price_id_100_hours"]), + "project_quota_price": strings.TrimSpace(cfg["stripe_project_quota_price_id"]), + "production_project_price": strings.TrimSpace(cfg["stripe_production_project_price_id"]), + "webhook": strings.TrimSpace(cfg["stripe_webhook_secret"]), } return out } @@ -45,7 +46,10 @@ func (s *Server) billingProducts(ctx context.Context) map[string]any { if err != nil { return emptyBillingProducts() } - return billingProductsFromConfig(stripeConfigFromMap(cfg)) + products := billingProductsFromConfig(stripeConfigFromMap(cfg)) + products["projectQuotaDays"] = s.projectQuotaDays(ctx) + products["productionProjectDays"] = s.productionProjectDays(ctx) + return products } func billingProductsFromConfig(cfg map[string]string) map[string]any { @@ -63,15 +67,20 @@ func billingProductsFromConfig(cfg map[string]string) map[string]any { hourPacks = append(hourPacks, 100) } return map[string]any{ - "hourPacks": hourPacks, - "projectQuota": strings.TrimSpace(cfg["project_quota_price"]) != "", + "hourPacks": hourPacks, + "projectQuota": strings.TrimSpace(cfg["project_quota_price"]) != "", + "productionProject": strings.TrimSpace(cfg["production_project_price"]) != "", + "productionProjectDays": defaultProductionProjectDays, } } func emptyBillingProducts() map[string]any { return map[string]any{ - "hourPacks": []int{}, - "projectQuota": false, + "hourPacks": []int{}, + "projectQuota": false, + "projectQuotaDays": defaultProjectQuotaDays, + "productionProject": false, + "productionProjectDays": defaultProductionProjectDays, } } @@ -87,9 +96,10 @@ func (s *Server) handleBillingCheckout(w http.ResponseWriter, r *http.Request) { return } var body struct { - Pack int `json:"pack"` - Product string `json:"product"` - Slots int `json:"slots"` + Pack int `json:"pack"` + Product string `json:"product"` + Slots int `json:"slots"` + ProjectID string `json:"projectId"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF { writeError(w, http.StatusBadRequest, "invalid json") @@ -100,6 +110,8 @@ func (s *Server) handleBillingCheckout(w http.ResponseWriter, r *http.Request) { slots := 0 priceID := "" switch product { + case "production_project": + priceID, err = stripeProductionProjectPrice(cfg) case "project_quota": slots, priceID, err = stripeProjectQuotaPrice(cfg, body.Slots) default: @@ -116,9 +128,21 @@ func (s *Server) handleBillingCheckout(w http.ResponseWriter, r *http.Request) { form.Set("success_url", s.config.BaseURL+"/profile?billing=success&session_id={CHECKOUT_SESSION_ID}") form.Set("cancel_url", s.config.BaseURL+"/profile?billing=cancel") form.Set("metadata[purchase_kind]", product) - if product == "project_quota" { + if product == "production_project" { + projectID := strings.TrimSpace(body.ProjectID) + if projectID == "" { + writeError(w, http.StatusBadRequest, "project_id is required") + return + } + if _, err := s.store.ProjectForUser(r.Context(), user.ID, projectID); err != nil { + writeError(w, http.StatusNotFound, "project not found") + return + } + form.Set("metadata[project_id]", projectID) + form.Set("metadata[production_project_days]", strconv.Itoa(s.productionProjectDays(r.Context()))) + } else if product == "project_quota" { form.Set("metadata[project_slots]", strconv.Itoa(slots)) - form.Set("metadata[project_quota_days]", "30") + form.Set("metadata[project_quota_days]", strconv.Itoa(s.projectQuotaDays(r.Context()))) } else { form.Set("metadata[pack_hours]", strconv.Itoa(pack)) } @@ -190,6 +214,8 @@ func (s *Server) handleBillingRefresh(w http.ResponseWriter, r *http.Request) { func normalizeStripeProduct(product string) string { switch strings.ToLower(strings.TrimSpace(product)) { + case "production", "production_project", "production-project": + return "production_project" case "project", "projects", "project_quota", "project-quota": return "project_quota" default: @@ -232,6 +258,13 @@ func stripeProjectQuotaPrice(cfg map[string]string, slots int) (int, string, err return slots, cfg["project_quota_price"], nil } +func stripeProductionProjectPrice(cfg map[string]string) (string, error) { + if cfg["production_project_price"] == "" { + return "", fmt.Errorf("Stripe price for production project is not configured") + } + return cfg["production_project_price"], nil +} + func (s *Server) handleStripeWebhook(w http.ResponseWriter, r *http.Request) { body := readAll(r.Body) rawCfg, _ := s.store.ConfigMap(r.Context()) @@ -342,6 +375,24 @@ func (s *Server) applyStripeCheckoutSession(ctx context.Context, cfg map[string] } } } + productionProjectID := stripeProductionProjectID(session["metadata"]) + if productionProjectID != "" { + result.PurchaseKind = "production_project" + if err := s.verifyStripeCheckoutPrice(ctx, cfg, sessionID, cfg["production_project_price"]); err != nil { + return result, err + } + result.Applied = true + if sessionID != "" && sessionID != "" { + days := stripeProductionProjectDays(session["metadata"], s.productionProjectDays(ctx)) + expiresAt := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour) + if granted, err := s.store.GrantProjectProduction(ctx, userID, productionProjectID, sessionID, expiresAt); err == nil && granted { + result.Granted = true + s.notifyProductionProjectPurchased(ctx, userID, productionProjectID, expiresAt) + } else if err != nil { + return result, err + } + } + } projectSlots := stripeProjectSlots(session["metadata"]) if projectSlots > 0 { result.PurchaseKind = "project_quota" @@ -350,7 +401,8 @@ func (s *Server) applyStripeCheckoutSession(ctx context.Context, cfg map[string] } result.Applied = true if sessionID != "" && sessionID != "" { - expiresAt := time.Now().UTC().Add(30 * 24 * time.Hour) + quotaDays := stripeProjectQuotaDays(session["metadata"], s.projectQuotaDays(ctx)) + expiresAt := time.Now().UTC().Add(time.Duration(quotaDays) * 24 * time.Hour) if granted, err := s.store.GrantProjectQuota(ctx, userID, sessionID, projectSlots, expiresAt); err == nil && granted { result.Granted = true s.notifyProjectQuotaPurchased(ctx, userID, projectSlots, expiresAt) @@ -441,6 +493,58 @@ func stripeProjectSlots(metadata any) int { } } +func stripeProjectQuotaDays(metadata any, fallback int) int { + if fallback <= 0 || fallback > maxProjectQuotaDays { + fallback = defaultProjectQuotaDays + } + raw := "" + if m, ok := metadata.(map[string]any); ok { + raw = fmt.Sprint(m["project_quota_days"]) + } + if raw == "" || raw == "" { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return fallback + } + if n > maxProjectQuotaDays { + return maxProjectQuotaDays + } + return n +} + +func stripeProductionProjectID(metadata any) string { + if m, ok := metadata.(map[string]any); ok { + raw := strings.TrimSpace(fmt.Sprint(m["project_id"])) + if raw != "" && raw != "" { + return raw + } + } + return "" +} + +func stripeProductionProjectDays(metadata any, fallback int) int { + if fallback <= 0 || fallback > maxProductionProjectDays { + fallback = defaultProductionProjectDays + } + raw := "" + if m, ok := metadata.(map[string]any); ok { + raw = fmt.Sprint(m["production_project_days"]) + } + if raw == "" || raw == "" { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return fallback + } + if n > maxProductionProjectDays { + return maxProductionProjectDays + } + return n +} + func expectedStripeHourPackPrice(cfg map[string]string, pack int) string { switch pack { case 1: diff --git a/internal/likeable/types.go b/internal/likeable/types.go index 3851708..b7b086c 100644 --- a/internal/likeable/types.go +++ b/internal/likeable/types.go @@ -19,6 +19,7 @@ type AgentPoolOption = domain.AgentPoolOption type UserNotice = domain.UserNotice type AdminUserSummary = domain.AdminUserSummary type AdminProjectSummary = domain.AdminProjectSummary +type AdminProjectDiagnostics = domain.AdminProjectDiagnostics type AdminUserDetail = domain.AdminUserDetail type AdminUserFilters = domain.AdminUserFilters diff --git a/internal/store/store.go b/internal/store/store.go index 3bff2ab..36d1102 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -232,6 +232,27 @@ func (s *Store) migrate(ctx context.Context) error { UNIQUE(payment_id) )`, `CREATE INDEX IF NOT EXISTS idx_project_quota_user_expires ON project_quota_ledger(user_id, expires_at DESC)`, + `CREATE TABLE IF NOT EXISTS project_production_grants ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + payment_id TEXT NOT NULL DEFAULT '', + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(payment_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_project_production_project_expires ON project_production_grants(project_id, expires_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_project_production_user_expires ON project_production_grants(user_id, expires_at DESC)`, + `CREATE TABLE IF NOT EXISTS project_domains ( + project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + domain TEXT NOT NULL UNIQUE, + target TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending_dns', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_project_domains_user_updated ON project_domains(user_id, updated_at DESC)`, `CREATE TABLE IF NOT EXISTS export_jobs ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, diff --git a/internal/store/store_admin.go b/internal/store/store_admin.go index fe6c20f..3e44856 100644 --- a/internal/store/store_admin.go +++ b/internal/store/store_admin.go @@ -367,3 +367,41 @@ func (s *Store) AdminProjectsForUser(ctx context.Context, userID string) ([]Admi } return out, nil } + +func (s *Store) AdminProjectDiagnostics(ctx context.Context, userID, projectID string) (*AdminProjectDiagnostics, error) { + project, err := s.ProjectForUser(ctx, userID, projectID) + if err != nil { + return nil, err + } + workSessions, err := s.ProjectWorkSessions(ctx, userID, projectID, 20) + if err != nil { + return nil, err + } + hourLedger, err := s.UserHourCreditLedger(ctx, userID, 30) + if err != nil { + return nil, err + } + payments, err := s.UserPayments(ctx, userID, 20) + if err != nil { + return nil, err + } + return &AdminProjectDiagnostics{ + Project: *project, + Internal: AdminProjectInternal{ + UserID: project.UserID, + ConversationID: project.ConversationID, + AgentID: project.AgentID, + ServerID: project.MarqueeID, + PlaygroundID: project.PlaygroundID, + PlaygroundName: project.PlaygroundName, + PlayspecID: project.PlayspecID, + PropID: project.PropID, + RepoURL: project.RepoURL, + ProvisioningLockUntil: project.ProvisioningLockUntil, + CleanupLastError: project.CleanupLastError, + }, + WorkSessions: workSessions, + HourLedger: hourLedger, + Payments: payments, + }, nil +} diff --git a/internal/store/store_billing.go b/internal/store/store_billing.go index dd6e693..68e0a2d 100644 --- a/internal/store/store_billing.go +++ b/internal/store/store_billing.go @@ -531,6 +531,43 @@ func (s *Store) ActiveProjectQuota(ctx context.Context, userID string) (int, str return slots, nextExpiresAt, nil } +func (s *Store) GrantProjectProduction(ctx context.Context, userID, projectID, paymentID string, expiresAt time.Time) (bool, error) { + if strings.TrimSpace(userID) == "" || strings.TrimSpace(projectID) == "" { + return false, nil + } + paymentID = strings.TrimSpace(paymentID) + if paymentID == "" { + paymentID = uuid.NewString() + } + if expiresAt.IsZero() { + expiresAt = time.Now().UTC().Add(30 * 24 * time.Hour) + } + result, err := s.db.ExecContext(ctx, ` + INSERT OR IGNORE INTO project_production_grants(id, user_id, project_id, payment_id, expires_at, created_at) + SELECT ?, ?, projects.id, ?, ?, ? + FROM projects + WHERE projects.id = ? AND projects.user_id = ? AND projects.status NOT IN ('archived', 'deleting') + `, uuid.NewString(), userID, paymentID, expiresAt.UTC().Format(time.RFC3339Nano), nowString(), projectID, userID) + if err != nil { + return false, err + } + rows, _ := result.RowsAffected() + return rows > 0, nil +} + +func (s *Store) ActiveProjectProduction(ctx context.Context, userID, projectID string) (string, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT COALESCE(MAX(expires_at), '') + FROM project_production_grants + WHERE user_id = ? AND project_id = ? AND expires_at > ? + `, userID, projectID, nowString()) + var expiresAt string + if err := row.Scan(&expiresAt); err != nil { + return "", err + } + return expiresAt, nil +} + func (s *Store) UpsertSocialConnection(ctx context.Context, conn SocialConnection) error { if conn.ID == "" { conn.ID = uuid.NewString() @@ -620,3 +657,164 @@ func (s *Store) UpsertPayment(ctx context.Context, payment Payment) error { `, payment.ID, payment.UserID, payment.ProviderPaymentID, payment.AmountCents, strings.ToLower(payment.Currency), payment.Status, payment.CreatedAt) return err } + +func (s *Store) RecentPayments(ctx context.Context, limit int) ([]AdminBillingPayment, error) { + if limit <= 0 { + limit = 10 + } + if limit > 100 { + limit = 100 + } + rows, err := s.db.QueryContext(ctx, ` + SELECT payments.id, payments.user_id, COALESCE(users.email, ''), payments.provider_payment_id, + payments.amount_cents, payments.currency, payments.status, payments.created_at + FROM payments + LEFT JOIN users ON users.id = payments.user_id + ORDER BY payments.created_at DESC + LIMIT ? + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminBillingPayment + for rows.Next() { + var payment AdminBillingPayment + if err := rows.Scan( + &payment.ID, + &payment.UserID, + &payment.UserEmail, + &payment.ProviderPaymentID, + &payment.AmountCents, + &payment.Currency, + &payment.Status, + &payment.CreatedAt, + ); err != nil { + return nil, err + } + out = append(out, payment) + } + if err := rows.Err(); err != nil { + return nil, err + } + if out == nil { + out = []AdminBillingPayment{} + } + return out, nil +} + +func (s *Store) UserPayments(ctx context.Context, userID string, limit int) ([]AdminBillingPayment, error) { + if limit <= 0 { + limit = 10 + } + if limit > 100 { + limit = 100 + } + rows, err := s.db.QueryContext(ctx, ` + SELECT payments.id, payments.user_id, COALESCE(users.email, ''), payments.provider_payment_id, + payments.amount_cents, payments.currency, payments.status, payments.created_at + FROM payments + LEFT JOIN users ON users.id = payments.user_id + WHERE payments.user_id = ? + ORDER BY payments.created_at DESC + LIMIT ? + `, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminBillingPayment + for rows.Next() { + var payment AdminBillingPayment + if err := rows.Scan( + &payment.ID, + &payment.UserID, + &payment.UserEmail, + &payment.ProviderPaymentID, + &payment.AmountCents, + &payment.Currency, + &payment.Status, + &payment.CreatedAt, + ); err != nil { + return nil, err + } + out = append(out, payment) + } + if err := rows.Err(); err != nil { + return nil, err + } + if out == nil { + out = []AdminBillingPayment{} + } + return out, nil +} + +func (s *Store) UserHourCreditLedger(ctx context.Context, userID string, limit int) ([]AdminHourCreditLedgerEntry, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + rows, err := s.db.QueryContext(ctx, ` + SELECT id, user_id, delta_ms, reason, payment_id, work_session_key, created_at + FROM hour_credit_ledger + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ? + `, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminHourCreditLedgerEntry + for rows.Next() { + var entry AdminHourCreditLedgerEntry + if err := rows.Scan(&entry.ID, &entry.UserID, &entry.DeltaMs, &entry.Reason, &entry.PaymentID, &entry.WorkSessionKey, &entry.CreatedAt); err != nil { + return nil, err + } + out = append(out, entry) + } + if err := rows.Err(); err != nil { + return nil, err + } + if out == nil { + out = []AdminHourCreditLedgerEntry{} + } + return out, nil +} + +func (s *Store) ProjectWorkSessions(ctx context.Context, userID, projectID string, limit int) ([]ProjectWorkSession, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + rows, err := s.db.QueryContext(ctx, ` + SELECT project_id, user_id, session_key, started_at, completed_at, elapsed_ms, free_billed_ms, paid_billed_ms, billed_at, created_at, updated_at + FROM project_work_sessions + WHERE user_id = ? AND project_id = ? + ORDER BY started_at DESC + LIMIT ? + `, userID, projectID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ProjectWorkSession + for rows.Next() { + var session ProjectWorkSession + if err := rows.Scan(&session.ProjectID, &session.UserID, &session.SessionKey, &session.StartedAt, &session.CompletedAt, &session.ElapsedMs, &session.FreeBilledMs, &session.PaidBilledMs, &session.BilledAt, &session.CreatedAt, &session.UpdatedAt); err != nil { + return nil, err + } + out = append(out, session) + } + if err := rows.Err(); err != nil { + return nil, err + } + if out == nil { + out = []ProjectWorkSession{} + } + return out, nil +} diff --git a/internal/store/store_projects.go b/internal/store/store_projects.go index 19fc858..f798c48 100644 --- a/internal/store/store_projects.go +++ b/internal/store/store_projects.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "net/url" "strings" "time" @@ -252,6 +253,33 @@ func (s *Store) UpdateProjectSelectedService(ctx context.Context, projectID, use return nil } +func (s *Store) UpsertProjectDomain(ctx context.Context, userID, projectID, domain, target string) error { + domain = strings.TrimSpace(strings.ToLower(domain)) + target = strings.TrimSpace(target) + now := nowString() + result, err := s.db.ExecContext(ctx, ` + INSERT INTO project_domains(project_id, user_id, domain, target, status, created_at, updated_at) + VALUES(?, ?, ?, ?, 'pending_dns', ?, ?) + ON CONFLICT(project_id) DO UPDATE SET domain = excluded.domain, target = excluded.target, status = 'pending_dns', updated_at = excluded.updated_at + `, projectID, userID, domain, target, now, now) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *Store) DeleteProjectDomain(ctx context.Context, userID, projectID string) error { + _, err := s.db.ExecContext(ctx, ` + DELETE FROM project_domains + WHERE project_id = ? AND user_id = ? + `, projectID, userID) + return err +} + func (s *Store) TouchProjectPlaygroundUsage(ctx context.Context, projectID, userID string) error { now := nowString() result, err := s.db.ExecContext(ctx, ` @@ -453,9 +481,15 @@ func (s *Store) IdleProjectsForPlaygroundStop(ctx context.Context, cutoff time.T projects.selected_service_name, projects.status, projects.error_message, projects.provisioning_lock_until, projects.cleanup_last_error, projects.playground_last_used_at, projects.created_at, projects.updated_at FROM projects WHERE projects.status = 'ready' AND TRIM(projects.playground_id) != '' AND TRIM(projects.playground_last_used_at) != '' AND projects.playground_last_used_at < ? + AND NOT EXISTS ( + SELECT 1 + FROM project_production_grants + WHERE project_production_grants.project_id = projects.id + AND project_production_grants.expires_at > ? + ) ORDER BY projects.playground_last_used_at ASC LIMIT ? - `, cutoffString, limit) + `, cutoffString, nowString(), limit) if err != nil { return nil, err } @@ -487,17 +521,24 @@ func (s *Store) IdleProjectsForPlaygroundStop(ctx context.Context, cutoff time.T func (s *Store) ProjectIdleForPlaygroundStop(ctx context.Context, projectID string, cutoff time.Time) (bool, string, error) { row := s.db.QueryRowContext(ctx, ` - SELECT projects.status, projects.playground_id, projects.playground_last_used_at + SELECT projects.status, projects.playground_id, projects.playground_last_used_at, + COALESCE((SELECT MAX(project_production_grants.expires_at) + FROM project_production_grants + WHERE project_production_grants.project_id = projects.id + AND project_production_grants.expires_at > ?), '') FROM projects WHERE projects.id = ? - `, projectID) - var status, playgroundID, lastUsedAt string - if err := row.Scan(&status, &playgroundID, &lastUsedAt); err != nil { + `, nowString(), projectID) + var status, playgroundID, lastUsedAt, productionExpiresAt string + if err := row.Scan(&status, &playgroundID, &lastUsedAt, &productionExpiresAt); err != nil { return false, "", err } if status != "ready" || strings.TrimSpace(playgroundID) == "" { return false, "", nil } + if strings.TrimSpace(productionExpiresAt) != "" { + return false, "production project active until " + productionExpiresAt, nil + } lastUsedAt = strings.TrimSpace(lastUsedAt) if lastUsedAt == "" { return false, "missing playground_last_used_at", nil @@ -597,6 +638,38 @@ func (s *Store) attachProjectResources(ctx context.Context, project *Project) er if project.PreviewURL == "" && len(services) > 0 { project.PreviewURL = services[0].URL } + expiresAt, err := s.ActiveProjectProduction(ctx, project.UserID, project.ID) + if err != nil { + return err + } + project.ProductionExpiresAt = expiresAt + if err := s.attachProjectDomain(ctx, project); err != nil { + return err + } + project.RefreshComputedFields() + return nil +} + +func (s *Store) attachProjectDomain(ctx context.Context, project *Project) error { + row := s.db.QueryRowContext(ctx, ` + SELECT domain, target, status, updated_at + FROM project_domains + WHERE project_id = ? AND user_id = ? + `, project.ID, project.UserID) + var domain, target, status, updatedAt string + if err := row.Scan(&domain, &target, &status, &updatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err + } + project.CustomDomain = domain + project.CustomDomainStatus = status + project.CustomDomainTarget = projectDomainTarget(project) + if project.CustomDomainTarget == "" { + project.CustomDomainTarget = target + } + project.CustomDomainUpdatedAt = updatedAt return nil } @@ -609,6 +682,43 @@ func (s *Store) attachProjectResourcesForProjects(ctx context.Context, projects return nil } +func projectDomainTarget(project *Project) string { + if project == nil { + return "" + } + selected := strings.TrimSpace(project.SelectedService) + for _, service := range project.Services { + if selected != "" && service.Name != selected { + continue + } + if target := urlHost(service.URL); target != "" { + return target + } + } + if target := urlHost(project.PreviewURL); target != "" { + return target + } + for _, service := range project.Services { + if target := urlHost(service.URL); target != "" { + return target + } + } + return "" +} + +func urlHost(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" + } + parsed, err := url.Parse(rawURL) + if err == nil && parsed.Host != "" { + return parsed.Host + } + rawURL = strings.TrimPrefix(strings.TrimPrefix(rawURL, "https://"), "http://") + return strings.Split(rawURL, "/")[0] +} + func (s *Store) ProjectRepositories(ctx context.Context, projectID string) ([]ProjectRepository, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, project_id, role, prop_id, repo_url, source_repo_url, provider, service_names, created_at diff --git a/internal/store/types.go b/internal/store/types.go index 698b1b5..ad72bba 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -20,5 +20,9 @@ type AgentPoolOption = domain.AgentPoolOption type UserNotice = domain.UserNotice type AdminUserSummary = domain.AdminUserSummary type AdminProjectSummary = domain.AdminProjectSummary +type AdminBillingPayment = domain.AdminBillingPayment +type AdminHourCreditLedgerEntry = domain.AdminHourCreditLedgerEntry +type AdminProjectInternal = domain.AdminProjectInternal +type AdminProjectDiagnostics = domain.AdminProjectDiagnostics type AdminUserDetail = domain.AdminUserDetail type AdminUserFilters = domain.AdminUserFilters From 24acda0b27952d11c1b127e114c9f8cf0eb450c4 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 14:50:42 +0300 Subject: [PATCH 02/36] Add billing and production controls to UI --- frontend/src/admin.tsx | 256 +++++++++++++++++++++++++--- frontend/src/builder_components.tsx | 88 +++++++++- frontend/src/config.ts | 4 +- frontend/src/domain.ts | 20 ++- frontend/src/i18n/translations.ts | 152 +++++++++++++---- frontend/src/main.tsx | 87 +++++++++- frontend/src/profile_panel.tsx | 3 +- frontend/src/styles.css | 119 +++++++++++++ 8 files changed, 662 insertions(+), 67 deletions(-) diff --git a/frontend/src/admin.tsx b/frontend/src/admin.tsx index 521c809..4c357a3 100644 --- a/frontend/src/admin.tsx +++ b/frontend/src/admin.tsx @@ -4,7 +4,7 @@ import { cleanPoolRows, makePoolRow, parsePoolRows } from './admin_pool'; import { api } from './api'; import { AppDialog, Metric } from './builder_components'; import { ADMIN_CONFIG_SECTIONS } from './config'; -import type { AdminConfigEntry, AdminConfigResponse, AdminProjectSummary, AdminRecoveryResponse, AdminUserDetail, AdminUserSummary, AdminUsersResponse, AgentAssignmentSummary, AgentPoolOption, AgentPoolStat, AppDialogConfig, PoolRow } from './domain'; +import type { AdminBillingHealth, AdminBillingHealthResponse, AdminConfigEntry, AdminConfigResponse, AdminProjectDiagnostics, AdminProjectDiagnosticsResponse, AdminProjectSummary, AdminRecoveryResponse, AdminUserDetail, AdminUserSummary, AdminUsersResponse, AgentAssignmentSummary, AgentPoolOption, AgentPoolStat, AppDialogConfig, PoolRow } from './domain'; import { formatBillingDuration, formatMessageTime, formatShortDate, userInitials } from './format'; import { resetCountdownLabels, statusLabel, TranslationKey, useDocumentTitle, useI18n } from './i18n'; @@ -20,6 +20,9 @@ function AdminCustomersPanel() { const [loading, setLoading] = useState(false); const [actionError, setActionError] = useState(''); const [reassigningProjectID, setReassigningProjectID] = useState(''); + const [diagnosticsProjectID, setDiagnosticsProjectID] = useState(''); + const [diagnostics, setDiagnostics] = useState(null); + const [diagnosticsLoadingID, setDiagnosticsLoadingID] = useState(''); const [accessNote, setAccessNote] = useState(''); const [noticeBody, setNoticeBody] = useState(''); const [noticeSeverity, setNoticeSeverity] = useState('warning'); @@ -223,6 +226,25 @@ function AdminCustomersPanel() { setReassigningProjectID(''); } }; + const loadProjectDiagnostics = async (projectID: string) => { + if (!selectedUserID) return; + if (diagnosticsProjectID === projectID) { + setDiagnosticsProjectID(''); + setDiagnostics(null); + return; + } + setActionError(''); + setDiagnosticsLoadingID(projectID); + try { + const response = await api(`/api/admin/users/${selectedUserID}/projects/${projectID}/diagnostics`); + setDiagnosticsProjectID(projectID); + setDiagnostics(response.diagnostics); + } catch (err) { + setActionError(err instanceof Error ? err.message : t('admin.diagnostics.failed')); + } finally { + setDiagnosticsLoadingID(''); + } + }; return (
@@ -428,32 +450,74 @@ function AdminCustomersPanel() { const assignmentValue = pairValue(assignment?.agentId ?? '', assignment?.serverId ?? ''); const canReassign = options.some((option) => option.status === 'active') && item.project.status !== 'archived' && item.project.status !== 'deleting'; const previewUrl = item.project.previewUrl; + const projectDiagnostics = diagnosticsProjectID === item.project.id ? diagnostics : null; return ( -
- - {item.project.title} - {statusLabel(item.project.status, t)} · {workDuration(item.workMs)} - - -
- {previewUrl && {t('common.open')}} - +
+
+ + {item.project.title} + {statusLabel(item.project.status, t)} · {workDuration(item.workMs)} + + +
+ {previewUrl && {t('common.open')}} + + +
+ {projectDiagnostics && ( +
+
+ {diagnosticEntries(projectDiagnostics).map(([label, value]) => ( + + {label} + {value || '—'} + + ))} +
+
+
+ {t('admin.diagnostics.workSessions')} + {projectDiagnostics.workSessions.slice(0, 4).map((session) => ( + {session.sessionKey} · {workDuration(session.freeBilledMs)}/{workDuration(session.paidBilledMs)} · {formatMessageTime(session.startedAt, locale)} + ))} + {projectDiagnostics.workSessions.length === 0 && {t('admin.diagnostics.empty')}} +
+
+ {t('admin.diagnostics.hourLedger')} + {projectDiagnostics.hourLedger.slice(0, 4).map((entry) => ( + {entry.reason} · {workDuration(entry.deltaMs)} · {entry.paymentId || entry.workSessionKey || '—'} + ))} + {projectDiagnostics.hourLedger.length === 0 && {t('admin.diagnostics.empty')}} +
+
+ {t('admin.billingHealth.recentPayments')} + {projectDiagnostics.payments.slice(0, 4).map((payment) => ( + {formatMoney(payment.amountCents, payment.currency)} · {payment.status} · {payment.providerPaymentId} + ))} + {projectDiagnostics.payments.length === 0 && {t('admin.diagnostics.empty')}} +
+
+
+ )}
); })} @@ -473,6 +537,32 @@ function formatMoney(cents: number, currency: string): string { return `${normalized} ${(cents / 100).toFixed(2)}`; } +function diagnosticEntries(diagnostics: AdminProjectDiagnostics): [string, string][] { + const internal = diagnostics.internal; + const services = diagnostics.project.services ?? []; + const repositories = diagnostics.project.repositories ?? []; + return [ + ['project_id', diagnostics.project.id], + ['conversation_id', internal.conversationId ?? ''], + ['agent_id', internal.agentId ?? ''], + ['server_id', internal.serverId ?? ''], + ['playground_id', internal.playgroundId ?? ''], + ['playground_name', internal.playgroundName ?? ''], + ['playspec_id', internal.playspecId ?? ''], + ['prop_id', internal.propId ?? ''], + ['repo_url', internal.repoUrl ?? ''], + ['selected_service', diagnostics.project.selectedServiceName ?? ''], + ['production_expires_at', diagnostics.project.productionExpiresAt ?? ''], + ['custom_domain', diagnostics.project.customDomain ?? ''], + ['custom_domain_status', diagnostics.project.customDomainStatus ?? ''], + ['custom_domain_target', diagnostics.project.customDomainTarget ?? ''], + ['services', services.map((service) => `${service.name}:${service.url}`).join(' | ')], + ['repositories', repositories.map((repository) => `${repository.role}:${repository.sourceRepoUrl || repository.id}`).join(' | ')], + ['cleanup_error', internal.cleanupLastError ?? ''], + ['provisioning_lock_until', internal.provisioningLockUntil ?? ''] + ]; +} + function pairValue(agentId: string, serverId: string): string { return JSON.stringify([agentId, serverId]); } @@ -534,6 +624,116 @@ function poolStatusLabel(status: string | undefined, t: (key: TranslationKey) => } } +function formatAdminMoney(cents: number, currency: string, locale: string): string { + const code = (currency || 'usd').toUpperCase(); + try { + return new Intl.NumberFormat(locale, { style: 'currency', currency: code }).format((cents || 0) / 100); + } catch { + return `${((cents || 0) / 100).toFixed(2)} ${code}`; + } +} + +function billingIssueLabel(issue: string, t: (key: TranslationKey, params?: Record) => string): string { + const keys: Record = { + stripe_publishable_missing: 'admin.billingHealth.issue.publishable', + stripe_secret_missing: 'admin.billingHealth.issue.secret', + stripe_webhook_missing: 'admin.billingHealth.issue.webhook', + stripe_hour_prices_missing: 'admin.billingHealth.issue.hourPrices', + stripe_project_quota_price_missing: 'admin.billingHealth.issue.projectQuota', + stripe_production_project_price_missing: 'admin.billingHealth.issue.productionProject' + }; + const key = keys[issue]; + return key ? t(key) : t('admin.billingHealth.issue.unknown', { issue }); +} + +function AdminBillingHealthPanel() { + const { locale, t } = useI18n(); + const [health, setHealth] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const load = async () => { + setLoading(true); + setError(''); + try { + const response = await api('/api/admin/billing/health'); + setHealth(response.health); + } catch (err) { + setError(err instanceof Error ? err.message : t('admin.billingHealth.loadFailed')); + } finally { + setLoading(false); + } + }; + useEffect(() => { + void load(); + }, []); + const configuredCount = health ? Object.values(health.configured).filter(Boolean).length : 0; + const hourPacks = health?.products.hourPacks ?? []; + const productsLabel = [ + hourPacks.length > 0 ? t('admin.billingHealth.hourPacks', { packs: hourPacks.map((pack) => `${pack}h`).join(', ') }) : t('admin.billingHealth.noHourPacks'), + health?.products.projectQuota ? t('admin.billingHealth.projectQuotaOn') : t('admin.billingHealth.projectQuotaOff'), + health?.products.productionProject ? t('admin.billingHealth.productionProjectOn') : t('admin.billingHealth.productionProjectOff') + ].join(' · '); + return ( +
+
+
+

{t('admin.billingHealth.title')}

+

{t('admin.billingHealth.body')}

+
+ +
+ {error &&
{error}
} +
+ + + + +
+ {health && ( + <> + {health.issues.length === 0 ? ( +
{t('admin.billingHealth.noIssues')}
+ ) : ( +
+ {health.issues.map((issue) => ( +
+ + {t('admin.billingHealth.issue')} + {billingIssueLabel(issue, t)} + + {issue} +
+ ))} +
+ )} +
+

{t('admin.billingHealth.recentPayments')}

+

{health.checkedAt ? t('admin.billingHealth.checkedAt', { time: formatMessageTime(health.checkedAt, locale) }) : ''}

+
+ {health.recentPayments.length === 0 ? ( +
{t('admin.billingHealth.noPayments')}
+ ) : ( +
+ {health.recentPayments.map((payment) => ( +
+ + {formatAdminMoney(payment.amountCents, payment.currency, locale)} · {payment.status} + {payment.userEmail || payment.userId} · {formatMessageTime(payment.createdAt, locale)} + + {payment.providerPaymentId} +
+ ))} +
+ )} + + )} +
+ ); +} + function adminConfigLabel(key: string, t: (key: TranslationKey) => string): string { const labelKeys: Record = { github_client_id: 'admin.config.github_client_id', @@ -548,11 +748,14 @@ function adminConfigLabel(key: string, t: (key: TranslationKey) => string): stri stripe_price_id_10_hours: 'admin.config.stripe_price_id_10_hours', stripe_price_id_100_hours: 'admin.config.stripe_price_id_100_hours', stripe_project_quota_price_id: 'admin.config.stripe_project_quota_price_id', + stripe_production_project_price_id: 'admin.config.stripe_production_project_price_id', stripe_webhook_secret: 'admin.config.stripe_webhook_secret', - free_hours: 'admin.config.free_hours', + free_minutes: 'admin.config.free_minutes', free_hour_window_hours: 'admin.config.free_hour_window_hours', prompt_improve_charge_minutes: 'admin.config.prompt_improve_charge_minutes', project_cap: 'admin.config.project_cap', + project_quota_days: 'admin.config.project_quota_days', + production_project_days: 'admin.config.production_project_days', agent_artefacts: 'admin.config.agent_artefacts' }; const labelKey = labelKeys[key]; @@ -686,6 +889,7 @@ export function Admin() {
+
diff --git a/frontend/src/builder_components.tsx b/frontend/src/builder_components.tsx index 3f7ab41..dec150f 100644 --- a/frontend/src/builder_components.tsx +++ b/frontend/src/builder_components.tsx @@ -1,14 +1,16 @@ import { useEffect, useState, type MouseEvent, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; -import { Check, ChevronLeft, ChevronRight, CircleAlert, CircleHelp, Download, FileOutput, GitBranch, Loader2, MessageSquare, Minimize2, MoreHorizontal, Paperclip, Pencil, Play, Plus, RotateCcw, Sparkles, Square, Trash2, X } from 'lucide-react'; +import { Check, ChevronLeft, ChevronRight, CircleAlert, CircleHelp, Download, FileOutput, GitBranch, Globe2, Loader2, MessageSquare, Minimize2, MoreHorizontal, Paperclip, Pencil, Play, Plus, RotateCcw, Sparkles, Square, Trash2, X } from 'lucide-react'; import type { AppDialogConfig, MessageAttachment, Project, ProjectService, UserFeedRow } from './domain'; -import { formatElapsedDuration, formatMessageTime } from './format'; +import { formatElapsedDuration, formatMessageTime, formatShortDate } from './format'; import { elapsedDurationLabels, statusLabel, useI18n } from './i18n'; -export function ProjectList({ projects, activeID, projectCap, busy, exportingID, controllingID, onSelect, onNew, onRename, onDelete, onExport, onControlPlayground, onClose }: { projects: Project[]; activeID: string; projectCap: number | null; busy: boolean; exportingID: string; controllingID: string; onSelect: (id: string) => void; onNew: () => void; onRename: (project: Project, title: string) => Promise; onDelete: (project: Project) => void; onExport: (project: Project) => void; onControlPlayground: (project: Project, action: 'start' | 'stop' | 'restart') => Promise; onClose: () => void }) { +export function ProjectList({ projects, activeID, projectCap, busy, exportingID, controllingID, productionCheckoutID, productionPurchasable, domainUpdatingID, onSelect, onNew, onRename, onDelete, onExport, onControlPlayground, onCheckoutProductionProject, onUpdateProjectDomain, onClose }: { projects: Project[]; activeID: string; projectCap: number | null; busy: boolean; exportingID: string; controllingID: string; productionCheckoutID: string; productionPurchasable: boolean; domainUpdatingID: string; onSelect: (id: string) => void; onNew: () => void; onRename: (project: Project, title: string) => Promise; onDelete: (project: Project) => void; onExport: (project: Project) => void; onControlPlayground: (project: Project, action: 'start' | 'stop' | 'restart') => Promise; onCheckoutProductionProject: (project: Project) => Promise; onUpdateProjectDomain: (project: Project, domain: string) => Promise; onClose: () => void }) { const { locale, t } = useI18n(); const [editingID, setEditingID] = useState(''); const [draftTitle, setDraftTitle] = useState(''); + const [domainEditingID, setDomainEditingID] = useState(''); + const [domainDraft, setDomainDraft] = useState(''); const [menuID, setMenuID] = useState(''); const quotaProjectCount = projects.filter((project) => project.status !== 'archived' && project.status !== 'deleting').length; const capReached = projectCap != null && quotaProjectCount >= projectCap; @@ -36,8 +38,29 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID, setMenuID(''); await onControlPlayground(project, action); }; + const checkoutProductionProject = async (project: Project) => { + setMenuID(''); + await onCheckoutProductionProject(project); + }; + const startDomainEdit = (project: Project) => { + setMenuID(''); + setDomainEditingID(project.id); + setDomainDraft(project.customDomain ?? ''); + }; + const cancelDomainEdit = () => { + setDomainEditingID(''); + setDomainDraft(''); + }; + const saveDomain = async (project: Project) => { + await onUpdateProjectDomain(project, domainDraft.trim()); + cancelDomainEdit(); + }; + const removeDomain = async (project: Project) => { + setMenuID(''); + await onUpdateProjectDomain(project, ''); + }; const canStartPlayground = (project: Project) => project.status === 'stopped'; - const canStopPlayground = (project: Project) => project.status === 'ready'; + const canStopPlayground = (project: Project) => project.status === 'ready' && !project.productionExpiresAt; const canRestartPlayground = (project: Project) => project.status === 'ready'; const projectRuntimeState = (project: Project) => { if (controllingID === project.id) return t('projects.actions.working'); @@ -65,6 +88,9 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID, : t('projects.rowMeta.servicesOnly', { services: serviceCount }); }; const projectRowDetail = (project: Project) => { + if (project.productionExpiresAt) { + return t('projects.production.until', { date: formatShortDate(project.productionExpiresAt, locale) }); + } switch (project.status) { case 'creating': case 'launching': @@ -79,6 +105,15 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID, return ''; } }; + const projectDomainTarget = (project: Project) => { + const url = project.services?.find((service) => service.name === (project.selectedServiceName || 'app'))?.url || project.previewUrl || project.services?.[0]?.url || ''; + if (!url) return ''; + try { + return new URL(url).host; + } catch { + return url.replace(/^https?:\/\//, '').split('/')[0] ?? ''; + } + }; return (
@@ -92,9 +127,12 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID,
{projects.map((project, index) => { const detail = projectRowDetail(project); + const cnameTarget = project.customDomainTarget || projectDomainTarget(project); + const domainEditing = domainEditingID === project.id; + const domainSaving = domainUpdatingID === project.id; const menuOpensUp = index > 0; return ( -
+
{editingID === project.id ? (
{ event.preventDefault(); void saveTitle(project); }}> {project.title} {project.id === activeID && {t('service.current')}} + {project.productionExpiresAt && {t('projects.production.badge')}} {projectRowMeta(project)} {detail && {detail}} + {project.customDomain && {t('projects.production.domain', { domain: project.customDomain })} · {t('projects.production.domainPending')}} + {project.productionExpiresAt && cnameTarget && {t('projects.production.cname', { host: cnameTarget })}} {statusLabel(project.status, t)}
@@ -161,10 +202,47 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID, + + + {project.customDomain && ( + + )}
)}
+ {domainEditing && ( + { event.preventDefault(); void saveDomain(project); }}> + + setDomainDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + cancelDomainEdit(); + } + }} + /> + + + + )} )}
diff --git a/frontend/src/config.ts b/frontend/src/config.ts index a1b8ada..296110b 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -25,11 +25,11 @@ export const ADMIN_CONFIG_SECTIONS = [ { titleKey: 'admin.config.stripe.title', bodyKey: 'admin.config.stripe.body', - keys: ['stripe_publishable_key', 'stripe_secret_key', 'stripe_price_id_1_hour', 'stripe_price_id_10_hours', 'stripe_price_id_100_hours', 'stripe_project_quota_price_id', 'stripe_webhook_secret'] + keys: ['stripe_publishable_key', 'stripe_secret_key', 'stripe_price_id_1_hour', 'stripe_price_id_10_hours', 'stripe_price_id_100_hours', 'stripe_project_quota_price_id', 'stripe_production_project_price_id', 'stripe_webhook_secret'] }, { titleKey: 'admin.config.application.title', bodyKey: 'admin.config.application.body', - keys: ['fibe_template_version_id', 'free_hours', 'free_hour_window_hours', 'prompt_improve_charge_minutes', 'project_cap', 'agent_artefacts', 'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'smtp_from_email', 'smtp_from_name', 'smtp_tls_mode'] + keys: ['fibe_template_version_id', 'free_minutes', 'free_hour_window_hours', 'prompt_improve_charge_minutes', 'project_cap', 'project_quota_days', 'production_project_days', 'agent_artefacts', 'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'smtp_from_email', 'smtp_from_name', 'smtp_tls_mode'] } ]; diff --git a/frontend/src/domain.ts b/frontend/src/domain.ts index 2ad08be..dc6f341 100644 --- a/frontend/src/domain.ts +++ b/frontend/src/domain.ts @@ -1,10 +1,10 @@ export type User = { id: string; email: string; name: string; avatarUrl: string; accessStatus?: string; accessNote?: string }; export type ProjectRepository = { id: string; role: string; sourceRepoUrl?: string; provider?: string; serviceNames?: string[]; createdAt?: string }; export type ProjectService = { id: string; name: string; url: string; type?: string; visibility?: string; authRequired?: boolean; createdAt?: string }; -export type Project = { id: string; title: string; previewUrl?: string; selectedServiceName?: string; repositories?: ProjectRepository[]; services?: ProjectService[]; status: string; errorMessage?: string; playgroundLastUsedAt?: string; playgroundIdleStopAt?: string; createdAt: string; updatedAt: string }; +export type Project = { id: string; title: string; previewUrl?: string; selectedServiceName?: string; repositories?: ProjectRepository[]; services?: ProjectService[]; status: string; errorMessage?: string; playgroundLastUsedAt?: string; playgroundIdleStopAt?: string; productionExpiresAt?: string; customDomain?: string; customDomainStatus?: string; customDomainTarget?: string; customDomainUpdatedAt?: string; createdAt: string; updatedAt: string }; export type HourQuota = { usedMs: number; limitMs: number; remainingMs: number; paidRemainingMs?: number; lifetimeUsedMs?: number; resetsAt?: string; windowHours?: number }; export type ProjectQuota = { used: number; limit: number; remaining: number; baseLimit: number; paidSlots: number; nextExpiresAt?: string }; -export type BillingProducts = { hourPacks: number[]; projectQuota: boolean }; +export type BillingProducts = { hourPacks: number[]; projectQuota: boolean; projectQuotaDays?: number; productionProject?: boolean; productionProjectDays?: number }; export type UserNotice = { id: string; userId?: string; sender: 'admin' | 'system' | 'user'; severity: string; body: string; readAt?: string; dismissedAt?: string; unsentAt?: string; createdAt: string }; export type ProjectArchive = { id: string; projectId: string; projectTitle: string; downloadUrl?: string; githubRepoUrl?: string; status: string; error?: string; expiresAt: string; createdAt: string }; export type Me = { user: User | null; isAdmin?: boolean; githubConnected?: boolean; githubNeedsReconnect?: boolean; hourQuota?: HourQuota; projectQuota?: ProjectQuota; billingProducts?: BillingProducts; notices?: UserNotice[]; auth?: { googleConfigured: boolean; devAuth: boolean; devEmail?: string } }; @@ -64,6 +64,22 @@ export type AdminUserSummary = { export type AdminProjectSummary = { project: Project; workMs: number; assignment?: AgentAssignmentSummary }; export type AdminUserDetail = { summary: AdminUserSummary; projects: AdminProjectSummary[]; notices: UserNotice[]; agentPool?: AgentPoolOption[] }; export type AdminUsersResponse = { users: AdminUserSummary[]; agentPool?: AgentPoolOption[]; pagination: { page: number; perPage: number; total: number } }; +export type AdminBillingPayment = { id: string; userId: string; userEmail: string; providerPaymentId: string; amountCents: number; currency: string; status: string; createdAt: string }; +export type AdminProjectInternal = { userId: string; conversationId?: string; agentId?: string; serverId?: string; playgroundId?: string; playgroundName?: string; playspecId?: string; propId?: string; repoUrl?: string; provisioningLockUntil?: string; cleanupLastError?: string }; +export type AdminProjectWorkSession = { projectId: string; userId: string; sessionKey: string; startedAt: string; completedAt?: string; elapsedMs: number; freeBilledMs: number; paidBilledMs: number; billedAt?: string; createdAt: string; updatedAt: string }; +export type AdminHourCreditLedgerEntry = { id: string; userId: string; deltaMs: number; reason: string; paymentId?: string; workSessionKey?: string; createdAt: string }; +export type AdminProjectDiagnostics = { project: Project; internal: AdminProjectInternal; workSessions: AdminProjectWorkSession[]; hourLedger: AdminHourCreditLedgerEntry[]; payments: AdminBillingPayment[] }; +export type AdminProjectDiagnosticsResponse = { diagnostics: AdminProjectDiagnostics }; +export type AdminBillingHealth = { + checkedAt: string; + configured: { publishableKey: boolean; secretKey: boolean; webhookSecret: boolean }; + products: BillingProducts; + prices: { oneHour: boolean; tenHours: boolean; hundredHours: boolean; projectQuota: boolean; productionProject?: boolean }; + free: { minutes: number; windowHours: number }; + issues: string[]; + recentPayments: AdminBillingPayment[]; +}; +export type AdminBillingHealthResponse = { health: AdminBillingHealth }; export type AppDialogConfig = { title: string; body: string; diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index 32d5956..7252144 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -109,8 +109,8 @@ const en = { 'builder.stopAgent': 'Stop agent', 'builder.busy.queue': 'Queue new messages', 'builder.busy.steer': 'Steer this run', - 'builder.hours.left': 'Build hours left', - 'builder.hourQuota.tooltip': '{paid} paid hours · resets in {reset}', + 'builder.hours.left': 'Build time left', + 'builder.hourQuota.tooltip': '{paid} paid time · resets in {reset}', 'builder.profile.tooltip': 'Profile', 'builder.admin.tooltip': 'Admin', 'builder.chat.collapse': 'Collapse chat', @@ -139,7 +139,7 @@ Describe the app you want in the composer. Fibe builds the first playground in a ## Limits and lifecycle -- Free build hours reset on a window. Paid hours roll over and are used after free time. +- Free build time resets on a window. Paid time rolls over and is used after free time. - Playgrounds auto-pause when inactive. Start them again from the menu. - Archived playgrounds stay downloadable for a while after archiving. - Delete a playground or the whole account from Profile — everything goes with it. @@ -159,7 +159,7 @@ Likeable lets you build and run application playgrounds from prompts. By signing **Playgrounds and secrets.** Playgrounds may run generated code, install dependencies, call external services, and store files needed for the app. Do not include secrets, credentials, regulated data, or private third-party data unless you intend that data to be processed in the playground and by the build agent. -**Billing.** Free and paid build hours, project slots, and reset windows are shown in the app. Payments are processed by Stripe. Paid features are delivered according to the limits shown at purchase; future prices and limits may change prospectively. +**Billing.** Free and paid build time, project slots, and reset windows are shown in the app. Payments are processed by Stripe. Paid features are delivered according to the limits shown at purchase; future prices and limits may change prospectively. **Service availability.** Likeable is provided on a best-effort basis. Playgrounds may be queued, paused, restarted, archived, deleted, or temporarily unavailable for maintenance, security, capacity, or abuse-prevention reasons. Uninterrupted service is not guaranteed. @@ -183,7 +183,7 @@ Last updated: May 25, 2026 - Account info from Google when you sign in (name, email, profile picture). - Prompts, chat messages, attached files, generated source code, playground state, preview URLs, export history, and support messages. - OAuth connection metadata for services you connect, such as GitHub export. Access tokens are stored only as needed to provide the connected feature. -- Billing events when you buy build hours or slots. Stripe handles payment details; Likeable does not store full card numbers. +- Billing events when you buy build time or slots. Stripe handles payment details; Likeable does not store full card numbers. - Usage, device, and security data such as IP address, browser, timestamps, request logs, quota usage, errors, and abuse-prevention signals. **How we use it** @@ -264,12 +264,13 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'builder.composerHint.agentQueue': 'Agent running · message will queue', 'builder.composerHint.agentSteer': 'Agent running · message will steer', 'builder.composerHint.improvingPrompt': 'Improving prompt...', + 'builder.composerHint.wakingToSend': 'Starting playground · message will send', 'builder.composerHint.signIn': 'Sign in to send prompts', 'builder.composerHint.loadingProjects': 'Loading playgrounds', 'builder.composerHint.noProject': 'Create a playground first', 'builder.composerHint.starting': 'Workspace is provisioning', 'builder.composerHint.error': 'Resolve workspace error first', - 'builder.composerHint.stopped': 'Start playground to send', + 'builder.composerHint.stopped': 'Send to start playground', 'builder.composerHint.archived': 'Archived playground', 'builder.preview.startingTitle': 'Starting playground', 'builder.preview.preparingTitle': 'Preparing playground', @@ -309,8 +310,8 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'onboarding.slide.modesBody': 'Use the mode button for split or basic view. Collapse chat when you only need the canvas.', 'onboarding.slide.modesBulletOne': 'Basic overlays chat on the playground and can shrink to a small bubble.', 'onboarding.slide.modesBulletTwo': 'Split keeps chat and preview side by side on wide screens.', - 'onboarding.slide.messagesTitle': 'Free build hours', - 'onboarding.slide.messagesBody': 'Free build hours reset on a window. Paid hours are used after free time.', + 'onboarding.slide.messagesTitle': 'Free build time', + 'onboarding.slide.messagesBody': 'Free build time resets on a window. Paid time is used after free time.', 'onboarding.slide.messagesBulletOne': 'The composer badge shows remaining free build time.', 'onboarding.slide.messagesBulletTwo': 'Profile shows resets, lifetime use, and hour packs.', 'onboarding.slide.messagesBulletThree': 'Improve Prompt runs a separate improvement pass, so it can take a moment and may use build minutes when charging is enabled.', @@ -357,6 +358,17 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'projects.rowDetail.error': 'Workspace needs attention before it can run.', 'projects.rowDetail.stopped': 'Preview is paused; start it from actions.', 'projects.rowDetail.archived': 'Archived project. Export or create a new playground.', + 'projects.production.badge': 'Production', + 'projects.production.until': 'Always-on until {date}', + 'projects.production.cname': 'Point CNAME to {host}', + 'projects.production.domain': 'Custom domain: {domain}', + 'projects.production.domainPending': 'DNS pending', + 'projects.production.domainAction': 'Custom domain', + 'projects.production.domainRemove': 'Remove custom domain', + 'projects.production.domainPlaceholder': 'app.example.com', + 'projects.production.domainSave': 'Save domain', + 'projects.production.buy': 'Make production', + 'projects.production.unavailable': 'Production package unavailable', 'projects.rename.aria': 'Rename {title}', 'projects.delete.aria': 'Delete {title}', 'projects.export.aria': 'Export {title}', @@ -443,18 +455,18 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'profile.tutorialTitle': 'Open onboarding', 'profile.tutorialBody': 'Revisit the compact guide for prompts, build minutes, exports, and lifecycle.', 'profile.tutorialOpen': 'Open', - 'profile.hours': 'Build hours', - 'profile.freeQuota': 'Free build-hour quota', - 'profile.freeInWindow': '{remaining}/{limit} free in {hours}h', + 'profile.hours': 'Build time', + 'profile.freeQuota': 'Free build-time quota', + 'profile.freeInWindow': '{remaining}/{limit} free per {hours}h window', 'profile.quotaReadout.limit': 'of {limit}', - 'profile.quotaDetail': '{paid} paid hours · resets in {reset} · {lifetime} lifetime', + 'profile.quotaDetail': '{paid} paid time · resets in {reset} · {lifetime} lifetime', 'profile.paidPacksUnavailable': 'Paid packs off', 'profile.projects': 'Playgrounds', 'profile.projectQuota': 'Playground quota', 'profile.projectSlots': '{used}/{limit} playground slots', 'profile.projectSlotsAvailable': '{remaining} slots available', 'profile.projectSlotsFull': 'Project limit reached', - 'profile.projectSlotDetail': '{paid} paid monthly slots{reset}', + 'profile.projectSlotDetail': '{paid} paid slots · new slots last {days}d{reset}', 'profile.projectSlotFullDetail': 'Delete or export an older playground before creating another.', 'profile.addSlot': '+1 slot', 'profile.projectSlotsUnavailable': 'Slot packs off', @@ -478,6 +490,32 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'admin.search.placeholder': 'email, name, or user id', 'admin.access': 'Access', 'admin.billing': 'Billing', + 'admin.billingHealth.title': 'Billing health', + 'admin.billingHealth.body': 'Stripe configuration, available products, free allowance, and recent payment ledger entries.', + 'admin.billingHealth.refresh': 'Refresh', + 'admin.billingHealth.loadFailed': 'Could not load billing health', + 'admin.billingHealth.stripe': 'Stripe keys', + 'admin.billingHealth.products': 'Products', + 'admin.billingHealth.freeWindow': 'Free window', + 'admin.billingHealth.payments': 'Payments', + 'admin.billingHealth.hourPacks': 'hour packs: {packs}', + 'admin.billingHealth.noHourPacks': 'no hour packs', + 'admin.billingHealth.projectQuotaOn': 'project slots on', + 'admin.billingHealth.projectQuotaOff': 'project slots off', + 'admin.billingHealth.productionProjectOn': 'production projects on', + 'admin.billingHealth.productionProjectOff': 'production projects off', + 'admin.billingHealth.noIssues': 'Billing configuration has no obvious launch blockers.', + 'admin.billingHealth.issue': 'Issue', + 'admin.billingHealth.issue.publishable': 'Stripe publishable key is missing.', + 'admin.billingHealth.issue.secret': 'Stripe secret key is missing; checkout products stay disabled.', + 'admin.billingHealth.issue.webhook': 'Stripe webhook secret is missing; async payment delivery cannot be verified.', + 'admin.billingHealth.issue.hourPrices': 'No hour-pack Stripe price IDs are configured.', + 'admin.billingHealth.issue.projectQuota': 'Project-slot Stripe price ID is missing.', + 'admin.billingHealth.issue.productionProject': 'Production-project Stripe price ID is missing.', + 'admin.billingHealth.issue.unknown': 'Unknown billing issue: {issue}', + 'admin.billingHealth.recentPayments': 'Recent payments', + 'admin.billingHealth.checkedAt': 'Checked at {time}', + 'admin.billingHealth.noPayments': 'No payment records yet.', 'admin.sort': 'Sort', 'admin.sort.newest': 'newest', 'admin.sort.hours': 'hours', @@ -530,6 +568,12 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'admin.assignment': 'Agent pair', 'admin.assignment.none': 'No pair set', 'admin.assignment.failed': 'Could not update pair', + 'admin.diagnostics.show': 'Diagnostics', + 'admin.diagnostics.hide': 'Hide diagnostics', + 'admin.diagnostics.failed': 'Could not load project diagnostics', + 'admin.diagnostics.workSessions': 'Work sessions', + 'admin.diagnostics.hourLedger': 'Hour ledger', + 'admin.diagnostics.empty': 'No records.', 'admin.noActiveProjects': 'No active playgrounds.', 'admin.deleteProject.aria': 'Delete {title}', 'admin.loadUsersFailed': 'Could not load users', @@ -614,11 +658,14 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'admin.config.stripe_price_id_10_hours': '10 hours price ID', 'admin.config.stripe_price_id_100_hours': '100 hours price ID', 'admin.config.stripe_project_quota_price_id': 'Playground quota price ID', + 'admin.config.stripe_production_project_price_id': 'Production project price ID', 'admin.config.stripe_webhook_secret': 'Webhook secret', - 'admin.config.free_hours': 'Free hours per window', - 'admin.config.free_hour_window_hours': 'Free-hour window hours', + 'admin.config.free_minutes': 'Free minutes per window', + 'admin.config.free_hour_window_hours': 'Free window hours', 'admin.config.prompt_improve_charge_minutes': 'Improve prompt charge minutes', 'admin.config.project_cap': 'Base playground cap', + 'admin.config.project_quota_days': 'Paid playground slot days', + 'admin.config.production_project_days': 'Production project days', 'admin.config.agent_artefacts': 'Agent artefacts JSON' } as const; @@ -733,8 +780,8 @@ const uk: Record = { 'builder.stopAgent': 'Зупинити агента', 'builder.busy.queue': 'Додати в чергу', 'builder.busy.steer': 'Скоригувати запуск', - 'builder.hours.left': 'Доступні години збірки', - 'builder.hourQuota.tooltip': '{paid} платних годин · оновлення через {reset}', + 'builder.hours.left': 'Доступний час збірки', + 'builder.hourQuota.tooltip': '{paid} платного часу · оновлення через {reset}', 'builder.profile.tooltip': 'Профіль', 'builder.admin.tooltip': 'Адмін', 'builder.chat.collapse': 'Згорнути чат', @@ -763,7 +810,7 @@ const uk: Record = { ## Ліміти та життєвий цикл -- Безплатні години збірки оновлюються кожне вікно. Платні години переходять на наступний період і використовуються після безплатного часу. +- Безплатний час збірки оновлюється кожне вікно. Платний час переходить на наступний період і використовується після безплатного часу. - Майданчики автоматично призупиняються в стані спокою. Запустити знову — з меню. - Архівовані майданчики можна завантажити обмежений час після архівування. - Видалення майданчика чи всього акаунта — у Профілі. Усе зникає разом. @@ -783,7 +830,7 @@ Likeable дає змогу створювати й запускати майда **Майданчики та секрети.** Майданчики можуть запускати згенерований код, встановлювати залежності, звертатися до зовнішніх сервісів і зберігати файли, потрібні застосунку. Не додавайте секрети, облікові дані, регульовані дані або приватні дані третіх осіб, якщо ви не хочете, щоб ці дані оброблялися в майданчику та агентом збірки. -**Білінг.** Безплатні й платні години збірки, слоти проєктів і вікна оновлення показані в застосунку. Платежі обробляє Stripe. Платні функції надаються в межах, показаних під час купівлі; майбутні ціни й ліміти можуть змінюватися для наступних покупок. +**Білінг.** Безплатний і платний час збірки, слоти проєктів і вікна оновлення показані в застосунку. Платежі обробляє Stripe. Платні функції надаються в межах, показаних під час купівлі; майбутні ціни й ліміти можуть змінюватися для наступних покупок. **Доступність сервісу.** Likeable надається за принципом найкращих зусиль. Майданчики можуть ставати в чергу, призупинятися, перезапускатися, архівуватися, видалятися або бути тимчасово недоступні через обслуговування, безпеку, навантаження чи запобігання зловживанням. Безперервна робота не гарантується. @@ -888,12 +935,13 @@ Likeable застосовує розумні технічні та органі 'builder.composerHint.agentQueue': 'Агент працює · повідомлення стане в чергу', 'builder.composerHint.agentSteer': 'Агент працює · повідомлення скерує хід', 'builder.composerHint.improvingPrompt': 'Покращення запиту...', + 'builder.composerHint.wakingToSend': 'Запуск майданчика · повідомлення буде надіслано', 'builder.composerHint.signIn': 'Увійдіть, щоб надсилати запити', 'builder.composerHint.loadingProjects': 'Завантаження майданчиків', 'builder.composerHint.noProject': 'Спочатку створіть майданчик', 'builder.composerHint.starting': 'Provisioning робочого простору', 'builder.composerHint.error': 'Спочатку усуньте помилку workspace', - 'builder.composerHint.stopped': 'Запустіть майданчик, щоб надсилати', + 'builder.composerHint.stopped': 'Надішліть, щоб запустити майданчик', 'builder.composerHint.archived': 'Майданчик архівовано', 'builder.preview.startingTitle': 'Запуск майданчика', 'builder.preview.preparingTitle': 'Підготовка майданчика', @@ -933,8 +981,8 @@ Likeable застосовує розумні технічні та органі 'onboarding.slide.modesBody': 'Кнопка режиму перемикає split або basic. Згортайте чат, коли потрібне лише полотно.', 'onboarding.slide.modesBulletOne': 'Basic накладає чат на майданчик і може згортатися в маленьку кнопку.', 'onboarding.slide.modesBulletTwo': 'Split тримає чат і превʼю поруч на широких екранах.', - 'onboarding.slide.messagesTitle': 'Безплатні години збірки', - 'onboarding.slide.messagesBody': 'Безплатні години збірки оновлюються за вікном. Платні години йдуть після безплатного часу.', + 'onboarding.slide.messagesTitle': 'Безплатний час збірки', + 'onboarding.slide.messagesBody': 'Безплатний час збірки оновлюється за вікном. Платний час іде після безплатного часу.', 'onboarding.slide.messagesBulletOne': 'Бейдж у полі вводу показує залишок безплатного часу.', 'onboarding.slide.messagesBulletTwo': 'Профіль показує оновлення, загальне використання й пакети годин.', 'onboarding.slide.messagesBulletThree': 'Improve Prompt запускає окреме покращення, тому може зайняти трохи часу й використовувати хвилини збірки, якщо списання увімкнене.', @@ -981,6 +1029,17 @@ Likeable застосовує розумні технічні та органі 'projects.rowDetail.error': 'Робочий простір потребує уваги перед запуском.', 'projects.rowDetail.stopped': 'Превʼю призупинено; запустіть його з дій.', 'projects.rowDetail.archived': 'Архівний проєкт. Експортуйте або створіть новий майданчик.', + 'projects.production.badge': 'Production', + 'projects.production.until': 'Always-on до {date}', + 'projects.production.cname': 'Спрямуйте CNAME на {host}', + 'projects.production.domain': 'Кастомний домен: {domain}', + 'projects.production.domainPending': 'DNS очікує', + 'projects.production.domainAction': 'Кастомний домен', + 'projects.production.domainRemove': 'Прибрати кастомний домен', + 'projects.production.domainPlaceholder': 'app.example.com', + 'projects.production.domainSave': 'Зберегти домен', + 'projects.production.buy': 'Зробити production', + 'projects.production.unavailable': 'Production пакет вимкнено', 'projects.rename.aria': 'Перейменувати {title}', 'projects.delete.aria': 'Видалити {title}', 'projects.export.aria': 'Експортувати {title}', @@ -1067,18 +1126,18 @@ Likeable застосовує розумні технічні та органі 'profile.tutorialTitle': 'Відкрити онбординг', 'profile.tutorialBody': 'Короткий гайд щодо запитів, хвилин збірки, експорту й життєвого циклу.', 'profile.tutorialOpen': 'Відкрити', - 'profile.hours': 'Години збірки', - 'profile.freeQuota': 'Безплатний ліміт годин збірки', - 'profile.freeInWindow': '{remaining}/{limit} безплатно за {hours} год', + 'profile.hours': 'Час збірки', + 'profile.freeQuota': 'Безплатний ліміт часу збірки', + 'profile.freeInWindow': '{remaining}/{limit} безплатно за вікно {hours} год', 'profile.quotaReadout.limit': 'з {limit}', - 'profile.quotaDetail': '{paid} платних годин · оновлення через {reset} · {lifetime} за весь час', + 'profile.quotaDetail': '{paid} платного часу · оновлення через {reset} · {lifetime} за весь час', 'profile.paidPacksUnavailable': 'Платні пакети вимкнено', 'profile.projects': 'Майданчики', 'profile.projectQuota': 'Ліміт майданчиків', 'profile.projectSlots': '{used}/{limit} місць для майданчиків', 'profile.projectSlotsAvailable': 'Доступно місць: {remaining}', 'profile.projectSlotsFull': 'Ліміт проєктів досягнуто', - 'profile.projectSlotDetail': '{paid} платних місячних місць{reset}', + 'profile.projectSlotDetail': '{paid} платних місць · нові місця діють {days} дн{reset}', 'profile.projectSlotFullDetail': 'Видаліть або експортуйте старіший майданчик, перш ніж створювати новий.', 'profile.addSlot': '+1 місце', 'profile.projectSlotsUnavailable': 'Пакети місць вимкнено', @@ -1102,6 +1161,32 @@ Likeable застосовує розумні технічні та органі 'admin.search.placeholder': 'email, імʼя або id користувача', 'admin.access': 'Доступ', 'admin.billing': 'Оплата', + 'admin.billingHealth.title': 'Стан білінгу', + 'admin.billingHealth.body': 'Конфігурація Stripe, доступні продукти, безплатний ліміт і останні записи payment ledger.', + 'admin.billingHealth.refresh': 'Оновити', + 'admin.billingHealth.loadFailed': 'Не вдалося завантажити стан білінгу', + 'admin.billingHealth.stripe': 'Ключі Stripe', + 'admin.billingHealth.products': 'Продукти', + 'admin.billingHealth.freeWindow': 'Безплатне вікно', + 'admin.billingHealth.payments': 'Платежі', + 'admin.billingHealth.hourPacks': 'пакети годин: {packs}', + 'admin.billingHealth.noHourPacks': 'немає пакетів годин', + 'admin.billingHealth.projectQuotaOn': 'місця проєктів увімкнено', + 'admin.billingHealth.projectQuotaOff': 'місця проєктів вимкнено', + 'admin.billingHealth.productionProjectOn': 'production проєкти увімкнено', + 'admin.billingHealth.productionProjectOff': 'production проєкти вимкнено', + 'admin.billingHealth.noIssues': 'У конфігурації білінгу немає очевидних блокерів запуску.', + 'admin.billingHealth.issue': 'Проблема', + 'admin.billingHealth.issue.publishable': 'Немає Stripe publishable key.', + 'admin.billingHealth.issue.secret': 'Немає Stripe secret key; checkout продукти вимкнені.', + 'admin.billingHealth.issue.webhook': 'Немає Stripe webhook secret; async доставку платежів не можна перевірити.', + 'admin.billingHealth.issue.hourPrices': 'Не налаштовано Stripe price ID для пакетів годин.', + 'admin.billingHealth.issue.projectQuota': 'Немає Stripe price ID для місць проєктів.', + 'admin.billingHealth.issue.productionProject': 'Немає Stripe price ID для production проєктів.', + 'admin.billingHealth.issue.unknown': 'Невідома проблема білінгу: {issue}', + 'admin.billingHealth.recentPayments': 'Останні платежі', + 'admin.billingHealth.checkedAt': 'Перевірено о {time}', + 'admin.billingHealth.noPayments': 'Записів платежів ще немає.', 'admin.sort': 'Сортування', 'admin.sort.newest': 'новіші', 'admin.sort.hours': 'години', @@ -1154,6 +1239,12 @@ Likeable застосовує розумні технічні та органі 'admin.assignment': 'Пара агента', 'admin.assignment.none': 'Пара не задана', 'admin.assignment.failed': 'Не вдалося оновити пару', + 'admin.diagnostics.show': 'Діагностика', + 'admin.diagnostics.hide': 'Сховати діагностику', + 'admin.diagnostics.failed': 'Не вдалося завантажити діагностику проєкту', + 'admin.diagnostics.workSessions': 'Work sessions', + 'admin.diagnostics.hourLedger': 'Hour ledger', + 'admin.diagnostics.empty': 'Записів немає.', 'admin.noActiveProjects': 'Активних майданчиків немає.', 'admin.deleteProject.aria': 'Видалити {title}', 'admin.loadUsersFailed': 'Не вдалося завантажити користувачів', @@ -1238,11 +1329,14 @@ Likeable застосовує розумні технічні та органі 'admin.config.stripe_price_id_10_hours': 'Price ID для 10 годин', 'admin.config.stripe_price_id_100_hours': 'Price ID для 100 годин', 'admin.config.stripe_project_quota_price_id': 'Price ID ліміту майданчиків', + 'admin.config.stripe_production_project_price_id': 'Price ID production проєкту', 'admin.config.stripe_webhook_secret': 'Webhook secret', - 'admin.config.free_hours': 'Безплатних годин за вікно', - 'admin.config.free_hour_window_hours': 'Години вікна безплатних годин', + 'admin.config.free_minutes': 'Безплатних хвилин за вікно', + 'admin.config.free_hour_window_hours': 'Години безплатного вікна', 'admin.config.prompt_improve_charge_minutes': 'Хвилин списання за Improve Prompt', 'admin.config.project_cap': 'Базовий ліміт майданчиків', + 'admin.config.project_quota_days': 'Днів платного місця майданчика', + 'admin.config.production_project_days': 'Днів production проєкту', 'admin.config.agent_artefacts': 'JSON артефактів агента' }; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d1cff6b..a00b3ef 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -303,6 +303,7 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; const [prompt, setPrompt] = useState(''); const [busy, setBusy] = useState(false); const [messageSubmitting, setMessageSubmitting] = useState(false); + const [wakeSendProjectID, setWakeSendProjectID] = useState(''); const [promptImproving, setPromptImproving] = useState(false); const [projectsLoaded, setProjectsLoaded] = useState(false); const [projectsLoadedUserID, setProjectsLoadedUserID] = useState(''); @@ -321,6 +322,8 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; const [exportingID, setExportingID] = useState(''); const [exportingMode, setExportingMode] = useState<'github' | 'zip' | ''>(''); const [controllingProjectID, setControllingProjectID] = useState(''); + const [productionCheckoutProjectID, setProductionCheckoutProjectID] = useState(''); + const [domainUpdatingProjectID, setDomainUpdatingProjectID] = useState(''); const [dialog, setDialog] = useState(null); const [iframeLoaded, setIframeLoaded] = useState(false); const [previewStatus, setPreviewStatus] = useState(null); @@ -412,9 +415,10 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; const idleStopTooltip = idleStopCountdown ? t('builder.idleStop.tooltip', { time: idleStopCountdown }) : ''; const promptHasText = Boolean(prompt.trim()); const hasDraft = promptHasText || attachments.length > 0; + const wakeSendActive = Boolean(wakeSendProjectID && activeProject?.id === wakeSendProjectID); const composerDisabled = !signedIn || projectsLoading || noActiveProject || projectArchived; const composerInputDisabled = composerDisabled || promptImproving; - const canSend = signedIn && !composerDisabled && hasDraft && !busy && !messageSubmitting && !promptImproving && Boolean(activePreviewURL) && (activeProject?.status === 'ready' || previewReady); + const canSend = signedIn && !composerDisabled && hasDraft && !busy && !messageSubmitting && !promptImproving && (activeProject?.status === 'stopped' || (Boolean(activePreviewURL) && (activeProject?.status === 'ready' || previewReady))); const hasActiveNotification = rows.some((row) => row.kind === 'notification' && row.active); const canvasLoading = projectsLoading || isProjectStarting || Boolean(activePreviewURL && previewRuntimeActive && !previewMaintenance && (!previewReady || !iframeLoaded)); const brandWorking = agentWorking || hasActiveNotification || canvasLoading; @@ -472,6 +476,8 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; : canvasStatusLabel; const StudioNextIcon = agentActivityActive ? CircleStop : projectArchived || noActiveProject ? FolderOpen : Sparkles; const composerHintTone = promptImproving + ? 'pending' + : wakeSendActive ? 'pending' : !signedIn || projectsLoading || noActiveProject ? 'blocked' @@ -484,6 +490,8 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; : ''; const composerModeHint = promptImproving ? t('builder.composerHint.improvingPrompt') + : wakeSendActive + ? t('builder.composerHint.wakingToSend') : agentActivityActive ? busyPolicy === 'queue' ? t('builder.composerHint.agentQueue') @@ -743,6 +751,28 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; const text = prompt.trim(); const files = attachments; if (!text && files.length === 0) return; + if (activeProject.status === 'stopped') { + setBusy(true); + setMessageSubmitting(true); + setWakeSendProjectID(activeProject.id); + try { + const res = await api<{ project: Project }>(`/api/projects/${activeProject.id}/playground`, { + method: 'POST', + body: JSON.stringify({ action: 'start' }) + }); + setPreviewStatus(null); + setIframeLoaded(false); + setProjects((current) => current.map((project) => project.id === activeProject.id ? res.project : project)); + setFeed((current) => current?.project.id === activeProject.id ? { ...current, project: res.project } : current); + } catch (err) { + setWakeSendProjectID(''); + setDialog({ title: t('dialog.playgroundActionFailed.title'), body: err instanceof Error ? err.message : t('dialog.requestFailed.title'), tone: 'warning', confirmLabel: t('common.close') }); + } finally { + setMessageSubmitting(false); + setBusy(false); + } + return; + } const optimisticID = `optimistic-${crypto.randomUUID()}`; const optimisticMessage: Message = { id: optimisticID, @@ -776,6 +806,7 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; if (acceptedMessage) { setPendingMessagesByProject((current) => replacePendingMessage(current, activeProject.id, optimisticID, acceptedMessage)); } + setWakeSendProjectID((current) => current === activeProject.id ? '' : current); rememberPendingAgentRun(activeProject.id); try { setFeed(await api(`/api/projects/${activeProject.id}/feed`)); @@ -786,6 +817,7 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; void refreshQuota(); } catch (err) { setPendingMessagesByProject((current) => removePendingMessage(current, activeProject.id, optimisticID)); + setWakeSendProjectID((current) => current === activeProject.id ? '' : current); setPrompt(text); setAttachments(files); setDialog({ title: t('dialog.requestFailed.title'), body: messageSendErrorBody(err, t), confirmLabel: t('common.close') }); @@ -794,6 +826,24 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; setBusy(false); } }; + useEffect(() => { + if (!wakeSendProjectID) return; + if (!activeProject || activeProject.id !== wakeSendProjectID) { + setWakeSendProjectID(''); + return; + } + if (activeProject.status === 'error' || activeProject.status === 'archived' || projectArchived) { + setWakeSendProjectID(''); + return; + } + if (activeProject.status !== 'ready' || !activePreviewURL || busy || messageSubmitting || promptImproving) return; + if (!prompt.trim() && attachments.length === 0) { + setWakeSendProjectID(''); + return; + } + setWakeSendProjectID(''); + void createOrSend(); + }, [wakeSendProjectID, activeProject?.id, activeProject?.status, activePreviewURL, busy, messageSubmitting, promptImproving, prompt, attachments.length, projectArchived]); const interruptAgent = async () => { if (!signedIn || !activeProject) return; setBusy(true); @@ -903,6 +953,39 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; setBusy(false); } }; + const checkoutProductionProject = async (project: Project) => { + if (!signedIn) return; + setBusy(true); + setProductionCheckoutProjectID(project.id); + try { + const res = await api<{ url: string }>('/api/billing/checkout', { + method: 'POST', + body: JSON.stringify({ product: 'production_project', projectId: project.id }) + }); + location.href = res.url; + } catch (err) { + setDialog({ title: t('profile.checkoutFailed'), body: err instanceof Error ? err.message : t('dialog.requestFailed.title'), tone: 'warning', confirmLabel: t('common.close') }); + setProductionCheckoutProjectID(''); + setBusy(false); + } + }; + const updateProjectDomain = async (project: Project, domain: string) => { + if (!signedIn) return; + setBusy(true); + setDomainUpdatingProjectID(project.id); + try { + const res = await api<{ project: Project }>(`/api/projects/${project.id}/domain`, domain + ? { method: 'PUT', body: JSON.stringify({ domain }) } + : { method: 'DELETE' }); + setProjects((current) => current.map((item) => item.id === project.id ? res.project : item)); + setFeed((current) => current?.project.id === project.id ? { ...current, project: res.project } : current); + } catch (err) { + setDialog({ title: t('dialog.requestFailed.title'), body: err instanceof Error ? err.message : t('dialog.requestFailed.title'), tone: 'warning', confirmLabel: t('common.close') }); + } finally { + setDomainUpdatingProjectID(''); + setBusy(false); + } + }; const requestProjectExport = (project: Project) => { setExportTarget(project); }; @@ -1355,7 +1438,7 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; {projectTitleButton('chatProjectTitle', true, true)} {builderChrome} - {showProjects && { setActiveID(id); setShowProjects(false); }} onNew={() => setConfirmNewProject(true)} onRename={renameProject} onDelete={setDeleteTarget} onExport={requestProjectExport} onControlPlayground={controlProjectPlayground} onClose={() => setShowProjects(false)} />} + {showProjects && { setActiveID(id); setShowProjects(false); }} onNew={() => setConfirmNewProject(true)} onRename={renameProject} onDelete={setDeleteTarget} onExport={requestProjectExport} onControlPlayground={controlProjectPlayground} onCheckoutProductionProject={checkoutProductionProject} onUpdateProjectDomain={updateProjectDomain} onClose={() => setShowProjects(false)} />} {showProfile && } {showHelp && setShowHelp(false)} />} {showServices && activeProject?.services && void selectService(service)} onClose={() => setShowServices(false)} />} diff --git a/frontend/src/profile_panel.tsx b/frontend/src/profile_panel.tsx index 4f86227..1b14d99 100644 --- a/frontend/src/profile_panel.tsx +++ b/frontend/src/profile_panel.tsx @@ -150,6 +150,7 @@ export function ProfilePanel({ me, onClose, onOpenTutorial, onRefreshAccount }: const projectQuota = me.projectQuota; const availableHourPacks = me.billingProducts?.hourPacks ?? []; const projectQuotaPurchasable = Boolean(me.billingProducts?.projectQuota); + const projectQuotaDays = me.billingProducts?.projectQuotaDays ?? 30; const quotaResetLabel = quota ? formatResetCountdown(quota.resetsAt, Date.now(), resetCountdownLabels(t)) : t('duration.fiveHours'); const quotaWindowHours = quota?.windowHours ?? 5; const remainingHours = formatBillingDuration(quota?.remainingMs ?? 0, resetCountdownLabels(t)); @@ -236,7 +237,7 @@ export function ProfilePanel({ me, onClose, onOpenTutorial, onRefreshAccount }: )} {projectQuota && } - {projectSlotsFull ? t('profile.projectSlotFullDetail') : t('profile.projectSlotDetail', { paid: projectQuota?.paidSlots ?? 0, reset: projectQuota?.nextExpiresAt ? ` · ${t('common.nextReset', { date: formatShortDate(projectQuota.nextExpiresAt, locale) })}` : '' })} + {projectSlotsFull ? t('profile.projectSlotFullDetail') : t('profile.projectSlotDetail', { paid: projectQuota?.paidSlots ?? 0, days: projectQuotaDays, reset: projectQuota?.nextExpiresAt ? ` · ${t('common.nextReset', { date: formatShortDate(projectQuota.nextExpiresAt, locale) })}` : '' })}
{projectQuotaPurchasable && ( +
diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index 7252144..b43eaf2 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -574,6 +574,11 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'admin.diagnostics.workSessions': 'Work sessions', 'admin.diagnostics.hourLedger': 'Hour ledger', 'admin.diagnostics.empty': 'No records.', + 'admin.productionGrant.enable': 'Enable production', + 'admin.productionGrant.extend': 'Extend production', + 'admin.productionGrant.failed': 'Could not enable production mode', + 'admin.productionGrantDialog.title': 'Enable production mode?', + 'admin.productionGrantDialog.body': 'This keeps {title} online for the configured production-project duration and sends the user a notice.', 'admin.noActiveProjects': 'No active playgrounds.', 'admin.deleteProject.aria': 'Delete {title}', 'admin.loadUsersFailed': 'Could not load users', @@ -1245,6 +1250,11 @@ Likeable застосовує розумні технічні та органі 'admin.diagnostics.workSessions': 'Work sessions', 'admin.diagnostics.hourLedger': 'Hour ledger', 'admin.diagnostics.empty': 'Записів немає.', + 'admin.productionGrant.enable': 'Увімкнути production', + 'admin.productionGrant.extend': 'Продовжити production', + 'admin.productionGrant.failed': 'Не вдалося увімкнути production режим', + 'admin.productionGrantDialog.title': 'Увімкнути production режим?', + 'admin.productionGrantDialog.body': 'Це залишить {title} онлайн на налаштований термін production проєкту й надішле користувачу сповіщення.', 'admin.noActiveProjects': 'Активних майданчиків немає.', 'admin.deleteProject.aria': 'Видалити {title}', 'admin.loadUsersFailed': 'Не вдалося завантажити користувачів', diff --git a/internal/likeable/admin_handlers.go b/internal/likeable/admin_handlers.go index 93301a0..759e72b 100644 --- a/internal/likeable/admin_handlers.go +++ b/internal/likeable/admin_handlers.go @@ -15,6 +15,7 @@ import ( ) const adminMaxHourGrant = 100 +const adminMaxProductionGrantDays = maxProductionProjectDays var secretConfigKeys = map[string]bool{ "fibe_api_key": true, @@ -350,6 +351,8 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { s.handleAdminUserProjectDelete(w, r, userID, parts[2]) case len(parts) == 4 && parts[1] == "projects" && parts[3] == "diagnostics" && r.Method == http.MethodGet: s.handleAdminUserProjectDiagnostics(w, r, userID, parts[2]) + case len(parts) == 4 && parts[1] == "projects" && parts[3] == "production" && r.Method == http.MethodPost: + s.handleAdminUserProjectProductionGrant(w, r, userID, parts[2]) case len(parts) == 4 && parts[1] == "projects" && parts[3] == "assignment" && r.Method == http.MethodPatch: s.handleAdminUserProjectAssignment(w, r, userID, parts[2]) default: @@ -572,6 +575,66 @@ func (s *Server) handleAdminUserProjectDiagnostics(w http.ResponseWriter, r *htt writeJSON(w, http.StatusOK, map[string]any{"diagnostics": diagnostics}) } +func (s *Server) handleAdminUserProjectProductionGrant(w http.ResponseWriter, r *http.Request, userID, projectID string) { + var body struct { + Days int `json:"days"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + days := body.Days + if days == 0 { + days = s.productionProjectDays(r.Context()) + } + if days <= 0 || days > adminMaxProductionGrantDays { + writeError(w, http.StatusBadRequest, "days must be between 1 and "+strconv.Itoa(adminMaxProductionGrantDays)) + return + } + if _, err := s.store.UserByID(r.Context(), userID); err != nil { + writeError(w, http.StatusNotFound, "user not found") + return + } + project, err := s.store.ProjectForUser(r.Context(), userID, projectID) + if err != nil { + writeError(w, http.StatusNotFound, "project not found") + return + } + if project.Status == "archived" || project.Status == "deleting" { + writeError(w, http.StatusConflict, "production grant requires an active project") + return + } + expiresAt := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour) + granted, err := s.store.GrantProjectProduction(r.Context(), userID, projectID, "admin_production_"+uuid.NewString(), expiresAt) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if granted { + s.notifyProductionProjectPurchased(r.Context(), userID, projectID, expiresAt) + } + updated, err := s.store.ProjectForUser(r.Context(), userID, projectID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + windowStart, windowEnd := s.freeHourWindow(time.Now(), r.Context()) + detail, err := s.store.AdminUserDetail(r.Context(), userID, s.freeHourLimitMs(r.Context()), windowStart, windowEnd) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + detail.Summary.ProjectLimit = s.baseProjectCap(r.Context()) + detail.Summary.PaidProjectSlots + pool, err := s.adminAgentPoolOptions(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + decorateAdminDetailAssignmentStatuses(detail, pool) + detail.AgentPool = pool + writeJSON(w, http.StatusOK, map[string]any{"detail": detail, "project": updated, "granted": granted, "days": days}) +} + func (s *Server) handleAdminUserProjectAssignment(w http.ResponseWriter, r *http.Request, userID, projectID string) { var body struct { AgentID string `json:"agent_id"` diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index b987641..dac9cfb 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -6265,6 +6265,116 @@ func TestAdminProjectDiagnosticsExposeSupportContext(t *testing.T) { } } +func TestAdminCanGrantProjectProduction(t *testing.T) { + appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer appStore.Close() + server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient} + admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "") + if err != nil { + t.Fatal(err) + } + user, err := appStore.UpsertUser(t.Context(), "manual-production@example.com", "Manual Production", "") + if err != nil { + t.Fatal(err) + } + if err := appStore.CreateSession(t.Context(), admin.ID, "admin-production-token", time.Hour); err != nil { + t.Fatal(err) + } + if err := appStore.UpsertConfig(t.Context(), map[string]string{"production_project_days": "14"}, secretConfigKeys); err != nil { + t.Fatal(err) + } + project := &Project{ + ID: "project-admin-production", + UserID: user.ID, + Title: "Admin Production", + ConversationID: "conv-admin-production", + PlaygroundID: "playground-admin-production", + Status: "ready", + PlaygroundLastUsedAt: time.Now().UTC().Add(-9 * time.Hour).Format(time.RFC3339Nano), + } + if err := appStore.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/production", strings.NewReader(`{}`)) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-production-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("production grant returned %d: %s", rec.Code, rec.Body.String()) + } + var resp struct { + Detail AdminUserDetail `json:"detail"` + Project Project `json:"project"` + Granted bool `json:"granted"` + Days int `json:"days"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + if !resp.Granted || resp.Days != 14 || resp.Project.ProductionExpiresAt == "" || resp.Project.PlaygroundIdleStopAt != "" { + t.Fatalf("response=%+v, want 14 day production grant without idle stop deadline", resp) + } + stored, err := appStore.ProjectForUser(t.Context(), user.ID, project.ID) + if err != nil { + t.Fatal(err) + } + if stored.ProductionExpiresAt == "" || stored.PlaygroundIdleStopAt != "" { + t.Fatalf("stored project=%+v, want production grant reflected", stored) + } + notices, err := appStore.NoticesForUser(t.Context(), user.ID, 10) + if err != nil { + t.Fatal(err) + } + if len(notices) == 0 || !strings.Contains(notices[0].Body, "Production project enabled") { + t.Fatalf("notices=%+v, want production notice", notices) + } +} + +func TestAdminProductionGrantRejectsInactiveProject(t *testing.T) { + appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer appStore.Close() + server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient} + admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "") + if err != nil { + t.Fatal(err) + } + user, err := appStore.UpsertUser(t.Context(), "manual-inactive@example.com", "Manual Inactive", "") + if err != nil { + t.Fatal(err) + } + if err := appStore.CreateSession(t.Context(), admin.ID, "admin-production-inactive-token", time.Hour); err != nil { + t.Fatal(err) + } + project := &Project{ID: "project-admin-production-archived", UserID: user.ID, Title: "Archived", ConversationID: "conv-admin-production-archived", Status: "archived"} + if err := appStore.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/production", strings.NewReader(`{"days":7}`)) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-production-inactive-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("production grant returned %d, want 409; body=%s", rec.Code, rec.Body.String()) + } + stored, err := appStore.ProjectForUser(t.Context(), user.ID, project.ID) + if err != nil { + t.Fatal(err) + } + if stored.ProductionExpiresAt != "" { + t.Fatalf("stored project=%+v, want no production grant", stored) + } +} + func TestFixedUTCHourWindowAnchorsToMidnight(t *testing.T) { tests := []struct { name string From 1145c42b8c127dce4bc91e6b2cf4ae21cb42ffe9 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 16:41:23 +0300 Subject: [PATCH 08/36] Add custom domain DNS verification --- frontend/src/builder_components.tsx | 17 ++- frontend/src/i18n/translations.ts | 8 ++ frontend/src/main.tsx | 18 ++- internal/domain/types.go | 14 +++ internal/likeable/jobs.go | 40 +++++++ .../likeable/project_domain_verification.go | 62 ++++++++++ internal/likeable/project_handlers.go | 27 ++++- internal/likeable/server.go | 1 + internal/likeable/server_test.go | 109 ++++++++++++++++++ internal/store/store_projects.go | 53 ++++++++- internal/store/types.go | 6 + 11 files changed, 347 insertions(+), 8 deletions(-) create mode 100644 internal/likeable/project_domain_verification.go diff --git a/frontend/src/builder_components.tsx b/frontend/src/builder_components.tsx index dec150f..f6a1c5e 100644 --- a/frontend/src/builder_components.tsx +++ b/frontend/src/builder_components.tsx @@ -5,7 +5,7 @@ import type { AppDialogConfig, MessageAttachment, Project, ProjectService, UserF import { formatElapsedDuration, formatMessageTime, formatShortDate } from './format'; import { elapsedDurationLabels, statusLabel, useI18n } from './i18n'; -export function ProjectList({ projects, activeID, projectCap, busy, exportingID, controllingID, productionCheckoutID, productionPurchasable, domainUpdatingID, onSelect, onNew, onRename, onDelete, onExport, onControlPlayground, onCheckoutProductionProject, onUpdateProjectDomain, onClose }: { projects: Project[]; activeID: string; projectCap: number | null; busy: boolean; exportingID: string; controllingID: string; productionCheckoutID: string; productionPurchasable: boolean; domainUpdatingID: string; onSelect: (id: string) => void; onNew: () => void; onRename: (project: Project, title: string) => Promise; onDelete: (project: Project) => void; onExport: (project: Project) => void; onControlPlayground: (project: Project, action: 'start' | 'stop' | 'restart') => Promise; onCheckoutProductionProject: (project: Project) => Promise; onUpdateProjectDomain: (project: Project, domain: string) => Promise; onClose: () => void }) { +export function ProjectList({ projects, activeID, projectCap, busy, exportingID, controllingID, productionCheckoutID, productionPurchasable, domainUpdatingID, domainVerifyingID, onSelect, onNew, onRename, onDelete, onExport, onControlPlayground, onCheckoutProductionProject, onUpdateProjectDomain, onVerifyProjectDomain, onClose }: { projects: Project[]; activeID: string; projectCap: number | null; busy: boolean; exportingID: string; controllingID: string; productionCheckoutID: string; productionPurchasable: boolean; domainUpdatingID: string; domainVerifyingID: string; onSelect: (id: string) => void; onNew: () => void; onRename: (project: Project, title: string) => Promise; onDelete: (project: Project) => void; onExport: (project: Project) => void; onControlPlayground: (project: Project, action: 'start' | 'stop' | 'restart') => Promise; onCheckoutProductionProject: (project: Project) => Promise; onUpdateProjectDomain: (project: Project, domain: string) => Promise; onVerifyProjectDomain: (project: Project) => Promise; onClose: () => void }) { const { locale, t } = useI18n(); const [editingID, setEditingID] = useState(''); const [draftTitle, setDraftTitle] = useState(''); @@ -55,6 +55,9 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID, await onUpdateProjectDomain(project, domainDraft.trim()); cancelDomainEdit(); }; + const verifyDomain = async (project: Project) => { + await onVerifyProjectDomain(project); + }; const removeDomain = async (project: Project) => { setMenuID(''); await onUpdateProjectDomain(project, ''); @@ -130,6 +133,8 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID, const cnameTarget = project.customDomainTarget || projectDomainTarget(project); const domainEditing = domainEditingID === project.id; const domainSaving = domainUpdatingID === project.id; + const domainVerifying = domainVerifyingID === project.id; + const domainBusy = domainSaving || domainVerifying; const menuOpensUp = index > 0; return (
@@ -165,7 +170,7 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID, {projectRowMeta(project)} {detail && {detail}} - {project.customDomain && {t('projects.production.domain', { domain: project.customDomain })} · {t('projects.production.domainPending')}} + {project.customDomain && {t('projects.production.domain', { domain: project.customDomain })} · {project.customDomainStatus === 'active' ? t('projects.production.domainActive') : t('projects.production.domainPending')}} {project.productionExpiresAt && cnameTarget && {t('projects.production.cname', { host: cnameTarget })}} {statusLabel(project.status, t)} @@ -211,7 +216,13 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID, {t('projects.production.domainAction')} {project.customDomain && ( - + )} + {project.customDomain && ( + diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index b43eaf2..3ce046d 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -362,11 +362,15 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'projects.production.until': 'Always-on until {date}', 'projects.production.cname': 'Point CNAME to {host}', 'projects.production.domain': 'Custom domain: {domain}', + 'projects.production.domainActive': 'DNS active', 'projects.production.domainPending': 'DNS pending', + 'projects.production.domainCheck': 'Check DNS', + 'projects.production.domainChecking': 'Checking DNS', 'projects.production.domainAction': 'Custom domain', 'projects.production.domainRemove': 'Remove custom domain', 'projects.production.domainPlaceholder': 'app.example.com', 'projects.production.domainSave': 'Save domain', + 'projects.production.domainVerifyFailed': 'Could not verify DNS', 'projects.production.buy': 'Make production', 'projects.production.unavailable': 'Production package unavailable', 'projects.rename.aria': 'Rename {title}', @@ -1038,11 +1042,15 @@ Likeable застосовує розумні технічні та органі 'projects.production.until': 'Always-on до {date}', 'projects.production.cname': 'Спрямуйте CNAME на {host}', 'projects.production.domain': 'Кастомний домен: {domain}', + 'projects.production.domainActive': 'DNS активний', 'projects.production.domainPending': 'DNS очікує', + 'projects.production.domainCheck': 'Перевірити DNS', + 'projects.production.domainChecking': 'Перевіряємо DNS', 'projects.production.domainAction': 'Кастомний домен', 'projects.production.domainRemove': 'Прибрати кастомний домен', 'projects.production.domainPlaceholder': 'app.example.com', 'projects.production.domainSave': 'Зберегти домен', + 'projects.production.domainVerifyFailed': 'Не вдалося перевірити DNS', 'projects.production.buy': 'Зробити production', 'projects.production.unavailable': 'Production пакет вимкнено', 'projects.rename.aria': 'Перейменувати {title}', diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a00b3ef..717eccc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -324,6 +324,7 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; const [controllingProjectID, setControllingProjectID] = useState(''); const [productionCheckoutProjectID, setProductionCheckoutProjectID] = useState(''); const [domainUpdatingProjectID, setDomainUpdatingProjectID] = useState(''); + const [domainVerifyingProjectID, setDomainVerifyingProjectID] = useState(''); const [dialog, setDialog] = useState(null); const [iframeLoaded, setIframeLoaded] = useState(false); const [previewStatus, setPreviewStatus] = useState(null); @@ -986,6 +987,21 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; setBusy(false); } }; + const verifyProjectDomain = async (project: Project) => { + if (!signedIn) return; + setBusy(true); + setDomainVerifyingProjectID(project.id); + try { + const res = await api<{ project: Project }>(`/api/projects/${project.id}/domain/verify`, { method: 'POST' }); + setProjects((current) => current.map((item) => item.id === project.id ? res.project : item)); + setFeed((current) => current?.project.id === project.id ? { ...current, project: res.project } : current); + } catch (err) { + setDialog({ title: t('projects.production.domainVerifyFailed'), body: err instanceof Error ? err.message : t('dialog.requestFailed.title'), tone: 'warning', confirmLabel: t('common.close') }); + } finally { + setDomainVerifyingProjectID(''); + setBusy(false); + } + }; const requestProjectExport = (project: Project) => { setExportTarget(project); }; @@ -1438,7 +1454,7 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; {projectTitleButton('chatProjectTitle', true, true)} {builderChrome} - {showProjects && { setActiveID(id); setShowProjects(false); }} onNew={() => setConfirmNewProject(true)} onRename={renameProject} onDelete={setDeleteTarget} onExport={requestProjectExport} onControlPlayground={controlProjectPlayground} onCheckoutProductionProject={checkoutProductionProject} onUpdateProjectDomain={updateProjectDomain} onClose={() => setShowProjects(false)} />} + {showProjects && { setActiveID(id); setShowProjects(false); }} onNew={() => setConfirmNewProject(true)} onRename={renameProject} onDelete={setDeleteTarget} onExport={requestProjectExport} onControlPlayground={controlProjectPlayground} onCheckoutProductionProject={checkoutProductionProject} onUpdateProjectDomain={updateProjectDomain} onVerifyProjectDomain={verifyProjectDomain} onClose={() => setShowProjects(false)} />} {showProfile && } {showHelp && setShowHelp(false)} />} {showServices && activeProject?.services && void selectService(service)} onClose={() => setShowServices(false)} />} diff --git a/internal/domain/types.go b/internal/domain/types.go index df23299..3ff85b0 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -7,6 +7,11 @@ import ( const PlaygroundIdleStopAfter = 8 * time.Hour +const ( + ProjectDomainStatusPendingDNS = "pending_dns" + ProjectDomainStatusActive = "active" +) + type User struct { ID string `json:"id"` Email string `json:"email"` @@ -48,6 +53,15 @@ type Project struct { UpdatedAt string `json:"updatedAt"` } +type ProjectDomain struct { + ProjectID string + UserID string + Domain string + Target string + Status string + UpdatedAt string +} + func (p *Project) RefreshComputedFields() { p.PlaygroundIdleStopAt = "" if strings.TrimSpace(p.ProductionExpiresAt) != "" { diff --git a/internal/likeable/jobs.go b/internal/likeable/jobs.go index 16695ec..1822af7 100644 --- a/internal/likeable/jobs.go +++ b/internal/likeable/jobs.go @@ -27,6 +27,7 @@ const projectCleanupLeaseTTL = projectCleanupTaskTimeout + 30*time.Second const projectCleanupSweepTimeout = 30 * time.Second const projectDeletionSweepInterval = 5 * time.Minute const projectDeletionSweepUniqueTTL = 4 * time.Minute +const projectDomainVerificationSweepUniqueTTL = 50 * time.Minute const maxConcurrentProjectCleanup = 1 const defaultJobWorkerConcurrency = 32 const idleProjectStopAfter = domain.PlaygroundIdleStopAfter @@ -43,6 +44,7 @@ const ( taskMonitorProjectNotifications = "likeable:project:monitor_notifications" taskSendEmail = "likeable:email:send" taskProjectQuotaSweep = "likeable:project_quota:sweep" + taskProjectDomainVerifySweep = "likeable:project_domain:verify_sweep" ) var errProjectCleanupConcurrencyLimit = errors.New("project cleanup concurrency limit reached") @@ -85,6 +87,7 @@ func newJobSystem(redisOpt asynq.RedisClientOpt, s *Server) *JobSystem { mux.HandleFunc(taskMonitorProjectNotifications, s.handleMonitorProjectNotificationsTask) mux.HandleFunc(taskSendEmail, s.handleSendEmailTask) mux.HandleFunc(taskProjectQuotaSweep, s.handleProjectQuotaSweepTask) + mux.HandleFunc(taskProjectDomainVerifySweep, s.handleProjectDomainVerifySweepTask) server := asynq.NewServer(redisOpt, asynq.Config{ Concurrency: jobWorkerConcurrency(), ShutdownTimeout: 20 * time.Second, @@ -255,6 +258,20 @@ func (s *Server) enqueueStopIdleProjectsSweep(ctx context.Context, delay time.Du } } +func (s *Server) enqueueProjectDomainVerifySweep(ctx context.Context, delay time.Duration) { + if s.jobs == nil { + return + } + opts := []asynq.Option{asynq.Queue("low"), asynq.MaxRetry(2), asynq.Timeout(2 * time.Minute), asynq.Unique(projectDomainVerificationSweepUniqueTTL)} + if delay > 0 { + opts = append(opts, asynq.ProcessIn(delay)) + } + _, err := s.jobs.client.EnqueueContext(ctx, asynq.NewTask(taskProjectDomainVerifySweep, nil), opts...) + if err != nil && !errors.Is(err, asynq.ErrDuplicateTask) { + log.Printf("enqueue custom domain DNS verification sweep: %v", err) + } +} + func (s *Server) startRecurringJobs(ctx context.Context) { if s.jobs == nil { return @@ -262,6 +279,7 @@ func (s *Server) startRecurringJobs(ctx context.Context) { s.enqueueProjectQuotaSweep(ctx, 0) s.enqueueProjectDeletionSweep(ctx, 0) s.enqueueStopIdleProjectsSweep(ctx, 0) + s.enqueueProjectDomainVerifySweep(ctx, 0) go func() { hourlyTicker := time.NewTicker(time.Hour) deletionTicker := time.NewTicker(projectDeletionSweepInterval) @@ -277,6 +295,7 @@ func (s *Server) startRecurringJobs(ctx context.Context) { s.enqueueProjectQuotaSweep(context.Background(), 0) s.enqueueProjectDeletionSweep(context.Background(), 0) s.enqueueStopIdleProjectsSweep(context.Background(), 0) + s.enqueueProjectDomainVerifySweep(context.Background(), 0) } } }() @@ -613,6 +632,27 @@ func (s *Server) handleProjectQuotaSweepTask(ctx context.Context, _ *asynq.Task) return s.cleanupExpiredArchives(ctx) } +func (s *Server) handleProjectDomainVerifySweepTask(ctx context.Context, _ *asynq.Task) error { + projectDomains, err := s.store.PendingProjectDomains(ctx, 100) + if err != nil { + return err + } + for _, projectDomain := range projectDomains { + status, err := s.projectCustomDomainDNSStatus(ctx, projectDomain.Domain, projectDomain.Target) + if err != nil { + log.Printf("verify custom domain DNS project=%s domain=%s: %v", projectDomain.ProjectID, projectDomain.Domain, err) + continue + } + if status == projectDomain.Status { + continue + } + if err := s.store.UpdateProjectDomainStatus(ctx, projectDomain.UserID, projectDomain.ProjectID, status); err != nil && !errors.Is(err, sql.ErrNoRows) { + log.Printf("update custom domain DNS status project=%s domain=%s: %v", projectDomain.ProjectID, projectDomain.Domain, err) + } + } + return nil +} + func (s *Server) handleStopIdleProjectsSweepTask(ctx context.Context, _ *asynq.Task) error { cutoff := time.Now().UTC().Add(-idleProjectStopAfter) projects, err := s.store.IdleProjectsForPlaygroundStop(ctx, cutoff, 100) diff --git a/internal/likeable/project_domain_verification.go b/internal/likeable/project_domain_verification.go new file mode 100644 index 0000000..afe7b00 --- /dev/null +++ b/internal/likeable/project_domain_verification.go @@ -0,0 +1,62 @@ +package likeable + +import ( + "context" + "errors" + "net" + "strings" + + "github.com/fibegg/likeable/internal/store" +) + +type customDomainResolver interface { + LookupCNAME(ctx context.Context, host string) (string, error) +} + +func (s *Server) customDomainResolver() customDomainResolver { + if s.domainDNS != nil { + return s.domainDNS + } + return net.DefaultResolver +} + +func (s *Server) projectCustomDomainDNSStatus(ctx context.Context, domain, target string) (string, error) { + domain = normalizeDNSHost(domain) + target = normalizeDNSHost(target) + if domain == "" || target == "" { + return store.ProjectDomainStatusPendingDNS, nil + } + cname, err := s.customDomainResolver().LookupCNAME(ctx, domain) + if err != nil { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return store.ProjectDomainStatusPendingDNS, nil + } + return "", err + } + if normalizeDNSHost(cname) == target { + return store.ProjectDomainStatusActive, nil + } + return store.ProjectDomainStatusPendingDNS, nil +} + +func (s *Server) verifyProjectCustomDomain(ctx context.Context, project *Project) (*Project, error) { + if project == nil || strings.TrimSpace(project.CustomDomain) == "" { + return project, nil + } + status, err := s.projectCustomDomainDNSStatus(ctx, project.CustomDomain, project.CustomDomainTarget) + if err != nil { + return nil, err + } + if status != project.CustomDomainStatus { + if err := s.store.UpdateProjectDomainStatus(ctx, project.UserID, project.ID, status); err != nil { + return nil, err + } + return s.store.ProjectForUser(ctx, project.UserID, project.ID) + } + return project, nil +} + +func normalizeDNSHost(value string) string { + return strings.Trim(strings.ToLower(strings.TrimSpace(value)), ".") +} diff --git a/internal/likeable/project_handlers.go b/internal/likeable/project_handlers.go index b6a0e85..7d621e1 100644 --- a/internal/likeable/project_handlers.go +++ b/internal/likeable/project_handlers.go @@ -178,7 +178,15 @@ func (s *Server) handleProjectRoute(w http.ResponseWriter, r *http.Request) { case "playground": s.handleProjectPlaygroundAction(w, r, user, project) case "domain": - s.handleProjectDomain(w, r, user, project) + if len(parts) == 2 { + s.handleProjectDomain(w, r, user, project) + return + } + if len(parts) == 3 && parts[2] == "verify" { + s.handleProjectDomainVerify(w, r, user, project) + return + } + writeError(w, http.StatusNotFound, "not found") case "attachments": if len(parts) != 3 { writeError(w, http.StatusNotFound, "not found") @@ -419,6 +427,23 @@ func (s *Server) handleProjectDomain(w http.ResponseWriter, r *http.Request, use writeJSON(w, http.StatusOK, map[string]any{"project": updated}) } +func (s *Server) handleProjectDomainVerify(w http.ResponseWriter, r *http.Request, user *User, project *Project) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if strings.TrimSpace(project.CustomDomain) == "" { + writeError(w, http.StatusConflict, "custom domain is not configured") + return + } + updated, err := s.verifyProjectCustomDomain(r.Context(), project) + if err != nil { + writeError(w, http.StatusBadGateway, "could not verify custom domain DNS") + return + } + writeJSON(w, http.StatusOK, map[string]any{"project": updated}) +} + func normalizeProjectCustomDomain(value string) (string, error) { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { diff --git a/internal/likeable/server.go b/internal/likeable/server.go index 01c8f3c..9542f68 100644 --- a/internal/likeable/server.go +++ b/internal/likeable/server.go @@ -29,6 +29,7 @@ type Server struct { cleanupSlots chan struct{} email emailSender jobs *JobSystem + domainDNS customDomainResolver limiter *RateLimiter limiterOnce sync.Once platform platformBackoffState diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index dac9cfb..2c95a30 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -13,6 +13,7 @@ import ( "fmt" "io" "mime/multipart" + "net" "net/http" "net/http/httptest" "net/url" @@ -45,6 +46,21 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } +type fakeCNAMEResolver map[string]fakeCNAMEResponse + +type fakeCNAMEResponse struct { + cname string + err error +} + +func (f fakeCNAMEResolver) LookupCNAME(_ context.Context, host string) (string, error) { + response, ok := f[host] + if !ok { + return "", &net.DNSError{Err: "no such host", Name: host, IsNotFound: true} + } + return response.cname, response.err +} + func testStripeSignature(secret string, body []byte) string { timestamp := time.Now().Unix() mac := hmac.New(sha256.New, []byte(secret)) @@ -4009,6 +4025,99 @@ func TestProjectCustomDomainRejectsDomainAlreadyLinked(t *testing.T) { } } +func TestProjectCustomDomainVerifyMarksActiveCNAME(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + server := &Server{ + store: store, + config: RuntimeConfig{BaseURL: "http://example.test"}, + http: http.DefaultClient, + domainDNS: fakeCNAMEResolver{ + "app.customer.example": {cname: "app-target.example.test."}, + }, + } + user, err := store.UpsertUser(t.Context(), "domain-verify@example.com", "Domain Verify", "") + if err != nil { + t.Fatal(err) + } + if err := store.CreateSession(t.Context(), user.ID, "domain-verify-token", time.Hour); err != nil { + t.Fatal(err) + } + project := &Project{ID: "project-domain-verify", UserID: user.ID, Title: "Domain Verify", ConversationID: "conv-domain-verify", Status: "ready", PreviewURL: "https://app-target.example.test"} + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + if granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_domain_verify_project", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted { + t.Fatalf("production grant=%v err=%v, want granted", granted, err) + } + + req := httptest.NewRequest(http.MethodPut, "/api/projects/project-domain-verify/domain", strings.NewReader(`{"domain":"app.customer.example"}`)) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-verify-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("domain save returned %d: %s", rec.Code, rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodPost, "/api/projects/project-domain-verify/domain/verify", nil) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-verify-token"}) + rec = httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("domain verify returned %d: %s", rec.Code, rec.Body.String()) + } + var body struct { + Project Project `json:"project"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Project.CustomDomain != "app.customer.example" || body.Project.CustomDomainStatus != "active" { + t.Fatalf("project domain=%+v, want active custom domain", body.Project) + } +} + +func TestProjectDomainVerifySweepMarksActiveCNAME(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + server := &Server{ + store: store, + config: RuntimeConfig{BaseURL: "http://example.test"}, + http: http.DefaultClient, + domainDNS: fakeCNAMEResolver{ + "app.customer.example": {cname: "app-target.example.test"}, + }, + } + user, err := store.UpsertUser(t.Context(), "domain-sweep@example.com", "Domain Sweep", "") + if err != nil { + t.Fatal(err) + } + project := &Project{ID: "project-domain-sweep", UserID: user.ID, Title: "Domain Sweep", ConversationID: "conv-domain-sweep", Status: "ready", PreviewURL: "https://app-target.example.test"} + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + if err := store.UpsertProjectDomain(t.Context(), user.ID, project.ID, "app.customer.example", "app-target.example.test"); err != nil { + t.Fatal(err) + } + + if err := server.handleProjectDomainVerifySweepTask(t.Context(), asynq.NewTask(taskProjectDomainVerifySweep, nil)); err != nil { + t.Fatalf("verify sweep returned error: %v", err) + } + stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID) + if err != nil { + t.Fatal(err) + } + if stored.CustomDomainStatus != "active" { + t.Fatalf("custom domain status=%q, want active", stored.CustomDomainStatus) + } +} + func TestProjectPassiveActionsDoNotTouchPlaygroundUsage(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { diff --git a/internal/store/store_projects.go b/internal/store/store_projects.go index c007f73..0321e0c 100644 --- a/internal/store/store_projects.go +++ b/internal/store/store_projects.go @@ -260,9 +260,9 @@ func (s *Store) UpsertProjectDomain(ctx context.Context, userID, projectID, doma now := nowString() result, err := s.db.ExecContext(ctx, ` INSERT INTO project_domains(project_id, user_id, domain, target, status, created_at, updated_at) - VALUES(?, ?, ?, ?, 'pending_dns', ?, ?) - ON CONFLICT(project_id) DO UPDATE SET domain = excluded.domain, target = excluded.target, status = 'pending_dns', updated_at = excluded.updated_at - `, projectID, userID, domain, target, now, now) + VALUES(?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id) DO UPDATE SET domain = excluded.domain, target = excluded.target, status = excluded.status, updated_at = excluded.updated_at + `, projectID, userID, domain, target, ProjectDomainStatusPendingDNS, now, now) if err != nil { return err } @@ -273,6 +273,53 @@ func (s *Store) UpsertProjectDomain(ctx context.Context, userID, projectID, doma return nil } +func (s *Store) UpdateProjectDomainStatus(ctx context.Context, userID, projectID, status string) error { + status = strings.TrimSpace(status) + if status == "" { + return errors.New("project domain status is required") + } + now := nowString() + result, err := s.db.ExecContext(ctx, ` + UPDATE project_domains + SET status = ?, updated_at = ? + WHERE project_id = ? AND user_id = ? + `, status, now, projectID, userID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *Store) PendingProjectDomains(ctx context.Context, limit int) ([]ProjectDomain, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := s.db.QueryContext(ctx, ` + SELECT project_id, user_id, domain, target, status, updated_at + FROM project_domains + WHERE status = ? + ORDER BY updated_at ASC + LIMIT ? + `, ProjectDomainStatusPendingDNS, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []ProjectDomain{} + for rows.Next() { + var projectDomain ProjectDomain + if err := rows.Scan(&projectDomain.ProjectID, &projectDomain.UserID, &projectDomain.Domain, &projectDomain.Target, &projectDomain.Status, &projectDomain.UpdatedAt); err != nil { + return nil, err + } + out = append(out, projectDomain) + } + return out, rows.Err() +} + func (s *Store) DeleteProjectDomain(ctx context.Context, userID, projectID string) error { _, err := s.db.ExecContext(ctx, ` DELETE FROM project_domains diff --git a/internal/store/types.go b/internal/store/types.go index ad72bba..712aaa9 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -4,6 +4,7 @@ import "github.com/fibegg/likeable/internal/domain" type User = domain.User type Project = domain.Project +type ProjectDomain = domain.ProjectDomain type ProjectRepository = domain.ProjectRepository type ProjectService = domain.ProjectService type Message = domain.Message @@ -26,3 +27,8 @@ type AdminProjectInternal = domain.AdminProjectInternal type AdminProjectDiagnostics = domain.AdminProjectDiagnostics type AdminUserDetail = domain.AdminUserDetail type AdminUserFilters = domain.AdminUserFilters + +const ( + ProjectDomainStatusPendingDNS = domain.ProjectDomainStatusPendingDNS + ProjectDomainStatusActive = domain.ProjectDomainStatusActive +) From aceab2bc63230f9cbdc9173e9c4cc90725e845e7 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 17:16:47 +0300 Subject: [PATCH 09/36] Clarify custom domain DNS verification status --- README.md | 4 ++-- frontend/src/builder_components.tsx | 3 ++- frontend/src/i18n/translations.ts | 4 ++-- internal/domain/types.go | 5 +++-- internal/likeable/project_domain_verification.go | 2 +- internal/likeable/server_test.go | 12 ++++++------ internal/store/types.go | 5 +++-- 7 files changed, 19 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e0fff5c..dd08499 100644 --- a/README.md +++ b/README.md @@ -147,10 +147,10 @@ Checkout uses backend-created Stripe Checkout Sessions and does not require `str - Sending a message to a stopped project wakes the playground and then sends the prompt. - Hour-pack checkout grants build minutes. - Project-slot checkout increases the project cap for the configured number of days. -- Production-project checkout pins that project as always-on until expiry, blocks manual stop, lets the user save a custom domain request, and shows the CNAME target in the project menu. +- Production-project checkout pins that project as always-on until expiry, blocks manual stop, lets the user save a custom domain request, shows the CNAME target, and verifies customer DNS. - Admin diagnostics for a user project shows conversation, agent, playground, server, repositories, payments, hour ledger, and work sessions. -Domain DNS is currently manual: after a production-project purchase, the user can save the intended custom domain and the app shows the target host to use as the customer CNAME. Admin diagnostics include the saved domain, status, and target. DNS ownership verification and automatic domain provisioning are separate follow-up work. +Domain routing is currently manual: after a production-project purchase, the user can save the intended custom domain, point a CNAME at the project target, and run DNS verification from the project menu. Admin diagnostics include the saved domain, DNS status, and target. Automatic Traefik routing and certificate provisioning for arbitrary customer domains are separate follow-up work. ## Development With Live Reload diff --git a/frontend/src/builder_components.tsx b/frontend/src/builder_components.tsx index f6a1c5e..98336d7 100644 --- a/frontend/src/builder_components.tsx +++ b/frontend/src/builder_components.tsx @@ -117,6 +117,7 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID, return url.replace(/^https?:\/\//, '').split('/')[0] ?? ''; } }; + const isDomainDNSVerified = (project: Project) => project.customDomainStatus === 'dns_verified' || project.customDomainStatus === 'active'; return (
@@ -170,7 +171,7 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID, {projectRowMeta(project)} {detail && {detail}} - {project.customDomain && {t('projects.production.domain', { domain: project.customDomain })} · {project.customDomainStatus === 'active' ? t('projects.production.domainActive') : t('projects.production.domainPending')}} + {project.customDomain && {t('projects.production.domain', { domain: project.customDomain })} · {isDomainDNSVerified(project) ? t('projects.production.domainActive') : t('projects.production.domainPending')}} {project.productionExpiresAt && cnameTarget && {t('projects.production.cname', { host: cnameTarget })}} {statusLabel(project.status, t)} diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index 3ce046d..b1a61a7 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -362,7 +362,7 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'projects.production.until': 'Always-on until {date}', 'projects.production.cname': 'Point CNAME to {host}', 'projects.production.domain': 'Custom domain: {domain}', - 'projects.production.domainActive': 'DNS active', + 'projects.production.domainActive': 'DNS verified', 'projects.production.domainPending': 'DNS pending', 'projects.production.domainCheck': 'Check DNS', 'projects.production.domainChecking': 'Checking DNS', @@ -1042,7 +1042,7 @@ Likeable застосовує розумні технічні та органі 'projects.production.until': 'Always-on до {date}', 'projects.production.cname': 'Спрямуйте CNAME на {host}', 'projects.production.domain': 'Кастомний домен: {domain}', - 'projects.production.domainActive': 'DNS активний', + 'projects.production.domainActive': 'DNS перевірено', 'projects.production.domainPending': 'DNS очікує', 'projects.production.domainCheck': 'Перевірити DNS', 'projects.production.domainChecking': 'Перевіряємо DNS', diff --git a/internal/domain/types.go b/internal/domain/types.go index 3ff85b0..43dd130 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -8,8 +8,9 @@ import ( const PlaygroundIdleStopAfter = 8 * time.Hour const ( - ProjectDomainStatusPendingDNS = "pending_dns" - ProjectDomainStatusActive = "active" + ProjectDomainStatusPendingDNS = "pending_dns" + ProjectDomainStatusDNSVerified = "dns_verified" + ProjectDomainStatusActive = "active" ) type User struct { diff --git a/internal/likeable/project_domain_verification.go b/internal/likeable/project_domain_verification.go index afe7b00..f5531bb 100644 --- a/internal/likeable/project_domain_verification.go +++ b/internal/likeable/project_domain_verification.go @@ -35,7 +35,7 @@ func (s *Server) projectCustomDomainDNSStatus(ctx context.Context, domain, targe return "", err } if normalizeDNSHost(cname) == target { - return store.ProjectDomainStatusActive, nil + return store.ProjectDomainStatusDNSVerified, nil } return store.ProjectDomainStatusPendingDNS, nil } diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index 2c95a30..ea40401 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -4025,7 +4025,7 @@ func TestProjectCustomDomainRejectsDomainAlreadyLinked(t *testing.T) { } } -func TestProjectCustomDomainVerifyMarksActiveCNAME(t *testing.T) { +func TestProjectCustomDomainVerifyMarksDNSVerifiedCNAME(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { t.Fatal(err) @@ -4075,12 +4075,12 @@ func TestProjectCustomDomainVerifyMarksActiveCNAME(t *testing.T) { if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatal(err) } - if body.Project.CustomDomain != "app.customer.example" || body.Project.CustomDomainStatus != "active" { - t.Fatalf("project domain=%+v, want active custom domain", body.Project) + if body.Project.CustomDomain != "app.customer.example" || body.Project.CustomDomainStatus != "dns_verified" { + t.Fatalf("project domain=%+v, want DNS verified custom domain", body.Project) } } -func TestProjectDomainVerifySweepMarksActiveCNAME(t *testing.T) { +func TestProjectDomainVerifySweepMarksDNSVerifiedCNAME(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { t.Fatal(err) @@ -4113,8 +4113,8 @@ func TestProjectDomainVerifySweepMarksActiveCNAME(t *testing.T) { if err != nil { t.Fatal(err) } - if stored.CustomDomainStatus != "active" { - t.Fatalf("custom domain status=%q, want active", stored.CustomDomainStatus) + if stored.CustomDomainStatus != "dns_verified" { + t.Fatalf("custom domain status=%q, want DNS verified", stored.CustomDomainStatus) } } diff --git a/internal/store/types.go b/internal/store/types.go index 712aaa9..61277a6 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -29,6 +29,7 @@ type AdminUserDetail = domain.AdminUserDetail type AdminUserFilters = domain.AdminUserFilters const ( - ProjectDomainStatusPendingDNS = domain.ProjectDomainStatusPendingDNS - ProjectDomainStatusActive = domain.ProjectDomainStatusActive + ProjectDomainStatusPendingDNS = domain.ProjectDomainStatusPendingDNS + ProjectDomainStatusDNSVerified = domain.ProjectDomainStatusDNSVerified + ProjectDomainStatusActive = domain.ProjectDomainStatusActive ) From eaf1fe61e49ed8b786384bcfe85eb796fb1692ec Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 17:30:43 +0300 Subject: [PATCH 10/36] Start stopped playgrounds after production grant --- internal/likeable/admin_handlers.go | 1 + internal/likeable/production_project.go | 49 +++++++++++++++++++++++++ internal/likeable/server_test.go | 37 ++++++++++++++----- internal/likeable/stripe.go | 1 + 4 files changed, 78 insertions(+), 10 deletions(-) create mode 100644 internal/likeable/production_project.go diff --git a/internal/likeable/admin_handlers.go b/internal/likeable/admin_handlers.go index 759e72b..4520fbb 100644 --- a/internal/likeable/admin_handlers.go +++ b/internal/likeable/admin_handlers.go @@ -611,6 +611,7 @@ func (s *Server) handleAdminUserProjectProductionGrant(w http.ResponseWriter, r return } if granted { + s.startProductionProjectIfStopped(r.Context(), userID, projectID) s.notifyProductionProjectPurchased(r.Context(), userID, projectID, expiresAt) } updated, err := s.store.ProjectForUser(r.Context(), userID, projectID) diff --git a/internal/likeable/production_project.go b/internal/likeable/production_project.go new file mode 100644 index 0000000..3945686 --- /dev/null +++ b/internal/likeable/production_project.go @@ -0,0 +1,49 @@ +package likeable + +import ( + "context" + "log" + "strings" + "time" +) + +func (s *Server) startProductionProjectIfStopped(ctx context.Context, userID, projectID string) { + project, err := s.store.ProjectForUser(ctx, userID, projectID) + if err != nil { + log.Printf("load production project for start %s/%s: %v", userID, projectID, err) + return + } + if project.Status != "stopped" { + return + } + playgroundID := strings.TrimSpace(project.PlaygroundID) + if playgroundID == "" { + return + } + user, err := s.store.UserByID(ctx, userID) + if err != nil { + log.Printf("load production user for start %s: %v", userID, err) + return + } + fibeClient, err := s.fibeClientForProject(ctx, project, user.Email) + if err != nil { + log.Printf("load Fibe client for production start project=%s playground=%s: %v", project.ID, playgroundID, err) + return + } + actionCtx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + if err := fibeClient.StartPlayground(actionCtx, playgroundID); err != nil { + s.observePlatformError(err) + log.Printf("start production project playground project=%s playground=%s: %v", project.ID, playgroundID, err) + return + } + if err := s.store.UpdateProjectStatus(ctx, project.ID, user.ID, "launching"); err != nil { + log.Printf("mark production project launching project=%s playground=%s: %v", project.ID, playgroundID, err) + return + } + if err := s.store.TouchProjectPlaygroundUsage(ctx, project.ID, user.ID); err != nil { + log.Printf("touch production project usage project=%s playground=%s: %v", project.ID, playgroundID, err) + return + } + s.clearPlatformBackoff() +} diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index ea40401..6c7a090 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -6380,6 +6380,7 @@ func TestAdminCanGrantProjectProduction(t *testing.T) { t.Fatal(err) } defer appStore.Close() + cliPath, logPath, _ := fakeFibeCLI(t) server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient} admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "") if err != nil { @@ -6392,7 +6393,12 @@ func TestAdminCanGrantProjectProduction(t *testing.T) { if err := appStore.CreateSession(t.Context(), admin.ID, "admin-production-token", time.Hour); err != nil { t.Fatal(err) } - if err := appStore.UpsertConfig(t.Context(), map[string]string{"production_project_days": "14"}, secretConfigKeys); err != nil { + if err := appStore.UpsertConfig(t.Context(), map[string]string{ + "production_project_days": "14", + "fibe_base_url": "server.test:3000", + "fibe_api_key": "test-key", + "fibe_cli_path": cliPath, + }, secretConfigKeys); err != nil { t.Fatal(err) } project := &Project{ @@ -6400,8 +6406,9 @@ func TestAdminCanGrantProjectProduction(t *testing.T) { UserID: user.ID, Title: "Admin Production", ConversationID: "conv-admin-production", + AgentID: "agent-1", PlaygroundID: "playground-admin-production", - Status: "ready", + Status: "stopped", PlaygroundLastUsedAt: time.Now().UTC().Add(-9 * time.Hour).Format(time.RFC3339Nano), } if err := appStore.CreateProject(t.Context(), project); err != nil { @@ -6425,15 +6432,18 @@ func TestAdminCanGrantProjectProduction(t *testing.T) { if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatal(err) } - if !resp.Granted || resp.Days != 14 || resp.Project.ProductionExpiresAt == "" || resp.Project.PlaygroundIdleStopAt != "" { - t.Fatalf("response=%+v, want 14 day production grant without idle stop deadline", resp) + if !resp.Granted || resp.Days != 14 || resp.Project.ProductionExpiresAt == "" || resp.Project.PlaygroundIdleStopAt != "" || resp.Project.Status != "launching" { + t.Fatalf("response=%+v, want 14 day production grant with playground starting and no idle stop deadline", resp) } stored, err := appStore.ProjectForUser(t.Context(), user.ID, project.ID) if err != nil { t.Fatal(err) } - if stored.ProductionExpiresAt == "" || stored.PlaygroundIdleStopAt != "" { - t.Fatalf("stored project=%+v, want production grant reflected", stored) + if stored.ProductionExpiresAt == "" || stored.PlaygroundIdleStopAt != "" || stored.Status != "launching" { + t.Fatalf("stored project=%+v, want production grant reflected with playground starting", stored) + } + if log := readFile(t, logPath); !strings.Contains(log, "playgrounds start playground-admin-production") { + t.Fatalf("missing production start command; log=%s", log) } notices, err := appStore.NoticesForUser(t.Context(), user.ID, 10) if err != nil { @@ -7014,10 +7024,14 @@ func TestStripeWebhookGrantsProductionProject(t *testing.T) { t.Fatal(err) } defer store.Close() + cliPath, logPath, _ := fakeFibeCLI(t) if err := store.UpsertConfig(t.Context(), map[string]string{ "stripe_secret_key": "sk_test", "stripe_webhook_secret": "whsec_test", "stripe_production_project_price_id": "price_production_project", + "fibe_base_url": "server.test:3000", + "fibe_api_key": "test-key", + "fibe_cli_path": cliPath, }, secretConfigKeys); err != nil { t.Fatal(err) } @@ -7025,7 +7039,7 @@ func TestStripeWebhookGrantsProductionProject(t *testing.T) { if err != nil { t.Fatal(err) } - project := &Project{ID: "project-production-webhook", UserID: user.ID, Title: "Production webhook", ConversationID: "conv-production-webhook", Status: "ready"} + project := &Project{ID: "project-production-webhook", UserID: user.ID, Title: "Production webhook", ConversationID: "conv-production-webhook", AgentID: "agent-1", PlaygroundID: "playground-production-webhook", Status: "stopped"} if err := store.CreateProject(t.Context(), project); err != nil { t.Fatal(err) } @@ -7039,7 +7053,7 @@ func TestStripeWebhookGrantsProductionProject(t *testing.T) { Body: io.NopCloser(strings.NewReader(`{"data":[{"price":{"id":"price_production_project"}}]}`)), }, nil })} - server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: client} + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: fakeFibeHTTPClient(client, fakeFibeTransportConfig{LogPath: logPath})} event := map[string]any{ "type": "checkout.session.completed", "data": map[string]any{"object": map[string]any{ @@ -7070,8 +7084,11 @@ func TestStripeWebhookGrantsProductionProject(t *testing.T) { if err != nil { t.Fatal(err) } - if stored.ProductionExpiresAt == "" || stored.PlaygroundIdleStopAt != "" { - t.Fatalf("project=%+v, want active production grant without idle stop", stored) + if stored.ProductionExpiresAt == "" || stored.PlaygroundIdleStopAt != "" || stored.Status != "launching" { + t.Fatalf("project=%+v, want production grant with playground starting and no idle stop", stored) + } + if log := readFile(t, logPath); !strings.Contains(log, "playgrounds start playground-production-webhook") { + t.Fatalf("missing production start command; log=%s", log) } expires, err := time.Parse(time.RFC3339Nano, stored.ProductionExpiresAt) if err != nil { diff --git a/internal/likeable/stripe.go b/internal/likeable/stripe.go index c7e9e01..69fce20 100644 --- a/internal/likeable/stripe.go +++ b/internal/likeable/stripe.go @@ -396,6 +396,7 @@ func (s *Server) applyStripeCheckoutSession(ctx context.Context, cfg map[string] expiresAt := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour) if granted, err := s.store.GrantProjectProduction(ctx, userID, productionProjectID, sessionID, expiresAt); err == nil && granted { result.Granted = true + s.startProductionProjectIfStopped(ctx, userID, productionProjectID) s.notifyProductionProjectPurchased(ctx, userID, productionProjectID, expiresAt) } else if err != nil { return result, err From 5bb8ad792d73269a3fe33013e1d8bb4873d740b0 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 17:34:41 +0300 Subject: [PATCH 11/36] Sweep stopped production playgrounds --- internal/likeable/jobs.go | 55 +++++++++++++++++++++++------- internal/likeable/server_test.go | 57 ++++++++++++++++++++++++++++++++ internal/store/store_projects.go | 45 +++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 12 deletions(-) diff --git a/internal/likeable/jobs.go b/internal/likeable/jobs.go index 1822af7..530db8b 100644 --- a/internal/likeable/jobs.go +++ b/internal/likeable/jobs.go @@ -28,23 +28,25 @@ const projectCleanupSweepTimeout = 30 * time.Second const projectDeletionSweepInterval = 5 * time.Minute const projectDeletionSweepUniqueTTL = 4 * time.Minute const projectDomainVerificationSweepUniqueTTL = 50 * time.Minute +const productionProjectStartSweepUniqueTTL = 30 * time.Minute const maxConcurrentProjectCleanup = 1 const defaultJobWorkerConcurrency = 32 const idleProjectStopAfter = domain.PlaygroundIdleStopAfter const ( - taskProvisionProject = "likeable:project:provision" - taskRecoverProject = "likeable:project:recover" - taskDeleteProjectResources = "likeable:project:delete_resources" - taskDeleteAccount = "likeable:account:delete" - taskProjectDeletionSweep = "likeable:project:deletion_sweep" - taskArchiveDeleteProject = "likeable:project:archive_delete" - taskStopIdleProjectsSweep = "likeable:project:stop_idle_sweep" - taskStopIdleProject = "likeable:project:stop_idle" - taskMonitorProjectNotifications = "likeable:project:monitor_notifications" - taskSendEmail = "likeable:email:send" - taskProjectQuotaSweep = "likeable:project_quota:sweep" - taskProjectDomainVerifySweep = "likeable:project_domain:verify_sweep" + taskProvisionProject = "likeable:project:provision" + taskRecoverProject = "likeable:project:recover" + taskDeleteProjectResources = "likeable:project:delete_resources" + taskDeleteAccount = "likeable:account:delete" + taskProjectDeletionSweep = "likeable:project:deletion_sweep" + taskArchiveDeleteProject = "likeable:project:archive_delete" + taskStopIdleProjectsSweep = "likeable:project:stop_idle_sweep" + taskStopIdleProject = "likeable:project:stop_idle" + taskStartProductionProjectsSweep = "likeable:project:start_production_sweep" + taskMonitorProjectNotifications = "likeable:project:monitor_notifications" + taskSendEmail = "likeable:email:send" + taskProjectQuotaSweep = "likeable:project_quota:sweep" + taskProjectDomainVerifySweep = "likeable:project_domain:verify_sweep" ) var errProjectCleanupConcurrencyLimit = errors.New("project cleanup concurrency limit reached") @@ -84,6 +86,7 @@ func newJobSystem(redisOpt asynq.RedisClientOpt, s *Server) *JobSystem { mux.HandleFunc(taskArchiveDeleteProject, s.handleArchiveDeleteProjectTask) mux.HandleFunc(taskStopIdleProjectsSweep, s.handleStopIdleProjectsSweepTask) mux.HandleFunc(taskStopIdleProject, s.handleStopIdleProjectTask) + mux.HandleFunc(taskStartProductionProjectsSweep, s.handleStartProductionProjectsSweepTask) mux.HandleFunc(taskMonitorProjectNotifications, s.handleMonitorProjectNotificationsTask) mux.HandleFunc(taskSendEmail, s.handleSendEmailTask) mux.HandleFunc(taskProjectQuotaSweep, s.handleProjectQuotaSweepTask) @@ -272,6 +275,20 @@ func (s *Server) enqueueProjectDomainVerifySweep(ctx context.Context, delay time } } +func (s *Server) enqueueStartProductionProjectsSweep(ctx context.Context, delay time.Duration) { + if s.jobs == nil { + return + } + opts := []asynq.Option{asynq.Queue("low"), asynq.MaxRetry(2), asynq.Timeout(15 * time.Minute), asynq.Unique(productionProjectStartSweepUniqueTTL)} + if delay > 0 { + opts = append(opts, asynq.ProcessIn(delay)) + } + _, err := s.jobs.client.EnqueueContext(ctx, asynq.NewTask(taskStartProductionProjectsSweep, nil), opts...) + if err != nil && !errors.Is(err, asynq.ErrDuplicateTask) { + log.Printf("enqueue production playground start sweep: %v", err) + } +} + func (s *Server) startRecurringJobs(ctx context.Context) { if s.jobs == nil { return @@ -280,6 +297,7 @@ func (s *Server) startRecurringJobs(ctx context.Context) { s.enqueueProjectDeletionSweep(ctx, 0) s.enqueueStopIdleProjectsSweep(ctx, 0) s.enqueueProjectDomainVerifySweep(ctx, 0) + s.enqueueStartProductionProjectsSweep(ctx, 0) go func() { hourlyTicker := time.NewTicker(time.Hour) deletionTicker := time.NewTicker(projectDeletionSweepInterval) @@ -296,6 +314,7 @@ func (s *Server) startRecurringJobs(ctx context.Context) { s.enqueueProjectDeletionSweep(context.Background(), 0) s.enqueueStopIdleProjectsSweep(context.Background(), 0) s.enqueueProjectDomainVerifySweep(context.Background(), 0) + s.enqueueStartProductionProjectsSweep(context.Background(), 0) } } }() @@ -653,6 +672,18 @@ func (s *Server) handleProjectDomainVerifySweepTask(ctx context.Context, _ *asyn return nil } +func (s *Server) handleStartProductionProjectsSweepTask(ctx context.Context, _ *asynq.Task) error { + projects, err := s.store.StoppedProductionProjects(ctx, 100) + if err != nil { + return err + } + for i := range projects { + project := &projects[i] + s.startProductionProjectIfStopped(ctx, project.UserID, project.ID) + } + return nil +} + func (s *Server) handleStopIdleProjectsSweepTask(ctx context.Context, _ *asynq.Task) error { cutoff := time.Now().UTC().Add(-idleProjectStopAfter) projects, err := s.store.IdleProjectsForPlaygroundStop(ctx, cutoff, 100) diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index 6c7a090..ae9b2d5 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -3846,6 +3846,63 @@ func TestProductionProjectPlaygroundCannotBeStopped(t *testing.T) { } } +func TestProductionProjectStartSweepStartsStoppedProductionProjects(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + cliPath, logPath, _ := fakeFibeCLI(t) + if err := store.UpsertConfig(t.Context(), map[string]string{ + "fibe_base_url": "server.test:3000", + "fibe_api_key": "test-key", + "fibe_cli_path": cliPath, + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient} + user, err := store.UpsertUser(t.Context(), "production-sweep@example.com", "Production Sweep", "") + if err != nil { + t.Fatal(err) + } + production := &Project{ID: "project-production-sweep", UserID: user.ID, Title: "Production Sweep", ConversationID: "conv-production-sweep", AgentID: "agent-1", PlaygroundID: "playground-production-sweep", Status: "stopped"} + ordinary := &Project{ID: "project-ordinary-sweep", UserID: user.ID, Title: "Ordinary Sweep", ConversationID: "conv-ordinary-sweep", AgentID: "agent-1", PlaygroundID: "playground-ordinary-sweep", Status: "stopped"} + for _, project := range []*Project{production, ordinary} { + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + } + if granted, err := store.GrantProjectProduction(t.Context(), user.ID, production.ID, "cs_production_sweep", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted { + t.Fatalf("production grant=%v err=%v, want granted", granted, err) + } + + if err := server.handleStartProductionProjectsSweepTask(t.Context(), asynq.NewTask(taskStartProductionProjectsSweep, nil)); err != nil { + t.Fatalf("production start sweep returned error: %v", err) + } + + storedProduction, err := store.ProjectForUser(t.Context(), user.ID, production.ID) + if err != nil { + t.Fatal(err) + } + if storedProduction.Status != "launching" || storedProduction.ProductionExpiresAt == "" || storedProduction.PlaygroundIdleStopAt != "" { + t.Fatalf("production project=%+v, want launching production project without idle stop", storedProduction) + } + storedOrdinary, err := store.ProjectForUser(t.Context(), user.ID, ordinary.ID) + if err != nil { + t.Fatal(err) + } + if storedOrdinary.Status != "stopped" { + t.Fatalf("ordinary project status=%q, want stopped", storedOrdinary.Status) + } + log := readFile(t, logPath) + if !strings.Contains(log, "playgrounds start playground-production-sweep") { + t.Fatalf("missing production start command; log=%s", log) + } + if strings.Contains(log, "playgrounds start playground-ordinary-sweep") { + t.Fatalf("unexpected ordinary start command; log=%s", log) + } +} + func TestProjectCustomDomainRequiresProductionProject(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { diff --git a/internal/store/store_projects.go b/internal/store/store_projects.go index 0321e0c..6a2b74f 100644 --- a/internal/store/store_projects.go +++ b/internal/store/store_projects.go @@ -518,6 +518,51 @@ func (s *Store) DeletingProjects(ctx context.Context, limit int) ([]Project, err return out, nil } +func (s *Store) StoppedProductionProjects(ctx context.Context, limit int) ([]Project, error) { + if limit <= 0 || limit > 1000 { + limit = 100 + } + rows, err := s.db.QueryContext(ctx, ` + SELECT id, user_id, title, conversation_id, agent_id, marquee_id, playground_id, playground_name, playspec_id, prop_id, repo_url, preview_url, selected_service_name, status, error_message, provisioning_lock_until, cleanup_last_error, playground_last_used_at, created_at, updated_at + FROM projects + WHERE status = 'stopped' AND TRIM(playground_id) != '' + AND EXISTS ( + SELECT 1 + FROM project_production_grants + WHERE project_production_grants.project_id = projects.id + AND project_production_grants.expires_at > ? + ) + ORDER BY updated_at ASC + LIMIT ? + `, nowString(), limit) + if err != nil { + return nil, err + } + var out []Project + for rows.Next() { + project, err := scanProject(rows) + if err != nil { + _ = rows.Close() + return nil, err + } + out = append(out, *project) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := s.attachProjectResourcesForProjects(ctx, out); err != nil { + return nil, err + } + if out == nil { + out = []Project{} + } + return out, nil +} + func (s *Store) IdleProjectsForPlaygroundStop(ctx context.Context, cutoff time.Time, limit int) ([]Project, error) { if limit <= 0 || limit > 1000 { limit = 100 From 3b6ffa0b53ef54d4b69608ca061791067b4ce780 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 17:50:37 +0300 Subject: [PATCH 12/36] Surface production runtime billing blocks --- internal/fibe/fibe_cli.go | 40 ++++++++++++++++ internal/fibe/fibe_test.go | 30 ++++++++++++ internal/likeable/notifications.go | 16 +++++++ internal/likeable/production_project.go | 7 +++ internal/likeable/server_test.go | 61 +++++++++++++++++++++++++ internal/likeable/test_helpers_test.go | 10 ++++ internal/store/store_projects.go | 2 + internal/store/store_projects_test.go | 10 ++++ 8 files changed, 176 insertions(+) diff --git a/internal/fibe/fibe_cli.go b/internal/fibe/fibe_cli.go index 13af0e5..1664dd4 100644 --- a/internal/fibe/fibe_cli.go +++ b/internal/fibe/fibe_cli.go @@ -45,6 +45,8 @@ func (e *PlatformError) PublicProjectErrorKind() string { switch { case e.Status == 401 || e.Status == 403: return "configuration" + case IsRuntimeBillingRequiredError(e): + return "runtime_billing" case isProvisioningConfigurationPlatformError(e, message): return "configuration" default: @@ -64,6 +66,9 @@ func IsRetryableProvisioningError(err error) bool { if !errors.As(err, &platform) { return false } + if IsRuntimeBillingRequiredError(platform) { + return false + } code := strings.ToUpper(strings.TrimSpace(platform.Code)) message := strings.ToLower(strings.TrimSpace(platform.Message + "\n" + platform.Stderr)) if isProvisioningConfigurationPlatformError(platform, message) { @@ -179,6 +184,41 @@ func IsPlaygroundMissingError(err error) bool { return strings.Contains(message, "playground") && containsAny(message, "not found", "missing") } +func IsRuntimeBillingRequiredError(err error) bool { + if err == nil { + return false + } + var platform *PlatformError + if errors.As(err, &platform) { + code := strings.ToUpper(strings.TrimSpace(platform.Code)) + message := strings.ToLower(strings.TrimSpace(platform.Message + "\n" + platform.Stderr)) + if platform.Status == 402 { + return true + } + if code == "MARQUEE_NOT_FUNDED" || code == "PAYMENT_REQUIRED" || code == "RUNTIME_ENTITLEMENT_REQUIRED" { + return true + } + if containsAny(message, + "unexpected status 402", + "marquee_not_funded", + "not funded", + "payment required", + "runtime entitlement", + "billing_runtime_active", + ) { + return true + } + } + message := strings.ToLower(strings.TrimSpace(err.Error())) + return containsAny(message, + "unexpected status 402", + "marquee_not_funded", + "not funded", + "payment required", + "runtime entitlement", + ) +} + func containsAny(value string, needles ...string) bool { for _, needle := range needles { if strings.Contains(value, needle) { diff --git a/internal/fibe/fibe_test.go b/internal/fibe/fibe_test.go index 8ba5489..b9b7fbd 100644 --- a/internal/fibe/fibe_test.go +++ b/internal/fibe/fibe_test.go @@ -118,6 +118,11 @@ func TestIsRetryableProvisioningError(t *testing.T) { err: &PlatformError{Code: "FIBE_CONFIGURATION_ERROR", Message: "Fibe platform is not configured"}, want: false, }, + { + name: "runtime billing required", + err: &PlatformError{Code: "INTERNAL_ERROR", Status: 402, Message: "unexpected status 402"}, + want: false, + }, { name: "plain error", err: errors.New("greenfield failed"), @@ -150,6 +155,13 @@ func TestPlatformErrorPublicProjectErrorKindDoesNotClassifyMirrorLagAsConfigurat } } +func TestPlatformErrorPublicProjectErrorKindClassifiesRuntimeBilling(t *testing.T) { + err := &PlatformError{Code: "INTERNAL_ERROR", Status: 402, Message: "unexpected status 402"} + if got := err.PublicProjectErrorKind(); got != "runtime_billing" { + t.Fatalf("PublicProjectErrorKind(%v)=%q, want runtime_billing", err, got) + } +} + func TestNewClientConfiguresSDK(t *testing.T) { client, err := NewClient(Config{ BaseURL: testFibeBaseURL(), @@ -209,6 +221,24 @@ func TestIsPlaygroundMissingError(t *testing.T) { } } +func TestIsRuntimeBillingRequiredError(t *testing.T) { + for _, err := range []error{ + &PlatformError{Code: "INTERNAL_ERROR", Status: 402, Message: "unexpected status 402"}, + &PlatformError{Code: "MARQUEE_NOT_FUNDED", Status: 422, Message: "This Marquee is not funded. Fund it to continue."}, + errors.New("fibe: INTERNAL_ERROR (402): unexpected status 402"), + } { + if !IsRuntimeBillingRequiredError(err) { + t.Fatalf("IsRuntimeBillingRequiredError(%v)=false, want true", err) + } + } + if IsRuntimeBillingRequiredError(&PlatformError{Code: "INTERNAL_ERROR", Status: 422, Message: "unexpected status 422"}) { + t.Fatal("generic internal 422 must not look like runtime billing") + } + if IsRuntimeBillingRequiredError(errors.New("payment settings page is unavailable")) { + t.Fatal("generic payment text must not look like runtime billing") + } +} + func TestIsAgentRuntimeUnavailableError(t *testing.T) { for _, err := range []error{ &PlatformError{Code: "UNPROCESSABLE_ENTITY", Status: 422, Message: "No running AgentChat for Agent#1"}, diff --git a/internal/likeable/notifications.go b/internal/likeable/notifications.go index b3deddf..27f98f4 100644 --- a/internal/likeable/notifications.go +++ b/internal/likeable/notifications.go @@ -171,6 +171,22 @@ func (s *Server) notifyProductionProjectPurchased(ctx context.Context, userID, p s.addSystemNoticeAndEmail(ctx, user, "info", body, "Likeable production project enabled", body+"\n\nOpen Likeable:\n"+s.config.BaseURL) } +func (s *Server) notifyProductionProjectStartBlocked(ctx context.Context, user *User, project *Project) { + if user == nil || project == nil { + return + } + prefix := fmt.Sprintf("Production runtime paused: %q", project.Title) + exists, err := s.store.NoticeExistsSince(ctx, user.ID, "system", prefix, time.Now().UTC().Add(-24*time.Hour)) + if err == nil && exists { + return + } + if err != nil { + log.Printf("production runtime notice dedupe for %s project=%s: %v", user.Email, project.ID, err) + } + body := prefix + " has an active production grant, but the linked Fibe runtime is not funded yet. Support must fund the runtime, then Likeable will retry starting it automatically." + s.addSystemNoticeAndEmail(ctx, user, "warning", body, "Likeable production runtime paused", body+"\n\nOpen Likeable:\n"+s.config.BaseURL) +} + func (s *Server) notifyProjectExportReady(ctx context.Context, user *User, project *Project, repoURL string) { if user == nil || project == nil || strings.TrimSpace(repoURL) == "" { return diff --git a/internal/likeable/production_project.go b/internal/likeable/production_project.go index 3945686..6a67675 100644 --- a/internal/likeable/production_project.go +++ b/internal/likeable/production_project.go @@ -5,6 +5,8 @@ import ( "log" "strings" "time" + + fibegateway "github.com/fibegg/likeable/internal/fibe" ) func (s *Server) startProductionProjectIfStopped(ctx context.Context, userID, projectID string) { @@ -34,6 +36,11 @@ func (s *Server) startProductionProjectIfStopped(ctx context.Context, userID, pr defer cancel() if err := fibeClient.StartPlayground(actionCtx, playgroundID); err != nil { s.observePlatformError(err) + if fibegateway.IsRuntimeBillingRequiredError(err) { + log.Printf("start production project blocked by Fibe runtime billing project=%s playground=%s: %v", project.ID, playgroundID, err) + s.notifyProductionProjectStartBlocked(ctx, user, project) + return + } log.Printf("start production project playground project=%s playground=%s: %v", project.ID, playgroundID, err) return } diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index ae9b2d5..906e825 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -6511,6 +6511,67 @@ func TestAdminCanGrantProjectProduction(t *testing.T) { } } +func TestProductionProjectStartBlockedByRuntimeBillingNotifiesUser(t *testing.T) { + appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer appStore.Close() + if err := appStore.UpsertConfig(t.Context(), map[string]string{ + "fibe_base_url": "server.test:3000", + "fibe_api_key": "test-key", + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + user, err := appStore.UpsertUser(t.Context(), "runtime-billing@example.com", "Runtime Billing", "") + if err != nil { + t.Fatal(err) + } + project := &Project{ + ID: "project-runtime-billing", + UserID: user.ID, + Title: "Runtime billing", + ConversationID: "conv-runtime-billing", + AgentID: "agent-1", + MarqueeID: "server-1", + PlaygroundID: "playground-runtime-billing", + Status: "stopped", + } + if err := appStore.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + if granted, err := appStore.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_runtime_billing", time.Now().UTC().Add(7*24*time.Hour)); err != nil || !granted { + t.Fatalf("production grant=%v err=%v, want granted", granted, err) + } + logPath := filepath.Join(t.TempDir(), "fibe.log") + server := &Server{ + store: appStore, + config: RuntimeConfig{BaseURL: "http://example.test"}, + http: fakeFibeHTTPClient(http.DefaultClient, fakeFibeTransportConfig{Mode: "runtime-billing-required", LogPath: logPath}), + } + + server.startProductionProjectIfStopped(t.Context(), user.ID, project.ID) + server.startProductionProjectIfStopped(t.Context(), user.ID, project.ID) + + stored, err := appStore.ProjectForUser(t.Context(), user.ID, project.ID) + if err != nil { + t.Fatal(err) + } + if stored.Status != "stopped" { + t.Fatalf("status=%q, want stopped after blocked start", stored.Status) + } + notices, err := appStore.NoticesForUser(t.Context(), user.ID, 10) + if err != nil { + t.Fatal(err) + } + if len(notices) != 1 || notices[0].Severity != "warning" || !strings.Contains(notices[0].Body, "Production runtime paused") || !strings.Contains(notices[0].Body, "not funded") { + t.Fatalf("notices=%+v, want one runtime billing warning", notices) + } + if log := readFile(t, logPath); strings.Count(log, "playgrounds start playground-runtime-billing") != 2 { + t.Fatalf("log=%s, want two start attempts", log) + } +} + func TestAdminProductionGrantRejectsInactiveProject(t *testing.T) { appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { diff --git a/internal/likeable/test_helpers_test.go b/internal/likeable/test_helpers_test.go index 1bd1fc5..d50022f 100644 --- a/internal/likeable/test_helpers_test.go +++ b/internal/likeable/test_helpers_test.go @@ -275,6 +275,8 @@ func (rt *fakeFibeTransport) roundTripWithBody(req *http.Request, bodyBytes []by return rt.roundTripAlreadyStopped(req, methodPath, bodyBytes), nil case "missing-playground": return rt.roundTripMissingPlayground(req, methodPath, bodyBytes), nil + case "runtime-billing-required": + return rt.roundTripRuntimeBillingRequired(req, methodPath, bodyBytes), nil case "hydration-fail": if req.Method == http.MethodDelete && strings.Contains(req.URL.Path, "playground-bad") { return fakeJSONResponse(req, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": "BAD_REQUEST", "message": "delete failed after debug failed"}}), nil @@ -459,6 +461,14 @@ func (rt *fakeFibeTransport) roundTripMissingPlayground(req *http.Request, metho return rt.roundTripDefault(req, methodPath, body) } +func (rt *fakeFibeTransport) roundTripRuntimeBillingRequired(req *http.Request, methodPath string, body []byte) *http.Response { + if strings.HasSuffix(req.URL.Path, "/operations") && actionTypeFromBody(body) == "start" { + rt.log("playgrounds start " + resourceIDFromPath(req.URL.Path, "playgrounds")) + return fakeJSONResponse(req, http.StatusPaymentRequired, map[string]any{"error": map[string]any{"code": "INTERNAL_ERROR", "message": "unexpected status 402"}}) + } + return rt.roundTripDefault(req, methodPath, body) +} + func agentIDFromPath(path string) string { return resourceIDFromPath(path, "agents") } diff --git a/internal/store/store_projects.go b/internal/store/store_projects.go index 6a2b74f..f7c1bf9 100644 --- a/internal/store/store_projects.go +++ b/internal/store/store_projects.go @@ -152,6 +152,8 @@ func publicProjectErrorMessageFromError(err error) string { switch classified.PublicProjectErrorKind() { case "configuration": return "Workspace settings are incomplete. Ask an admin to review the configuration, then create a new project." + case "runtime_billing": + return "The workspace runtime is not funded. Ask an admin to fund the linked Fibe workspace, then retry starting the project." case "timeout": return "The canvas took too long to start. Try creating a new project." } diff --git a/internal/store/store_projects_test.go b/internal/store/store_projects_test.go index b317f86..d5a402e 100644 --- a/internal/store/store_projects_test.go +++ b/internal/store/store_projects_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "testing" "time" + + "github.com/fibegg/likeable/internal/fibe" ) func TestTryAcquireProjectProvisioningLeasesProject(t *testing.T) { @@ -443,3 +445,11 @@ func TestPublicProjectErrorMessageExplainsLinkedFibePlaygroundError(t *testing.T t.Fatalf("message=%q, want %q", got, want) } } + +func TestPublicProjectErrorMessageExplainsRuntimeBilling(t *testing.T) { + got := publicProjectErrorMessageFromError(&fibe.PlatformError{Code: "INTERNAL_ERROR", Status: 402, Message: "unexpected status 402"}) + want := "The workspace runtime is not funded. Ask an admin to fund the linked Fibe workspace, then retry starting the project." + if got != want { + t.Fatalf("message=%q, want %q", got, want) + } +} From 97c1012a27af81d3514aac678c73f6b62fdf082f Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 17:59:48 +0300 Subject: [PATCH 13/36] Clarify custom domain routing status --- frontend/src/i18n/translations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index b1a61a7..f76dafe 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -362,7 +362,7 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'projects.production.until': 'Always-on until {date}', 'projects.production.cname': 'Point CNAME to {host}', 'projects.production.domain': 'Custom domain: {domain}', - 'projects.production.domainActive': 'DNS verified', + 'projects.production.domainActive': 'DNS verified · routing pending', 'projects.production.domainPending': 'DNS pending', 'projects.production.domainCheck': 'Check DNS', 'projects.production.domainChecking': 'Checking DNS', @@ -1042,7 +1042,7 @@ Likeable застосовує розумні технічні та органі 'projects.production.until': 'Always-on до {date}', 'projects.production.cname': 'Спрямуйте CNAME на {host}', 'projects.production.domain': 'Кастомний домен: {domain}', - 'projects.production.domainActive': 'DNS перевірено', + 'projects.production.domainActive': 'DNS перевірено · маршрутизація очікує', 'projects.production.domainPending': 'DNS очікує', 'projects.production.domainCheck': 'Перевірити DNS', 'projects.production.domainChecking': 'Перевіряємо DNS', From 84e337f2c9a04e8fd4546450b9c4f749977d7d0a Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 18:15:29 +0300 Subject: [PATCH 14/36] Handle runtime billing on manual starts --- internal/likeable/project_handlers.go | 9 +++- internal/likeable/server_test.go | 72 +++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/internal/likeable/project_handlers.go b/internal/likeable/project_handlers.go index 7d621e1..73e0015 100644 --- a/internal/likeable/project_handlers.go +++ b/internal/likeable/project_handlers.go @@ -302,7 +302,8 @@ func (s *Server) handleProjectPlaygroundAction(w http.ResponseWriter, r *http.Re writeError(w, http.StatusBadRequest, "invalid json") return } - updated, err := s.controlProjectPlayground(r.Context(), user, project, strings.ToLower(strings.TrimSpace(body.Action))) + action := strings.ToLower(strings.TrimSpace(body.Action)) + updated, err := s.controlProjectPlayground(r.Context(), user, project, action) if err != nil { if errors.Is(err, errProjectExportOnly) || errors.Is(err, errProjectRetiring) || errors.Is(err, errProductionProjectCannotStop) { writeError(w, http.StatusConflict, developmentBlockedMessage(err)) @@ -312,6 +313,12 @@ func (s *Server) handleProjectPlaygroundAction(w http.ResponseWriter, r *http.Re writeError(w, http.StatusBadRequest, err.Error()) return } + if action == "start" && strings.TrimSpace(project.ProductionExpiresAt) != "" && fibegateway.IsRuntimeBillingRequiredError(err) { + log.Printf("project playground start blocked by Fibe runtime billing project=%s playground=%s: %v", project.ID, strings.TrimSpace(project.PlaygroundID), err) + s.notifyProductionProjectStartBlocked(r.Context(), user, project) + writeError(w, http.StatusConflict, "production runtime is not funded yet; support has been notified and Likeable will retry starting it automatically") + return + } log.Printf("project playground action %s for project %s: %v", body.Action, project.ID, err) if isPlatformRateLimited(err) { writeError(w, http.StatusServiceUnavailable, "workspace platform is rate limited; try again shortly") diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index 906e825..2da4762 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -3846,6 +3846,78 @@ func TestProductionProjectPlaygroundCannotBeStopped(t *testing.T) { } } +func TestProductionProjectPlaygroundStartRuntimeBillingReturnsConflict(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err := store.UpsertConfig(t.Context(), map[string]string{ + "fibe_base_url": "server.test:3000", + "fibe_api_key": "test-key", + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + logPath := filepath.Join(t.TempDir(), "fibe.log") + server := &Server{ + store: store, + config: RuntimeConfig{BaseURL: "http://example.test"}, + http: fakeFibeHTTPClient(http.DefaultClient, fakeFibeTransportConfig{Mode: "runtime-billing-required", LogPath: logPath}), + } + user, err := store.UpsertUser(t.Context(), "production-start-billing@example.com", "Production Start Billing", "") + if err != nil { + t.Fatal(err) + } + if err := store.CreateSession(t.Context(), user.ID, "production-start-billing-token", time.Hour); err != nil { + t.Fatal(err) + } + project := &Project{ + ID: "project-production-start-billing", + UserID: user.ID, + Title: "Production Billing", + ConversationID: "conv-production-start-billing", + AgentID: "agent-1", + MarqueeID: "server-1", + PlaygroundID: "playground-production-start-billing", + Status: "stopped", + } + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + if granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_production_start_billing", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted { + t.Fatalf("production grant=%v err=%v, want granted", granted, err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/projects/project-production-start-billing/playground", strings.NewReader(`{"action":"start"}`)) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "production-start-billing-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("start returned %d, want 409; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "production runtime is not funded yet") { + t.Fatalf("body=%s, want runtime billing message", rec.Body.String()) + } + stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID) + if err != nil { + t.Fatal(err) + } + if stored.Status != "stopped" { + t.Fatalf("status=%q, want stopped after blocked start", stored.Status) + } + notices, err := store.NoticesForUser(t.Context(), user.ID, 10) + if err != nil { + t.Fatal(err) + } + if len(notices) != 1 || notices[0].Severity != "warning" || !strings.Contains(notices[0].Body, "Production runtime paused") || !strings.Contains(notices[0].Body, "not funded") { + t.Fatalf("notices=%+v, want one runtime billing warning", notices) + } + if log := readFile(t, logPath); strings.Count(log, "playgrounds start playground-production-start-billing") != 1 { + t.Fatalf("log=%s, want one start attempt", log) + } +} + func TestProductionProjectStartSweepStartsStoppedProductionProjects(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { From 596d54fe8df13f170f2b56649ab39c7305485b42 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 18:30:09 +0300 Subject: [PATCH 15/36] Code runtime billing start failures --- internal/likeable/project_handlers.go | 4 +++- internal/likeable/server_test.go | 12 ++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/likeable/project_handlers.go b/internal/likeable/project_handlers.go index 73e0015..9a37d4d 100644 --- a/internal/likeable/project_handlers.go +++ b/internal/likeable/project_handlers.go @@ -23,6 +23,8 @@ var ( errProductionProjectCannotStop = errors.New("production project cannot be stopped") ) +const productionRuntimeBillingRequiredMessage = "production runtime is not funded yet; support has been notified and Likeable will retry starting it automatically" + func (s *Server) handleProjects(w http.ResponseWriter, r *http.Request) { user := userFromContext(r.Context()) switch r.Method { @@ -316,7 +318,7 @@ func (s *Server) handleProjectPlaygroundAction(w http.ResponseWriter, r *http.Re if action == "start" && strings.TrimSpace(project.ProductionExpiresAt) != "" && fibegateway.IsRuntimeBillingRequiredError(err) { log.Printf("project playground start blocked by Fibe runtime billing project=%s playground=%s: %v", project.ID, strings.TrimSpace(project.PlaygroundID), err) s.notifyProductionProjectStartBlocked(r.Context(), user, project) - writeError(w, http.StatusConflict, "production runtime is not funded yet; support has been notified and Likeable will retry starting it automatically") + writeErrorCode(w, http.StatusConflict, "runtime_billing_required", productionRuntimeBillingRequiredMessage) return } log.Printf("project playground action %s for project %s: %v", body.Action, project.ID, err) diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index 2da4762..a86826e 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -3896,8 +3896,16 @@ func TestProductionProjectPlaygroundStartRuntimeBillingReturnsConflict(t *testin if rec.Code != http.StatusConflict { t.Fatalf("start returned %d, want 409; body=%s", rec.Code, rec.Body.String()) } - if !strings.Contains(rec.Body.String(), "production runtime is not funded yet") { - t.Fatalf("body=%s, want runtime billing message", rec.Body.String()) + var errorBody struct { + Error string `json:"error"` + Code string `json:"code"` + Message string `json:"message"` + } + if err := json.NewDecoder(rec.Body).Decode(&errorBody); err != nil { + t.Fatal(err) + } + if errorBody.Code != "runtime_billing_required" || errorBody.Message != errorBody.Error || !strings.Contains(errorBody.Error, "production runtime is not funded yet") { + t.Fatalf("errorBody=%+v, want coded runtime billing message", errorBody) } stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID) if err != nil { From 5f9ca353cb959126ab90955f1e340107d79f5da3 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 19:01:59 +0300 Subject: [PATCH 16/36] Add admin production start retry --- frontend/src/admin.tsx | 41 +++++++ frontend/src/domain.ts | 2 +- frontend/src/i18n/translations.ts | 4 + internal/domain/types.go | 25 ++-- internal/likeable/admin_handlers.go | 96 ++++++++++++++++ internal/likeable/server_test.go | 170 ++++++++++++++++++++++++++++ 6 files changed, 326 insertions(+), 12 deletions(-) diff --git a/frontend/src/admin.tsx b/frontend/src/admin.tsx index fdd9199..bcab5b3 100644 --- a/frontend/src/admin.tsx +++ b/frontend/src/admin.tsx @@ -21,6 +21,7 @@ function AdminCustomersPanel() { const [actionError, setActionError] = useState(''); const [reassigningProjectID, setReassigningProjectID] = useState(''); const [productionGrantingProjectID, setProductionGrantingProjectID] = useState(''); + const [productionStartingProjectID, setProductionStartingProjectID] = useState(''); const [diagnosticsProjectID, setDiagnosticsProjectID] = useState(''); const [diagnostics, setDiagnostics] = useState(null); const [diagnosticsLoadingID, setDiagnosticsLoadingID] = useState(''); @@ -278,6 +279,36 @@ function AdminCustomersPanel() { setDiagnosticsLoadingID(''); } }; + const refreshOpenProjectDiagnostics = async (projectID: string) => { + if (!selectedUserID || diagnosticsProjectID !== projectID) return; + try { + const response = await api(`/api/admin/users/${selectedUserID}/projects/${projectID}/diagnostics`); + setDiagnostics(response.diagnostics); + } catch { + setDiagnosticsProjectID(''); + setDiagnostics(null); + } + }; + const retryProductionStart = async (projectID: string) => { + if (!selectedUserID) return; + setActionError(''); + setProductionStartingProjectID(projectID); + try { + const response = await api<{ detail: AdminUserDetail; warning?: string }>(`/api/admin/users/${selectedUserID}/projects/${projectID}/production/start`, { + method: 'POST', + body: JSON.stringify({}) + }); + setDetail(response.detail); + setAgentPool((current) => response.detail.agentPool ?? current); + if (response.warning) setActionError(response.warning); + await refreshOpenProjectDiagnostics(projectID); + await loadUsers(); + } catch (err) { + setActionError(err instanceof Error ? err.message : t('admin.productionStart.failed')); + } finally { + setProductionStartingProjectID(''); + } + }; return (
@@ -482,6 +513,7 @@ function AdminCustomersPanel() { const assignment = item.assignment; const assignmentValue = pairValue(assignment?.agentId ?? '', assignment?.serverId ?? ''); const canReassign = options.some((option) => option.status === 'active') && item.project.status !== 'archived' && item.project.status !== 'deleting'; + const canRetryProductionStart = Boolean(item.project.productionExpiresAt) && item.project.status === 'stopped'; const previewUrl = item.project.previewUrl; const projectDiagnostics = diagnosticsProjectID === item.project.id ? diagnostics : null; return ( @@ -517,6 +549,12 @@ function AdminCustomersPanel() { {productionGrantingProjectID === item.project.id ? : } {item.project.productionExpiresAt ? t('admin.productionGrant.extend') : t('admin.productionGrant.enable')} + {item.project.productionExpiresAt && ( + + )}
@@ -590,6 +628,9 @@ function diagnosticEntries(diagnostics: AdminProjectDiagnostics): [string, strin ['repo_url', internal.repoUrl ?? ''], ['selected_service', diagnostics.project.selectedServiceName ?? ''], ['production_expires_at', diagnostics.project.productionExpiresAt ?? ''], + ['production_runtime_status', internal.productionRuntimeStatus ?? ''], + ['production_runtime_blocked_at', internal.productionRuntimeBlockedAt ?? ''], + ['production_runtime_message', internal.productionRuntimeMessage ?? ''], ['custom_domain', diagnostics.project.customDomain ?? ''], ['custom_domain_status', diagnostics.project.customDomainStatus ?? ''], ['custom_domain_target', diagnostics.project.customDomainTarget ?? ''], diff --git a/frontend/src/domain.ts b/frontend/src/domain.ts index dc6f341..379da99 100644 --- a/frontend/src/domain.ts +++ b/frontend/src/domain.ts @@ -65,7 +65,7 @@ export type AdminProjectSummary = { project: Project; workMs: number; assignment export type AdminUserDetail = { summary: AdminUserSummary; projects: AdminProjectSummary[]; notices: UserNotice[]; agentPool?: AgentPoolOption[] }; export type AdminUsersResponse = { users: AdminUserSummary[]; agentPool?: AgentPoolOption[]; pagination: { page: number; perPage: number; total: number } }; export type AdminBillingPayment = { id: string; userId: string; userEmail: string; providerPaymentId: string; amountCents: number; currency: string; status: string; createdAt: string }; -export type AdminProjectInternal = { userId: string; conversationId?: string; agentId?: string; serverId?: string; playgroundId?: string; playgroundName?: string; playspecId?: string; propId?: string; repoUrl?: string; provisioningLockUntil?: string; cleanupLastError?: string }; +export type AdminProjectInternal = { userId: string; conversationId?: string; agentId?: string; serverId?: string; playgroundId?: string; playgroundName?: string; playspecId?: string; propId?: string; repoUrl?: string; provisioningLockUntil?: string; cleanupLastError?: string; productionRuntimeStatus?: string; productionRuntimeMessage?: string; productionRuntimeBlockedAt?: string }; export type AdminProjectWorkSession = { projectId: string; userId: string; sessionKey: string; startedAt: string; completedAt?: string; elapsedMs: number; freeBilledMs: number; paidBilledMs: number; billedAt?: string; createdAt: string; updatedAt: string }; export type AdminHourCreditLedgerEntry = { id: string; userId: string; deltaMs: number; reason: string; paymentId?: string; workSessionKey?: string; createdAt: string }; export type AdminProjectDiagnostics = { project: Project; internal: AdminProjectInternal; workSessions: AdminProjectWorkSession[]; hourLedger: AdminHourCreditLedgerEntry[]; payments: AdminBillingPayment[] }; diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index f76dafe..f4f0b92 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -581,6 +581,8 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'admin.productionGrant.enable': 'Enable production', 'admin.productionGrant.extend': 'Extend production', 'admin.productionGrant.failed': 'Could not enable production mode', + 'admin.productionStart.retry': 'Retry start', + 'admin.productionStart.failed': 'Could not retry production start', 'admin.productionGrantDialog.title': 'Enable production mode?', 'admin.productionGrantDialog.body': 'This keeps {title} online for the configured production-project duration and sends the user a notice.', 'admin.noActiveProjects': 'No active playgrounds.', @@ -1261,6 +1263,8 @@ Likeable застосовує розумні технічні та органі 'admin.productionGrant.enable': 'Увімкнути production', 'admin.productionGrant.extend': 'Продовжити production', 'admin.productionGrant.failed': 'Не вдалося увімкнути production режим', + 'admin.productionStart.retry': 'Повторити старт', + 'admin.productionStart.failed': 'Не вдалося повторити старт production', 'admin.productionGrantDialog.title': 'Увімкнути production режим?', 'admin.productionGrantDialog.body': 'Це залишить {title} онлайн на налаштований термін production проєкту й надішле користувачу сповіщення.', 'admin.noActiveProjects': 'Активних майданчиків немає.', diff --git a/internal/domain/types.go b/internal/domain/types.go index 43dd130..9184b3a 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -281,17 +281,20 @@ type AdminHourCreditLedgerEntry struct { } type AdminProjectInternal struct { - UserID string `json:"userId"` - ConversationID string `json:"conversationId,omitempty"` - AgentID string `json:"agentId,omitempty"` - ServerID string `json:"serverId,omitempty"` - PlaygroundID string `json:"playgroundId,omitempty"` - PlaygroundName string `json:"playgroundName,omitempty"` - PlayspecID string `json:"playspecId,omitempty"` - PropID string `json:"propId,omitempty"` - RepoURL string `json:"repoUrl,omitempty"` - ProvisioningLockUntil string `json:"provisioningLockUntil,omitempty"` - CleanupLastError string `json:"cleanupLastError,omitempty"` + UserID string `json:"userId"` + ConversationID string `json:"conversationId,omitempty"` + AgentID string `json:"agentId,omitempty"` + ServerID string `json:"serverId,omitempty"` + PlaygroundID string `json:"playgroundId,omitempty"` + PlaygroundName string `json:"playgroundName,omitempty"` + PlayspecID string `json:"playspecId,omitempty"` + PropID string `json:"propId,omitempty"` + RepoURL string `json:"repoUrl,omitempty"` + ProvisioningLockUntil string `json:"provisioningLockUntil,omitempty"` + CleanupLastError string `json:"cleanupLastError,omitempty"` + ProductionRuntimeStatus string `json:"productionRuntimeStatus,omitempty"` + ProductionRuntimeMessage string `json:"productionRuntimeMessage,omitempty"` + ProductionRuntimeBlockedAt string `json:"productionRuntimeBlockedAt,omitempty"` } type AdminProjectDiagnostics struct { diff --git a/internal/likeable/admin_handlers.go b/internal/likeable/admin_handlers.go index 4520fbb..539aed2 100644 --- a/internal/likeable/admin_handlers.go +++ b/internal/likeable/admin_handlers.go @@ -353,6 +353,8 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { s.handleAdminUserProjectDiagnostics(w, r, userID, parts[2]) case len(parts) == 4 && parts[1] == "projects" && parts[3] == "production" && r.Method == http.MethodPost: s.handleAdminUserProjectProductionGrant(w, r, userID, parts[2]) + case len(parts) == 5 && parts[1] == "projects" && parts[3] == "production" && parts[4] == "start" && r.Method == http.MethodPost: + s.handleAdminUserProjectProductionStart(w, r, userID, parts[2]) case len(parts) == 4 && parts[1] == "projects" && parts[3] == "assignment" && r.Method == http.MethodPatch: s.handleAdminUserProjectAssignment(w, r, userID, parts[2]) default: @@ -572,6 +574,7 @@ func (s *Server) handleAdminUserProjectDiagnostics(w http.ResponseWriter, r *htt writeError(w, http.StatusInternalServerError, err.Error()) return } + s.decorateAdminProjectRuntimeDiagnostics(r.Context(), diagnostics) writeJSON(w, http.StatusOK, map[string]any{"diagnostics": diagnostics}) } @@ -636,6 +639,99 @@ func (s *Server) handleAdminUserProjectProductionGrant(w http.ResponseWriter, r writeJSON(w, http.StatusOK, map[string]any{"detail": detail, "project": updated, "granted": granted, "days": days}) } +func (s *Server) handleAdminUserProjectProductionStart(w http.ResponseWriter, r *http.Request, userID, projectID string) { + target, err := s.store.UserByID(r.Context(), userID) + if err != nil { + writeError(w, http.StatusNotFound, "user not found") + return + } + project, err := s.store.ProjectForUser(r.Context(), userID, projectID) + if err != nil { + writeError(w, http.StatusNotFound, "project not found") + return + } + if strings.TrimSpace(project.ProductionExpiresAt) == "" { + writeError(w, http.StatusConflict, "production start retry requires an active production grant") + return + } + if project.Status == "archived" || project.Status == "deleting" { + writeError(w, http.StatusConflict, "production start retry requires an active project") + return + } + + started := false + blockedCode := "" + warning := "" + updated := project + if project.Status == "stopped" { + updated, err = s.controlProjectPlayground(r.Context(), target, project, "start") + if err != nil { + if fibe.IsRuntimeBillingRequiredError(err) { + blockedCode = "runtime_billing_required" + warning = productionRuntimeBillingRequiredMessage + s.notifyProductionProjectStartBlocked(r.Context(), target, project) + updated = project + } else if isPlatformRateLimited(err) { + writeError(w, http.StatusServiceUnavailable, "workspace platform is rate limited; try again shortly") + return + } else { + writeError(w, http.StatusBadGateway, "could not start production playground") + return + } + } else { + started = true + } + } + + windowStart, windowEnd := s.freeHourWindow(time.Now(), r.Context()) + detail, err := s.store.AdminUserDetail(r.Context(), userID, s.freeHourLimitMs(r.Context()), windowStart, windowEnd) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + detail.Summary.ProjectLimit = s.baseProjectCap(r.Context()) + detail.Summary.PaidProjectSlots + pool, err := s.adminAgentPoolOptions(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + decorateAdminDetailAssignmentStatuses(detail, pool) + detail.AgentPool = pool + writeJSON(w, http.StatusAccepted, map[string]any{"detail": detail, "project": updated, "started": started, "blockedCode": blockedCode, "warning": warning}) +} + +func (s *Server) decorateAdminProjectRuntimeDiagnostics(ctx context.Context, diagnostics *AdminProjectDiagnostics) { + if diagnostics == nil || strings.TrimSpace(diagnostics.Project.ProductionExpiresAt) == "" { + return + } + switch diagnostics.Project.Status { + case "ready": + diagnostics.Internal.ProductionRuntimeStatus = "running" + case "creating", "launching": + diagnostics.Internal.ProductionRuntimeStatus = "starting" + case "stopped": + diagnostics.Internal.ProductionRuntimeStatus = "stopped" + default: + diagnostics.Internal.ProductionRuntimeStatus = diagnostics.Project.Status + } + if diagnostics.Project.Status != "stopped" { + return + } + notices, err := s.store.NoticesForUser(ctx, diagnostics.Project.UserID, 50) + if err != nil { + return + } + prefix := "Production runtime paused: " + strconv.Quote(diagnostics.Project.Title) + for _, notice := range notices { + if notice.Sender == "system" && strings.HasPrefix(notice.Body, prefix) { + diagnostics.Internal.ProductionRuntimeStatus = "runtime_billing_required" + diagnostics.Internal.ProductionRuntimeMessage = notice.Body + diagnostics.Internal.ProductionRuntimeBlockedAt = notice.CreatedAt + return + } + } +} + func (s *Server) handleAdminUserProjectAssignment(w http.ResponseWriter, r *http.Request, userID, projectID string) { var body struct { AgentID string `json:"agent_id"` diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index a86826e..da46fb6 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -6652,6 +6652,176 @@ func TestProductionProjectStartBlockedByRuntimeBillingNotifiesUser(t *testing.T) } } +func TestAdminCanRetryProductionProjectStart(t *testing.T) { + appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer appStore.Close() + cliPath, logPath, _ := fakeFibeCLI(t) + if err := appStore.UpsertConfig(t.Context(), map[string]string{ + "fibe_base_url": "server.test:3000", + "fibe_api_key": "test-key", + "fibe_cli_path": cliPath, + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient} + admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "") + if err != nil { + t.Fatal(err) + } + user, err := appStore.UpsertUser(t.Context(), "admin-production-start@example.com", "Admin Production Start", "") + if err != nil { + t.Fatal(err) + } + if err := appStore.CreateSession(t.Context(), admin.ID, "admin-production-start-token", time.Hour); err != nil { + t.Fatal(err) + } + project := &Project{ + ID: "project-admin-production-start", + UserID: user.ID, + Title: "Admin Production Start", + ConversationID: "conv-admin-production-start", + AgentID: "agent-1", + MarqueeID: "server-1", + PlaygroundID: "playground-admin-production-start", + Status: "stopped", + } + if err := appStore.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + if granted, err := appStore.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_admin_production_start", time.Now().UTC().Add(7*24*time.Hour)); err != nil || !granted { + t.Fatalf("production grant=%v err=%v, want granted", granted, err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/production/start", strings.NewReader(`{}`)) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-production-start-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("production start returned %d: %s", rec.Code, rec.Body.String()) + } + var resp struct { + Detail AdminUserDetail `json:"detail"` + Project Project `json:"project"` + Started bool `json:"started"` + BlockedCode string `json:"blockedCode"` + Warning string `json:"warning"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + if !resp.Started || resp.BlockedCode != "" || resp.Warning != "" || resp.Project.Status != "launching" { + t.Fatalf("response=%+v, want successful production start", resp) + } + if len(resp.Detail.Projects) != 1 || resp.Detail.Projects[0].Project.Status != "launching" { + t.Fatalf("detail projects=%+v, want launching project", resp.Detail.Projects) + } + if log := readFile(t, logPath); !strings.Contains(log, "playgrounds start playground-admin-production-start") { + t.Fatalf("missing production start command; log=%s", log) + } +} + +func TestAdminProductionProjectStartRuntimeBillingReturnsWarning(t *testing.T) { + appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer appStore.Close() + if err := appStore.UpsertConfig(t.Context(), map[string]string{ + "fibe_base_url": "server.test:3000", + "fibe_api_key": "test-key", + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + logPath := filepath.Join(t.TempDir(), "fibe.log") + server := &Server{ + store: appStore, + config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, + http: fakeFibeHTTPClient(http.DefaultClient, fakeFibeTransportConfig{Mode: "runtime-billing-required", LogPath: logPath}), + } + admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "") + if err != nil { + t.Fatal(err) + } + user, err := appStore.UpsertUser(t.Context(), "admin-runtime-billing@example.com", "Admin Runtime Billing", "") + if err != nil { + t.Fatal(err) + } + if err := appStore.CreateSession(t.Context(), admin.ID, "admin-runtime-billing-token", time.Hour); err != nil { + t.Fatal(err) + } + project := &Project{ + ID: "project-admin-runtime-billing", + UserID: user.ID, + Title: "Admin Runtime Billing", + ConversationID: "conv-admin-runtime-billing", + AgentID: "agent-1", + MarqueeID: "server-1", + PlaygroundID: "playground-admin-runtime-billing", + Status: "stopped", + } + if err := appStore.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + if granted, err := appStore.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_admin_runtime_billing", time.Now().UTC().Add(7*24*time.Hour)); err != nil || !granted { + t.Fatalf("production grant=%v err=%v, want granted", granted, err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/production/start", strings.NewReader(`{}`)) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-runtime-billing-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("production start returned %d: %s", rec.Code, rec.Body.String()) + } + var resp struct { + Project Project `json:"project"` + Started bool `json:"started"` + BlockedCode string `json:"blockedCode"` + Warning string `json:"warning"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + if resp.Started || resp.BlockedCode != "runtime_billing_required" || !strings.Contains(resp.Warning, "production runtime is not funded") || resp.Project.Status != "stopped" { + t.Fatalf("response=%+v, want runtime billing warning with stopped project", resp) + } + notices, err := appStore.NoticesForUser(t.Context(), user.ID, 10) + if err != nil { + t.Fatal(err) + } + if len(notices) != 1 || !strings.Contains(notices[0].Body, "Production runtime paused") { + t.Fatalf("notices=%+v, want production runtime notice", notices) + } + if log := readFile(t, logPath); strings.Count(log, "playgrounds start playground-admin-runtime-billing") != 1 { + t.Fatalf("log=%s, want one start attempt", log) + } + + diagReq := httptest.NewRequest(http.MethodGet, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/diagnostics", nil) + diagReq.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-runtime-billing-token"}) + diagRec := httptest.NewRecorder() + server.routes().ServeHTTP(diagRec, diagReq) + + if diagRec.Code != http.StatusOK { + t.Fatalf("diagnostics returned %d: %s", diagRec.Code, diagRec.Body.String()) + } + var diagResp struct { + Diagnostics AdminProjectDiagnostics `json:"diagnostics"` + } + if err := json.NewDecoder(diagRec.Body).Decode(&diagResp); err != nil { + t.Fatal(err) + } + if diagResp.Diagnostics.Internal.ProductionRuntimeStatus != "runtime_billing_required" || + !strings.Contains(diagResp.Diagnostics.Internal.ProductionRuntimeMessage, "not funded") || + diagResp.Diagnostics.Internal.ProductionRuntimeBlockedAt == "" { + t.Fatalf("diagnostics internal=%+v, want runtime billing support context", diagResp.Diagnostics.Internal) + } +} + func TestAdminProductionGrantRejectsInactiveProject(t *testing.T) { appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { From 0b850015e3dc030683ffca6618280efb676f6dce Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 19:25:36 +0300 Subject: [PATCH 17/36] Document production runtime support flow --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index dd08499..694141a 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,20 @@ Checkout uses backend-created Stripe Checkout Sessions and does not require `str - Production-project checkout pins that project as always-on until expiry, blocks manual stop, lets the user save a custom domain request, shows the CNAME target, and verifies customer DNS. - Admin diagnostics for a user project shows conversation, agent, playground, server, repositories, payments, hour ledger, and work sessions. +### Production Runtime Billing Operations + +A production-project purchase grants the Likeable project permission to stay online, but the linked Fibe runtime must also be funded. If Fibe rejects a production start with runtime billing/payment status, Likeable keeps the project stopped, creates a warning notice for the user, and reports `runtime_billing_required`. + +Support flow: + +1. Open Admin, select the user, and inspect the production project. +2. Open project diagnostics and check `production_runtime_status`, `production_runtime_message`, `server_id`, and `playground_id`. +3. If the status is `runtime_billing_required`, fund the linked Fibe runtime/marquee outside Likeable. +4. Click `Retry start` in Admin, or ask the user to click `Start playground`. +5. Confirm the project moves to `launching` or `ready`, and `/healthz` remains healthy. + +The hourly production sweep also retries stopped production projects, so `Retry start` is mainly for immediate support verification after funding. + Domain routing is currently manual: after a production-project purchase, the user can save the intended custom domain, point a CNAME at the project target, and run DNS verification from the project menu. Admin diagnostics include the saved domain, DNS status, and target. Automatic Traefik routing and certificate provisioning for arbitrary customer domains are separate follow-up work. ## Development With Live Reload From 32dd6a6e7639631ba81de2004689183ac2f4197b Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 19:55:08 +0300 Subject: [PATCH 18/36] Sync custom domains to Fibe routing --- frontend/src/i18n/translations.ts | 4 +- internal/fibe/fibe_playground_custom_hosts.go | 91 ++++++++++ internal/fibe/fibe_test.go | 38 +++++ internal/likeable/jobs.go | 13 +- .../likeable/project_domain_verification.go | 49 ++++++ internal/likeable/project_handlers.go | 40 +++++ internal/likeable/server_test.go | 158 ++++++++++++++++++ 7 files changed, 384 insertions(+), 9 deletions(-) create mode 100644 internal/fibe/fibe_playground_custom_hosts.go diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index f4f0b92..9d55ae1 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -362,7 +362,7 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'projects.production.until': 'Always-on until {date}', 'projects.production.cname': 'Point CNAME to {host}', 'projects.production.domain': 'Custom domain: {domain}', - 'projects.production.domainActive': 'DNS verified · routing pending', + 'projects.production.domainActive': 'DNS verified · routing active', 'projects.production.domainPending': 'DNS pending', 'projects.production.domainCheck': 'Check DNS', 'projects.production.domainChecking': 'Checking DNS', @@ -1044,7 +1044,7 @@ Likeable застосовує розумні технічні та органі 'projects.production.until': 'Always-on до {date}', 'projects.production.cname': 'Спрямуйте CNAME на {host}', 'projects.production.domain': 'Кастомний домен: {domain}', - 'projects.production.domainActive': 'DNS перевірено · маршрутизація очікує', + 'projects.production.domainActive': 'DNS перевірено · маршрутизація активна', 'projects.production.domainPending': 'DNS очікує', 'projects.production.domainCheck': 'Перевірити DNS', 'projects.production.domainChecking': 'Перевіряємо DNS', diff --git a/internal/fibe/fibe_playground_custom_hosts.go b/internal/fibe/fibe_playground_custom_hosts.go new file mode 100644 index 0000000..140da90 --- /dev/null +++ b/internal/fibe/fibe_playground_custom_hosts.go @@ -0,0 +1,91 @@ +package fibe + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +func (c *Client) UpdatePlaygroundServiceCustomHosts(ctx context.Context, playgroundID string, serviceHosts map[string][]string) error { + playgroundID = strings.TrimSpace(playgroundID) + if playgroundID == "" { + return errors.New("playground id is required") + } + services := make(map[string]map[string][]string, len(serviceHosts)) + for serviceName, hosts := range serviceHosts { + serviceName = strings.TrimSpace(serviceName) + if serviceName == "" { + continue + } + services[serviceName] = map[string][]string{"custom_hosts": normalizeCustomHosts(hosts)} + } + if len(services) == 0 { + return nil + } + body := map[string]any{"playground": map[string]any{"services": services}} + return c.patchJSON(ctx, "/api/playgrounds/"+url.PathEscape(playgroundID), body) +} + +func normalizeCustomHosts(hosts []string) []string { + out := make([]string, 0, len(hosts)) + seen := map[string]bool{} + for _, host := range hosts { + host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), ".") + if host == "" || seen[host] { + continue + } + seen[host] = true + out = append(out, host) + } + return out +} + +func (c *Client) patchJSON(ctx context.Context, path string, body any) error { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("fibe: marshal request body: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.baseURL+path, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("fibe: create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + return platformErrorFromResponse(resp.StatusCode, resp.Status, respBody) +} + +func platformErrorFromResponse(statusCode int, status string, body []byte) error { + var payload struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details"` + } `json:"error"` + } + _ = json.Unmarshal(body, &payload) + message := firstNonEmpty(payload.Error.Message, status) + return &PlatformError{ + Code: firstNonEmpty(payload.Error.Code, platformCodeUnknown), + Status: statusCode, + Message: message, + Details: payload.Error.Details, + Stderr: strings.TrimSpace(string(body)), + } +} diff --git a/internal/fibe/fibe_test.go b/internal/fibe/fibe_test.go index b9b7fbd..ea3cd62 100644 --- a/internal/fibe/fibe_test.go +++ b/internal/fibe/fibe_test.go @@ -323,6 +323,44 @@ func TestControlPlaygroundLifecycleUsesSDKActions(t *testing.T) { } } +func TestUpdatePlaygroundServiceCustomHostsPatchesServices(t *testing.T) { + var gotAuth string + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || r.URL.Path != "/api/playgrounds/playground-123" { + t.Fatalf("request=%s %s, want PATCH /api/playgrounds/playground-123", r.Method, r.URL.Path) + } + gotAuth = r.Header.Get("Authorization") + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatal(err) + } + writeJSONResponse(t, w, map[string]any{"id": 123, "status": "running"}) + })) + defer server.Close() + + client := newTestClient(t, server, "agent", "marquee") + err := client.UpdatePlaygroundServiceCustomHosts(t.Context(), "playground-123", map[string][]string{ + "app": []string{"App.Customer.Example.", "app.customer.example"}, + "api": []string{}, + }) + if err != nil { + t.Fatal(err) + } + if gotAuth != "Bearer test" { + t.Fatalf("Authorization=%q, want bearer token", gotAuth) + } + playground := gotBody["playground"].(map[string]any) + services := playground["services"].(map[string]any) + app := services["app"].(map[string]any) + api := services["api"].(map[string]any) + if hosts := app["custom_hosts"].([]any); len(hosts) != 1 || hosts[0] != "app.customer.example" { + t.Fatalf("app custom_hosts=%#v, want normalized unique host", app["custom_hosts"]) + } + if hosts := api["custom_hosts"].([]any); len(hosts) != 0 { + t.Fatalf("api custom_hosts=%#v, want empty clear list", api["custom_hosts"]) + } +} + func TestCreateGreenfieldUsesTemplateVersionIDOnlyWhenConfigured(t *testing.T) { for _, tc := range []struct { name string diff --git a/internal/likeable/jobs.go b/internal/likeable/jobs.go index 530db8b..65f6cfd 100644 --- a/internal/likeable/jobs.go +++ b/internal/likeable/jobs.go @@ -657,16 +657,15 @@ func (s *Server) handleProjectDomainVerifySweepTask(ctx context.Context, _ *asyn return err } for _, projectDomain := range projectDomains { - status, err := s.projectCustomDomainDNSStatus(ctx, projectDomain.Domain, projectDomain.Target) + project, err := s.store.ProjectForUser(ctx, projectDomain.UserID, projectDomain.ProjectID) if err != nil { - log.Printf("verify custom domain DNS project=%s domain=%s: %v", projectDomain.ProjectID, projectDomain.Domain, err) - continue - } - if status == projectDomain.Status { + if !errors.Is(err, sql.ErrNoRows) { + log.Printf("load custom domain project=%s domain=%s: %v", projectDomain.ProjectID, projectDomain.Domain, err) + } continue } - if err := s.store.UpdateProjectDomainStatus(ctx, projectDomain.UserID, projectDomain.ProjectID, status); err != nil && !errors.Is(err, sql.ErrNoRows) { - log.Printf("update custom domain DNS status project=%s domain=%s: %v", projectDomain.ProjectID, projectDomain.Domain, err) + if _, err := s.verifyProjectCustomDomain(ctx, project); err != nil { + log.Printf("verify custom domain project=%s domain=%s: %v", projectDomain.ProjectID, projectDomain.Domain, err) } } return nil diff --git a/internal/likeable/project_domain_verification.go b/internal/likeable/project_domain_verification.go index f5531bb..c1c6611 100644 --- a/internal/likeable/project_domain_verification.go +++ b/internal/likeable/project_domain_verification.go @@ -48,6 +48,12 @@ func (s *Server) verifyProjectCustomDomain(ctx context.Context, project *Project if err != nil { return nil, err } + if status == store.ProjectDomainStatusDNSVerified && strings.TrimSpace(project.PlaygroundID) != "" { + if err := s.syncProjectCustomDomainRouting(ctx, project, project.CustomDomain); err != nil { + return nil, err + } + status = store.ProjectDomainStatusActive + } if status != project.CustomDomainStatus { if err := s.store.UpdateProjectDomainStatus(ctx, project.UserID, project.ID, status); err != nil { return nil, err @@ -57,6 +63,49 @@ func (s *Server) verifyProjectCustomDomain(ctx context.Context, project *Project return project, nil } +func (s *Server) syncProjectCustomDomainRouting(ctx context.Context, project *Project, domain string) error { + if project == nil || strings.TrimSpace(project.PlaygroundID) == "" { + return nil + } + serviceHosts := projectCustomDomainServiceHosts(project, domain) + if len(serviceHosts) == 0 { + return nil + } + client, err := s.fibeClientForProject(ctx, project, "") + if err != nil { + return err + } + return client.UpdatePlaygroundServiceCustomHosts(ctx, project.PlaygroundID, serviceHosts) +} + +func projectCustomDomainServiceHosts(project *Project, domain string) map[string][]string { + if project == nil { + return nil + } + domain = strings.Trim(strings.ToLower(strings.TrimSpace(domain)), ".") + selected := projectCustomDomainServiceName(project) + out := map[string][]string{} + for _, service := range project.Services { + name := strings.TrimSpace(service.Name) + if name == "" { + continue + } + if name == selected && domain != "" { + out[name] = []string{domain} + } else { + out[name] = []string{} + } + } + if len(out) == 0 && selected != "" { + if domain != "" { + out[selected] = []string{domain} + } else { + out[selected] = []string{} + } + } + return out +} + func normalizeDNSHost(value string) string { return strings.Trim(strings.ToLower(strings.TrimSpace(value)), ".") } diff --git a/internal/likeable/project_handlers.go b/internal/likeable/project_handlers.go index 9a37d4d..2a0c3d0 100644 --- a/internal/likeable/project_handlers.go +++ b/internal/likeable/project_handlers.go @@ -414,12 +414,26 @@ func (s *Server) handleProjectDomain(w http.ResponseWriter, r *http.Request, use writeError(w, http.StatusConflict, "project CNAME target is not available") return } + if existing := strings.TrimSpace(project.CustomDomain); existing != "" && existing != domain { + if err := s.syncProjectCustomDomainRouting(r.Context(), project, ""); err != nil { + log.Printf("clear old custom domain routing for project %s: %v", project.ID, err) + writeError(w, http.StatusBadGateway, "could not update custom domain routing") + return + } + } if err := s.store.UpsertProjectDomain(r.Context(), user.ID, project.ID, domain, target); err != nil { log.Printf("project domain upsert for project %s: %v", project.ID, err) writeError(w, http.StatusConflict, "custom domain is already linked to another project") return } case http.MethodDelete: + if strings.TrimSpace(project.CustomDomain) != "" { + if err := s.syncProjectCustomDomainRouting(r.Context(), project, ""); err != nil { + log.Printf("clear custom domain routing for project %s: %v", project.ID, err) + writeError(w, http.StatusBadGateway, "could not update custom domain routing") + return + } + } if err := s.store.DeleteProjectDomain(r.Context(), user.ID, project.ID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -531,6 +545,32 @@ func projectCustomDomainTarget(project *Project) string { return "" } +func projectCustomDomainServiceName(project *Project) string { + if project == nil { + return "" + } + selected := strings.TrimSpace(project.SelectedService) + if selected != "" { + for _, service := range project.Services { + if service.Name == selected && projectURLHost(service.URL) != "" { + return selected + } + } + if project.PreviewURL != "" { + return selected + } + } + for _, service := range project.Services { + if strings.TrimSpace(service.Name) != "" && projectURLHost(service.URL) != "" { + return strings.TrimSpace(service.Name) + } + } + if project.PreviewURL != "" { + return firstNonEmpty(selected, "app") + } + return selected +} + func projectURLHost(rawURL string) string { rawURL = strings.TrimSpace(rawURL) if rawURL == "" { diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index da46fb6..ee8a2e3 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -4217,6 +4217,164 @@ func TestProjectCustomDomainVerifyMarksDNSVerifiedCNAME(t *testing.T) { } } +func TestProjectCustomDomainVerifyActivatesFibeRouting(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + var patches []map[string]any + fibeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || r.URL.Path != "/api/playgrounds/123" { + t.Fatalf("Fibe request=%s %s, want PATCH /api/playgrounds/123", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Fatalf("Authorization=%q, want bearer token", got) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + patches = append(patches, body) + writeJSONResponse(t, w, map[string]any{"id": 123, "status": "running"}) + })) + defer fibeServer.Close() + if err := store.UpsertConfig(t.Context(), map[string]string{ + "fibe_base_url": fibeServer.URL, + "fibe_api_key": "test-key", + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + server := &Server{ + store: store, + config: RuntimeConfig{BaseURL: "http://example.test"}, + http: fibeServer.Client(), + domainDNS: fakeCNAMEResolver{ + "app.customer.example": {cname: "app-target.example.test."}, + }, + } + user, err := store.UpsertUser(t.Context(), "domain-routing@example.com", "Domain Routing", "") + if err != nil { + t.Fatal(err) + } + if err := store.CreateSession(t.Context(), user.ID, "domain-routing-token", time.Hour); err != nil { + t.Fatal(err) + } + project := &Project{ID: "project-domain-routing", UserID: user.ID, Title: "Domain Routing", ConversationID: "conv-domain-routing", AgentID: "agent-1", MarqueeID: "server-1", PlaygroundID: "123", Status: "ready", PreviewURL: "https://app-target.example.test", SelectedService: "app"} + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + if err := store.ReplaceProjectResources(t.Context(), project.ID, nil, []ProjectService{ + {ProjectID: project.ID, Name: "app", URL: "https://app-target.example.test", Type: "dynamic", Visibility: "external"}, + {ProjectID: project.ID, Name: "api", URL: "https://api-target.example.test", Type: "dynamic", Visibility: "external"}, + }); err != nil { + t.Fatal(err) + } + if granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_domain_routing_project", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted { + t.Fatalf("production grant=%v err=%v, want granted", granted, err) + } + + req := httptest.NewRequest(http.MethodPut, "/api/projects/project-domain-routing/domain", strings.NewReader(`{"domain":"app.customer.example"}`)) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-routing-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("domain save returned %d: %s", rec.Code, rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodPost, "/api/projects/project-domain-routing/domain/verify", nil) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-routing-token"}) + rec = httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("domain verify returned %d: %s", rec.Code, rec.Body.String()) + } + var body struct { + Project Project `json:"project"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Project.CustomDomainStatus != "active" { + t.Fatalf("custom domain status=%q, want active", body.Project.CustomDomainStatus) + } + if len(patches) != 1 { + t.Fatalf("patches=%d, want one Fibe routing patch", len(patches)) + } + playground := patches[0]["playground"].(map[string]any) + services := playground["services"].(map[string]any) + app := services["app"].(map[string]any) + api := services["api"].(map[string]any) + if hosts := app["custom_hosts"].([]any); len(hosts) != 1 || hosts[0] != "app.customer.example" { + t.Fatalf("app custom_hosts=%#v, want custom domain", app["custom_hosts"]) + } + if hosts := api["custom_hosts"].([]any); len(hosts) != 0 { + t.Fatalf("api custom_hosts=%#v, want cleared hosts", api["custom_hosts"]) + } +} + +func TestProjectCustomDomainDeleteClearsFibeRouting(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + var patch map[string]any + fibeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || r.URL.Path != "/api/playgrounds/123" { + t.Fatalf("Fibe request=%s %s, want PATCH /api/playgrounds/123", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + t.Fatal(err) + } + writeJSONResponse(t, w, map[string]any{"id": 123, "status": "running"}) + })) + defer fibeServer.Close() + if err := store.UpsertConfig(t.Context(), map[string]string{ + "fibe_base_url": fibeServer.URL, + "fibe_api_key": "test-key", + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: fibeServer.Client()} + user, err := store.UpsertUser(t.Context(), "domain-clear@example.com", "Domain Clear", "") + if err != nil { + t.Fatal(err) + } + if err := store.CreateSession(t.Context(), user.ID, "domain-clear-token", time.Hour); err != nil { + t.Fatal(err) + } + project := &Project{ID: "project-domain-clear", UserID: user.ID, Title: "Domain Clear", ConversationID: "conv-domain-clear", AgentID: "agent-1", MarqueeID: "server-1", PlaygroundID: "123", Status: "ready", SelectedService: "app"} + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + if err := store.ReplaceProjectResources(t.Context(), project.ID, nil, []ProjectService{ + {ProjectID: project.ID, Name: "app", URL: "https://app-target.example.test", Type: "dynamic", Visibility: "external"}, + }); err != nil { + t.Fatal(err) + } + if err := store.UpsertProjectDomain(t.Context(), user.ID, project.ID, "app.customer.example", "app-target.example.test"); err != nil { + t.Fatal(err) + } + if err := store.UpdateProjectDomainStatus(t.Context(), user.ID, project.ID, "active"); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodDelete, "/api/projects/project-domain-clear/domain", nil) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-clear-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("domain delete returned %d: %s", rec.Code, rec.Body.String()) + } + playground := patch["playground"].(map[string]any) + services := playground["services"].(map[string]any) + app := services["app"].(map[string]any) + if hosts := app["custom_hosts"].([]any); len(hosts) != 0 { + t.Fatalf("app custom_hosts=%#v, want cleared hosts", app["custom_hosts"]) + } +} + func TestProjectDomainVerifySweepMarksDNSVerifiedCNAME(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { From c42c082ee43f2b858799c82864be216c8cd2d76e Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 20:26:47 +0300 Subject: [PATCH 19/36] Roll out playgrounds after custom domain routing sync --- internal/fibe/fibe_agent.go | 4 ++ internal/fibe/fibe_test.go | 5 +- .../likeable/project_domain_verification.go | 5 +- internal/likeable/server_test.go | 57 ++++++++++++++----- 4 files changed, 54 insertions(+), 17 deletions(-) diff --git a/internal/fibe/fibe_agent.go b/internal/fibe/fibe_agent.go index 11fb405..2326b97 100644 --- a/internal/fibe/fibe_agent.go +++ b/internal/fibe/fibe_agent.go @@ -130,6 +130,10 @@ func (c *Client) RestartPlayground(ctx context.Context, playgroundID string) err return c.controlPlayground(ctx, "hard-restart", playgroundID) } +func (c *Client) RolloutPlayground(ctx context.Context, playgroundID string) error { + return c.controlPlayground(ctx, "rollout", playgroundID) +} + func (c *Client) controlPlayground(ctx context.Context, action, playgroundID string) error { playgroundID = strings.TrimSpace(playgroundID) if playgroundID == "" { diff --git a/internal/fibe/fibe_test.go b/internal/fibe/fibe_test.go index ea3cd62..d9507e2 100644 --- a/internal/fibe/fibe_test.go +++ b/internal/fibe/fibe_test.go @@ -317,7 +317,10 @@ func TestControlPlaygroundLifecycleUsesSDKActions(t *testing.T) { if err := client.RestartPlayground(t.Context(), "123"); err != nil { t.Fatal(err) } - want := []string{"start", "stop", "hard_restart"} + if err := client.RolloutPlayground(t.Context(), "123"); err != nil { + t.Fatal(err) + } + want := []string{"start", "stop", "hard_restart", "rollout"} if strings.Join(actions, ",") != strings.Join(want, ",") { t.Fatalf("actions=%v, want %v", actions, want) } diff --git a/internal/likeable/project_domain_verification.go b/internal/likeable/project_domain_verification.go index c1c6611..8281c8d 100644 --- a/internal/likeable/project_domain_verification.go +++ b/internal/likeable/project_domain_verification.go @@ -75,7 +75,10 @@ func (s *Server) syncProjectCustomDomainRouting(ctx context.Context, project *Pr if err != nil { return err } - return client.UpdatePlaygroundServiceCustomHosts(ctx, project.PlaygroundID, serviceHosts) + if err := client.UpdatePlaygroundServiceCustomHosts(ctx, project.PlaygroundID, serviceHosts); err != nil { + return err + } + return client.RolloutPlayground(ctx, project.PlaygroundID) } func projectCustomDomainServiceHosts(project *Project, domain string) map[string][]string { diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index ee8a2e3..832bd24 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -4224,18 +4224,27 @@ func TestProjectCustomDomainVerifyActivatesFibeRouting(t *testing.T) { } defer store.Close() var patches []map[string]any + var actions []string fibeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPatch || r.URL.Path != "/api/playgrounds/123" { - t.Fatalf("Fibe request=%s %s, want PATCH /api/playgrounds/123", r.Method, r.URL.Path) - } - if got := r.Header.Get("Authorization"); got != "Bearer test-key" { - t.Fatalf("Authorization=%q, want bearer token", got) - } - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatal(err) + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/api/playgrounds/123": + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Fatalf("Authorization=%q, want bearer token", got) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + patches = append(patches, body) + case r.Method == http.MethodPost && r.URL.Path == "/api/playgrounds/123/operations": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + actions = append(actions, fmt.Sprint(body["action_type"])) + default: + t.Fatalf("Fibe request=%s %s, want custom-host PATCH or rollout action", r.Method, r.URL.Path) } - patches = append(patches, body) writeJSONResponse(t, w, map[string]any{"id": 123, "status": "running"}) })) defer fibeServer.Close() @@ -4301,6 +4310,9 @@ func TestProjectCustomDomainVerifyActivatesFibeRouting(t *testing.T) { if len(patches) != 1 { t.Fatalf("patches=%d, want one Fibe routing patch", len(patches)) } + if len(actions) != 1 || actions[0] != "rollout" { + t.Fatalf("actions=%v, want rollout after routing patch", actions) + } playground := patches[0]["playground"].(map[string]any) services := playground["services"].(map[string]any) app := services["app"].(map[string]any) @@ -4320,12 +4332,21 @@ func TestProjectCustomDomainDeleteClearsFibeRouting(t *testing.T) { } defer store.Close() var patch map[string]any + var actions []string fibeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPatch || r.URL.Path != "/api/playgrounds/123" { - t.Fatalf("Fibe request=%s %s, want PATCH /api/playgrounds/123", r.Method, r.URL.Path) - } - if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { - t.Fatal(err) + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/api/playgrounds/123": + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + t.Fatal(err) + } + case r.Method == http.MethodPost && r.URL.Path == "/api/playgrounds/123/operations": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + actions = append(actions, fmt.Sprint(body["action_type"])) + default: + t.Fatalf("Fibe request=%s %s, want custom-host PATCH or rollout action", r.Method, r.URL.Path) } writeJSONResponse(t, w, map[string]any{"id": 123, "status": "running"}) })) @@ -4367,6 +4388,12 @@ func TestProjectCustomDomainDeleteClearsFibeRouting(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("domain delete returned %d: %s", rec.Code, rec.Body.String()) } + if patch == nil { + t.Fatal("missing Fibe routing clear patch") + } + if len(actions) != 1 || actions[0] != "rollout" { + t.Fatalf("actions=%v, want rollout after routing clear", actions) + } playground := patch["playground"].(map[string]any) services := playground["services"].(map[string]any) app := services["app"].(map[string]any) From 0bea816857749afe4412583f204786ea4538fd8a Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 20:47:26 +0300 Subject: [PATCH 20/36] Reconcile already-running project previews --- internal/likeable/production_project.go | 13 ++ internal/likeable/project_handlers.go | 31 ++++ internal/likeable/server_test.go | 186 ++++++++++++++++++++++++ 3 files changed, 230 insertions(+) diff --git a/internal/likeable/production_project.go b/internal/likeable/production_project.go index 6a67675..cbb4c46 100644 --- a/internal/likeable/production_project.go +++ b/internal/likeable/production_project.go @@ -27,6 +27,19 @@ func (s *Server) startProductionProjectIfStopped(ctx context.Context, userID, pr log.Printf("load production user for start %s: %v", userID, err) return } + if strings.TrimSpace(project.PreviewURL) != "" { + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + updated, ready, _, _, err := s.promoteProjectFromReachablePreview(probeCtx, user.ID, project) + cancel() + if err == nil && ready && updated != nil { + if err := s.store.TouchProjectPlaygroundUsage(ctx, project.ID, user.ID); err != nil { + log.Printf("touch already-running production project usage project=%s playground=%s: %v", project.ID, playgroundID, err) + return + } + s.clearPlatformBackoff() + return + } + } fibeClient, err := s.fibeClientForProject(ctx, project, user.Email) if err != nil { log.Printf("load Fibe client for production start project=%s playground=%s: %v", project.ID, playgroundID, err) diff --git a/internal/likeable/project_handlers.go b/internal/likeable/project_handlers.go index 2a0c3d0..0eadefc 100644 --- a/internal/likeable/project_handlers.go +++ b/internal/likeable/project_handlers.go @@ -359,6 +359,15 @@ func (s *Server) controlProjectPlayground(ctx context.Context, user *User, proje nextStatus := "launching" switch action { case "start": + if strings.TrimSpace(project.PreviewURL) != "" { + updated, ready, _, _, err := s.promoteProjectFromReachablePreview(actionCtx, user.ID, project) + if err == nil && ready && updated != nil { + if err := s.store.TouchProjectPlaygroundUsage(ctx, updated.ID, user.ID); err != nil { + return nil, err + } + return s.store.ProjectForUser(ctx, user.ID, updated.ID) + } + } err = fibeClient.StartPlayground(actionCtx, playgroundID) case "stop": nextStatus = "stopped" @@ -641,6 +650,7 @@ func (s *Server) handleProjectPreviewStatus(w http.ResponseWriter, r *http.Reque return } readinessRefreshed := false + resourceStatusRefreshed := false if projectNeedsReadinessRecovery(project) && user != nil { ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) updated, err := s.refreshProjectReadiness(ctx, user, project) @@ -659,11 +669,32 @@ func (s *Server) handleProjectPreviewStatus(w http.ResponseWriter, r *http.Reque cancel() if err == nil && updated != nil { project = updated + resourceStatusRefreshed = true } else if err != nil { log.Printf("preview status project resource refresh %s: %v", project.ID, err) } } if project.Status == "stopped" { + if !resourceStatusRefreshed && strings.TrimSpace(project.PreviewURL) != "" { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + updated, ready, status, maintenance, err := s.promoteProjectFromReachablePreview(ctx, project.UserID, project) + cancel() + if err == nil && updated != nil { + project = updated + } else if err != nil { + log.Printf("stopped preview status probe for project %s failed: %v", project.ID, err) + } + if ready || maintenance { + writeJSON(w, http.StatusOK, map[string]any{ + "ready": ready, + "maintenance": maintenance, + "status": publicPreviewProbeStatus(status), + "checkedAt": nowString(), + "project": project, + }) + return + } + } writeJSON(w, http.StatusOK, map[string]any{ "ready": false, "status": "stopped", diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index 832bd24..e52a44f 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -1317,6 +1317,62 @@ func TestProjectPreviewStatusPromotesReachablePreviewWithoutPlatformConfig(t *te } } +func TestProjectPreviewStatusPromotesStoppedReachablePreview(t *testing.T) { + previewServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte("already running")) + })) + defer previewServer.Close() + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: previewServer.Client()} + user, _ := store.UpsertUser(t.Context(), "stopped-preview@example.com", "Stopped Preview", "") + if err := store.CreateSession(t.Context(), user.ID, "stopped-preview-token", time.Hour); err != nil { + t.Fatal(err) + } + project := &Project{ + ID: "project-stopped-preview-ready", + UserID: user.ID, + Title: "Stopped Preview", + ConversationID: "conv-stopped-preview", + PlaygroundID: "playground-stopped-preview", + PreviewURL: previewServer.URL, + Status: "stopped", + } + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/projects/project-stopped-preview-ready/preview-status", nil) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "stopped-preview-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("preview-status returned %d: %s", rec.Code, rec.Body.String()) + } + var body struct { + Ready bool `json:"ready"` + Status string `json:"status"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if !body.Ready || body.Status != "200 OK" { + t.Fatalf("body=%+v, want reachable stopped preview promoted", body) + } + updated, err := store.ProjectForUser(t.Context(), user.ID, project.ID) + if err != nil { + t.Fatal(err) + } + if updated.Status != "ready" { + t.Fatalf("status=%q, want ready", updated.Status) + } +} + func TestProjectPreviewStatusMarksReachablePreviewReady(t *testing.T) { previewServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") @@ -3791,6 +3847,73 @@ func TestProjectPlaygroundLifecycleActions(t *testing.T) { } } +func TestProjectPlaygroundStartPromotesReachableStoppedPreview(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + _, logPath, _ := fakeFibeCLI(t) + previewServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte("already running")) + })) + defer previewServer.Close() + if err := store.UpsertConfig(t.Context(), map[string]string{ + "fibe_base_url": "server.test:3000", + "fibe_api_key": "test-key", + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + server := &Server{ + store: store, + config: RuntimeConfig{BaseURL: "http://example.test"}, + http: fakeFibeHTTPClient(previewServer.Client(), fakeFibeTransportConfig{Mode: "default", LogPath: logPath}), + } + user, err := store.UpsertUser(t.Context(), "project-start-preview@example.com", "Project Start Preview", "") + if err != nil { + t.Fatal(err) + } + if err := store.CreateSession(t.Context(), user.ID, "project-start-preview-token", time.Hour); err != nil { + t.Fatal(err) + } + project := &Project{ + ID: "project-start-preview", + UserID: user.ID, + Title: "Start Preview", + ConversationID: "conv-start-preview", + AgentID: "agent-1", + MarqueeID: "server-1", + PlaygroundID: "playground-start-preview", + PreviewURL: previewServer.URL, + Status: "stopped", + } + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/projects/project-start-preview/playground", strings.NewReader(`{"action":"start"}`)) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "project-start-preview-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("start returned %d, want 202; body=%s", rec.Code, rec.Body.String()) + } + stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID) + if err != nil { + t.Fatal(err) + } + if stored.Status != "ready" || stored.PlaygroundLastUsedAt == "" { + t.Fatalf("project=%+v, want ready with touched usage", stored) + } + if log, err := os.ReadFile(logPath); err == nil && strings.Contains(string(log), "playgrounds start playground-start-preview") { + t.Fatalf("unexpected Fibe start for reachable preview; log=%s", log) + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } +} + func TestProductionProjectPlaygroundCannotBeStopped(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { @@ -3983,6 +4106,69 @@ func TestProductionProjectStartSweepStartsStoppedProductionProjects(t *testing.T } } +func TestProductionProjectStartSweepPromotesReachableStoppedPreview(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + _, logPath, _ := fakeFibeCLI(t) + previewServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte("already running")) + })) + defer previewServer.Close() + if err := store.UpsertConfig(t.Context(), map[string]string{ + "fibe_base_url": "server.test:3000", + "fibe_api_key": "test-key", + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + server := &Server{ + store: store, + config: RuntimeConfig{BaseURL: "http://example.test"}, + http: fakeFibeHTTPClient(previewServer.Client(), fakeFibeTransportConfig{Mode: "default", LogPath: logPath}), + } + user, err := store.UpsertUser(t.Context(), "production-sweep-preview@example.com", "Production Sweep Preview", "") + if err != nil { + t.Fatal(err) + } + project := &Project{ + ID: "project-production-sweep-preview", + UserID: user.ID, + Title: "Production Sweep Preview", + ConversationID: "conv-production-sweep-preview", + AgentID: "agent-1", + MarqueeID: "server-1", + PlaygroundID: "playground-production-sweep-preview", + PreviewURL: previewServer.URL, + Status: "stopped", + } + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + if granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_production_sweep_preview", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted { + t.Fatalf("production grant=%v err=%v, want granted", granted, err) + } + + if err := server.handleStartProductionProjectsSweepTask(t.Context(), asynq.NewTask(taskStartProductionProjectsSweep, nil)); err != nil { + t.Fatalf("production start sweep returned error: %v", err) + } + + stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID) + if err != nil { + t.Fatal(err) + } + if stored.Status != "ready" || stored.ProductionExpiresAt == "" || stored.PlaygroundLastUsedAt == "" { + t.Fatalf("project=%+v, want ready production project with touched usage", stored) + } + if log, err := os.ReadFile(logPath); err == nil && strings.Contains(string(log), "playgrounds start playground-production-sweep-preview") { + t.Fatalf("unexpected Fibe start for reachable production preview; log=%s", log) + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } +} + func TestProjectCustomDomainRequiresProductionProject(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { From 46969daa00925a4abdbacdddbb006ed26f136706 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 21:24:26 +0300 Subject: [PATCH 21/36] Skip Fibe clear for pending custom domains --- .../likeable/project_domain_verification.go | 6 +++ internal/likeable/project_handlers.go | 4 +- internal/likeable/server_test.go | 53 +++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/internal/likeable/project_domain_verification.go b/internal/likeable/project_domain_verification.go index 8281c8d..08e5d25 100644 --- a/internal/likeable/project_domain_verification.go +++ b/internal/likeable/project_domain_verification.go @@ -81,6 +81,12 @@ func (s *Server) syncProjectCustomDomainRouting(ctx context.Context, project *Pr return client.RolloutPlayground(ctx, project.PlaygroundID) } +func projectCustomDomainRoutingSynced(project *Project) bool { + return project != nil && + strings.TrimSpace(project.CustomDomain) != "" && + strings.TrimSpace(project.CustomDomainStatus) == store.ProjectDomainStatusActive +} + func projectCustomDomainServiceHosts(project *Project, domain string) map[string][]string { if project == nil { return nil diff --git a/internal/likeable/project_handlers.go b/internal/likeable/project_handlers.go index 0eadefc..0c98521 100644 --- a/internal/likeable/project_handlers.go +++ b/internal/likeable/project_handlers.go @@ -423,7 +423,7 @@ func (s *Server) handleProjectDomain(w http.ResponseWriter, r *http.Request, use writeError(w, http.StatusConflict, "project CNAME target is not available") return } - if existing := strings.TrimSpace(project.CustomDomain); existing != "" && existing != domain { + if existing := strings.TrimSpace(project.CustomDomain); existing != "" && existing != domain && projectCustomDomainRoutingSynced(project) { if err := s.syncProjectCustomDomainRouting(r.Context(), project, ""); err != nil { log.Printf("clear old custom domain routing for project %s: %v", project.ID, err) writeError(w, http.StatusBadGateway, "could not update custom domain routing") @@ -436,7 +436,7 @@ func (s *Server) handleProjectDomain(w http.ResponseWriter, r *http.Request, use return } case http.MethodDelete: - if strings.TrimSpace(project.CustomDomain) != "" { + if projectCustomDomainRoutingSynced(project) { if err := s.syncProjectCustomDomainRouting(r.Context(), project, ""); err != nil { log.Printf("clear custom domain routing for project %s: %v", project.ID, err) writeError(w, http.StatusBadGateway, "could not update custom domain routing") diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index e52a44f..e59770f 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -4588,6 +4588,59 @@ func TestProjectCustomDomainDeleteClearsFibeRouting(t *testing.T) { } } +func TestProjectCustomDomainDeletePendingDNSSkipsFibeRoutingClear(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + fibeCalls := 0 + fibeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fibeCalls++ + t.Fatalf("unexpected Fibe request for pending DNS domain delete: %s %s", r.Method, r.URL.Path) + })) + defer fibeServer.Close() + if err := store.UpsertConfig(t.Context(), map[string]string{ + "fibe_base_url": fibeServer.URL, + "fibe_api_key": "test-key", + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: fibeServer.Client()} + user, err := store.UpsertUser(t.Context(), "domain-pending-clear@example.com", "Domain Pending Clear", "") + if err != nil { + t.Fatal(err) + } + if err := store.CreateSession(t.Context(), user.ID, "domain-pending-clear-token", time.Hour); err != nil { + t.Fatal(err) + } + project := &Project{ID: "project-domain-pending-clear", UserID: user.ID, Title: "Domain Pending Clear", ConversationID: "conv-domain-pending-clear", AgentID: "agent-1", MarqueeID: "server-1", PlaygroundID: "123", Status: "ready", PreviewURL: "https://app-target.example.test"} + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + if err := store.UpsertProjectDomain(t.Context(), user.ID, project.ID, "app.customer.example", "app-target.example.test"); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodDelete, "/api/projects/project-domain-pending-clear/domain", nil) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-pending-clear-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("domain delete returned %d: %s", rec.Code, rec.Body.String()) + } + if fibeCalls != 0 { + t.Fatalf("fibeCalls=%d, want none for pending DNS delete", fibeCalls) + } + stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID) + if err != nil { + t.Fatal(err) + } + if stored.CustomDomain != "" || stored.CustomDomainStatus != "" || stored.CustomDomainTarget != "" { + t.Fatalf("custom domain=%q status=%q target=%q, want deleted", stored.CustomDomain, stored.CustomDomainStatus, stored.CustomDomainTarget) + } +} + func TestProjectDomainVerifySweepMarksDNSVerifiedCNAME(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { From f693d70647a00e7dfce9343521091cad3b9ae26c Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 21:38:18 +0300 Subject: [PATCH 22/36] Expose provisioning failure causes to admins --- frontend/src/admin.tsx | 1 + frontend/src/domain.ts | 2 +- internal/domain/types.go | 1 + internal/likeable/project_provisioning.go | 7 ++- .../likeable/project_provisioning_test.go | 7 +++ internal/store/store.go | 4 ++ internal/store/store_admin.go | 18 +++++++ internal/store/store_projects.go | 50 ++++++++++++++++--- internal/store/store_projects_test.go | 42 ++++++++++++++++ 9 files changed, 123 insertions(+), 9 deletions(-) diff --git a/frontend/src/admin.tsx b/frontend/src/admin.tsx index bcab5b3..ba1d3aa 100644 --- a/frontend/src/admin.tsx +++ b/frontend/src/admin.tsx @@ -636,6 +636,7 @@ function diagnosticEntries(diagnostics: AdminProjectDiagnostics): [string, strin ['custom_domain_target', diagnostics.project.customDomainTarget ?? ''], ['services', services.map((service) => `${service.name}:${service.url}`).join(' | ')], ['repositories', repositories.map((repository) => `${repository.role}:${repository.sourceRepoUrl || repository.id}`).join(' | ')], + ['internal_error', internal.internalErrorMessage ?? ''], ['cleanup_error', internal.cleanupLastError ?? ''], ['provisioning_lock_until', internal.provisioningLockUntil ?? ''] ]; diff --git a/frontend/src/domain.ts b/frontend/src/domain.ts index 379da99..bf7a71d 100644 --- a/frontend/src/domain.ts +++ b/frontend/src/domain.ts @@ -65,7 +65,7 @@ export type AdminProjectSummary = { project: Project; workMs: number; assignment export type AdminUserDetail = { summary: AdminUserSummary; projects: AdminProjectSummary[]; notices: UserNotice[]; agentPool?: AgentPoolOption[] }; export type AdminUsersResponse = { users: AdminUserSummary[]; agentPool?: AgentPoolOption[]; pagination: { page: number; perPage: number; total: number } }; export type AdminBillingPayment = { id: string; userId: string; userEmail: string; providerPaymentId: string; amountCents: number; currency: string; status: string; createdAt: string }; -export type AdminProjectInternal = { userId: string; conversationId?: string; agentId?: string; serverId?: string; playgroundId?: string; playgroundName?: string; playspecId?: string; propId?: string; repoUrl?: string; provisioningLockUntil?: string; cleanupLastError?: string; productionRuntimeStatus?: string; productionRuntimeMessage?: string; productionRuntimeBlockedAt?: string }; +export type AdminProjectInternal = { userId: string; conversationId?: string; agentId?: string; serverId?: string; playgroundId?: string; playgroundName?: string; playspecId?: string; propId?: string; repoUrl?: string; provisioningLockUntil?: string; internalErrorMessage?: string; cleanupLastError?: string; productionRuntimeStatus?: string; productionRuntimeMessage?: string; productionRuntimeBlockedAt?: string }; export type AdminProjectWorkSession = { projectId: string; userId: string; sessionKey: string; startedAt: string; completedAt?: string; elapsedMs: number; freeBilledMs: number; paidBilledMs: number; billedAt?: string; createdAt: string; updatedAt: string }; export type AdminHourCreditLedgerEntry = { id: string; userId: string; deltaMs: number; reason: string; paymentId?: string; workSessionKey?: string; createdAt: string }; export type AdminProjectDiagnostics = { project: Project; internal: AdminProjectInternal; workSessions: AdminProjectWorkSession[]; hourLedger: AdminHourCreditLedgerEntry[]; payments: AdminBillingPayment[] }; diff --git a/internal/domain/types.go b/internal/domain/types.go index 9184b3a..8d591c5 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -291,6 +291,7 @@ type AdminProjectInternal struct { PropID string `json:"propId,omitempty"` RepoURL string `json:"repoUrl,omitempty"` ProvisioningLockUntil string `json:"provisioningLockUntil,omitempty"` + InternalErrorMessage string `json:"internalErrorMessage,omitempty"` CleanupLastError string `json:"cleanupLastError,omitempty"` ProductionRuntimeStatus string `json:"productionRuntimeStatus,omitempty"` ProductionRuntimeMessage string `json:"productionRuntimeMessage,omitempty"` diff --git a/internal/likeable/project_provisioning.go b/internal/likeable/project_provisioning.go index 747933f..e7477cf 100644 --- a/internal/likeable/project_provisioning.go +++ b/internal/likeable/project_provisioning.go @@ -286,12 +286,17 @@ func (s *Server) provisionProject(ctx context.Context, userID, userEmail string, } func (s *Server) recordProjectProvisionFailure(ctx context.Context, userID string, project *Project, err error, retriesRemaining bool) { + if project == nil { + log.Printf("project provisioning failed without project retry=%t: %v", retriesRemaining, err) + return + } + log.Printf("project provisioning failed project=%s retry=%t: %v", project.ID, retriesRemaining, err) if retriesRemaining && retryProjectProvisionLater(project, err) { status := "creating" if projectHasProvisionedResources(project) { status = "launching" } - _ = s.store.UpdateProjectStatus(ctx, project.ID, userID, status) + _ = s.store.UpdateProjectProvisioningRetryError(ctx, project.ID, userID, status, err) return } _ = s.store.UpdateProjectErrorFromError(ctx, project.ID, userID, err) diff --git a/internal/likeable/project_provisioning_test.go b/internal/likeable/project_provisioning_test.go index d39587a..74fa915 100644 --- a/internal/likeable/project_provisioning_test.go +++ b/internal/likeable/project_provisioning_test.go @@ -160,6 +160,13 @@ func TestRecordProjectProvisionFailureKeepsTransientPreGreenfieldFailureCreating if stored.ErrorMessage != "" { t.Fatalf("error_message=%q, want empty", stored.ErrorMessage) } + diagnostics, err := store.AdminProjectDiagnostics(t.Context(), user.ID, project.ID) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(diagnostics.Internal.InternalErrorMessage, "unexpected status 422") { + t.Fatalf("internal_error_message=%q, want provisioning retry cause", diagnostics.Internal.InternalErrorMessage) + } } func TestProjectNeedsProvisioningRecoveryWaitsForFreshOrLockedProject(t *testing.T) { diff --git a/internal/store/store.go b/internal/store/store.go index 36d1102..871fd92 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -80,6 +80,7 @@ func (s *Store) migrate(ctx context.Context) error { selected_service_name TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'creating', error_message TEXT NOT NULL DEFAULT '', + internal_error_message TEXT NOT NULL DEFAULT '', provisioning_lock_until TEXT NOT NULL DEFAULT '', cleanup_last_error TEXT NOT NULL DEFAULT '', cleanup_lock_until TEXT NOT NULL DEFAULT '', @@ -301,6 +302,9 @@ func (s *Store) migrate(ctx context.Context) error { if err := s.ensureColumn(ctx, "projects", "error_message", "TEXT NOT NULL DEFAULT ''"); err != nil { return err } + if err := s.ensureColumn(ctx, "projects", "internal_error_message", "TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } if err := s.ensureColumn(ctx, "projects", "selected_service_name", "TEXT NOT NULL DEFAULT ''"); err != nil { return err } diff --git a/internal/store/store_admin.go b/internal/store/store_admin.go index 3e44856..435b66c 100644 --- a/internal/store/store_admin.go +++ b/internal/store/store_admin.go @@ -385,6 +385,10 @@ func (s *Store) AdminProjectDiagnostics(ctx context.Context, userID, projectID s if err != nil { return nil, err } + internalErrorMessage, err := s.projectInternalErrorMessage(ctx, userID, projectID) + if err != nil { + return nil, err + } return &AdminProjectDiagnostics{ Project: *project, Internal: AdminProjectInternal{ @@ -398,6 +402,7 @@ func (s *Store) AdminProjectDiagnostics(ctx context.Context, userID, projectID s PropID: project.PropID, RepoURL: project.RepoURL, ProvisioningLockUntil: project.ProvisioningLockUntil, + InternalErrorMessage: internalErrorMessage, CleanupLastError: project.CleanupLastError, }, WorkSessions: workSessions, @@ -405,3 +410,16 @@ func (s *Store) AdminProjectDiagnostics(ctx context.Context, userID, projectID s Payments: payments, }, nil } + +func (s *Store) projectInternalErrorMessage(ctx context.Context, userID, projectID string) (string, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT internal_error_message + FROM projects + WHERE id = ? AND user_id = ? + `, projectID, userID) + var message string + if err := row.Scan(&message); err != nil { + return "", err + } + return message, nil +} diff --git a/internal/store/store_projects.go b/internal/store/store_projects.go index f7c1bf9..e2c3da7 100644 --- a/internal/store/store_projects.go +++ b/internal/store/store_projects.go @@ -37,7 +37,7 @@ func (s *Store) UpdateProjectProvisioning(ctx context.Context, projectID, userID now := nowString() result, err := s.db.ExecContext(ctx, ` UPDATE projects - SET playground_id = ?, playspec_id = ?, prop_id = ?, repo_url = ?, preview_url = ?, selected_service_name = ?, status = ?, error_message = '', + SET playground_id = ?, playspec_id = ?, prop_id = ?, repo_url = ?, preview_url = ?, selected_service_name = ?, status = ?, error_message = '', internal_error_message = '', playground_last_used_at = CASE WHEN ? = 'ready' AND playground_last_used_at = '' THEN ? ELSE playground_last_used_at END, updated_at = ? WHERE id = ? AND user_id = ? AND status != 'deleting' @@ -116,19 +116,39 @@ func (s *Store) ClearProjectCleanupLease(ctx context.Context, projectID, userID } func (s *Store) UpdateProjectError(ctx context.Context, projectID, userID, message string) error { - return s.updateProjectError(ctx, projectID, userID, publicProjectErrorMessage(message)) + return s.updateProjectError(ctx, projectID, userID, publicProjectErrorMessage(message), internalProjectErrorMessage(message)) } func (s *Store) UpdateProjectErrorFromError(ctx context.Context, projectID, userID string, err error) error { - return s.updateProjectError(ctx, projectID, userID, publicProjectErrorMessageFromError(err)) + return s.updateProjectError(ctx, projectID, userID, publicProjectErrorMessageFromError(err), internalProjectErrorMessageFromError(err)) } -func (s *Store) updateProjectError(ctx context.Context, projectID, userID, message string) error { +func (s *Store) UpdateProjectProvisioningRetryError(ctx context.Context, projectID, userID, status string, err error) error { + status = strings.TrimSpace(status) + if status == "" { + status = "creating" + } + result, execErr := s.db.ExecContext(ctx, ` + UPDATE projects + SET status = ?, internal_error_message = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND status != 'deleting' + `, status, internalProjectErrorMessageFromError(err), nowString(), projectID, userID) + if execErr != nil { + return execErr + } + rows, _ := result.RowsAffected() + if rows == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *Store) updateProjectError(ctx context.Context, projectID, userID, message, internalMessage string) error { result, err := s.db.ExecContext(ctx, ` UPDATE projects - SET status = 'error', error_message = ?, updated_at = ? + SET status = 'error', error_message = ?, internal_error_message = ?, updated_at = ? WHERE id = ? AND user_id = ? AND status != 'deleting' - `, message, nowString(), projectID, userID) + `, message, internalMessage, nowString(), projectID, userID) if err != nil { return err } @@ -139,6 +159,22 @@ func (s *Store) updateProjectError(ctx context.Context, projectID, userID, messa return nil } +func internalProjectErrorMessageFromError(err error) string { + if err == nil { + return "" + } + return internalProjectErrorMessage(err.Error()) +} + +func internalProjectErrorMessage(message string) string { + message = strings.TrimSpace(message) + const maxInternalProjectErrorMessage = 2000 + if len(message) > maxInternalProjectErrorMessage { + return message[:maxInternalProjectErrorMessage] + } + return message +} + type projectPublicErrorKind interface { PublicProjectErrorKind() string } @@ -380,7 +416,7 @@ func (s *Store) SaveProjectProvisioningSnapshot(ctx context.Context, project *Pr result, err := tx.ExecContext(ctx, ` UPDATE projects - SET agent_id = ?, marquee_id = ?, playground_id = ?, playground_name = ?, playspec_id = ?, prop_id = ?, repo_url = ?, preview_url = ?, selected_service_name = ?, status = ?, error_message = '', cleanup_last_error = '', + SET agent_id = ?, marquee_id = ?, playground_id = ?, playground_name = ?, playspec_id = ?, prop_id = ?, repo_url = ?, preview_url = ?, selected_service_name = ?, status = ?, error_message = '', internal_error_message = '', cleanup_last_error = '', playground_last_used_at = CASE WHEN playground_last_used_at = '' AND ? != '' THEN ? ELSE playground_last_used_at END, updated_at = ? WHERE id = ? AND user_id = ? AND status != 'deleting' diff --git a/internal/store/store_projects_test.go b/internal/store/store_projects_test.go index d5a402e..3770d9a 100644 --- a/internal/store/store_projects_test.go +++ b/internal/store/store_projects_test.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "path/filepath" + "strings" "testing" "time" @@ -453,3 +454,44 @@ func TestPublicProjectErrorMessageExplainsRuntimeBilling(t *testing.T) { t.Fatalf("message=%q, want %q", got, want) } } + +func TestAdminProjectDiagnosticsExposeInternalProjectError(t *testing.T) { + store, err := Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + user, err := store.UpsertUser(t.Context(), "project-internal-error@example.com", "Internal Error", "") + if err != nil { + t.Fatal(err) + } + project := &Project{ + ID: "project-internal-error", + UserID: user.ID, + Title: "Internal Error", + ConversationID: "conv-internal-error", + Status: "creating", + } + if err := store.CreateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + rawErr := &fibe.PlatformError{Code: "INTERNAL_ERROR", Status: 422, Message: "unexpected status 422"} + if err := store.UpdateProjectErrorFromError(t.Context(), project.ID, user.ID, rawErr); err != nil { + t.Fatal(err) + } + + stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID) + if err != nil { + t.Fatal(err) + } + if strings.Contains(stored.ErrorMessage, "unexpected status 422") { + t.Fatalf("public error_message=%q should stay sanitized", stored.ErrorMessage) + } + diagnostics, err := store.AdminProjectDiagnostics(t.Context(), user.ID, project.ID) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(diagnostics.Internal.InternalErrorMessage, "unexpected status 422") { + t.Fatalf("internal_error_message=%q, want raw platform cause", diagnostics.Internal.InternalErrorMessage) + } +} From b694421e5ae7c52dbfd1f0344064e22b4a6e7ed4 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Wed, 27 May 2026 22:12:08 +0300 Subject: [PATCH 23/36] Show Fibe pool health in admin config --- frontend/src/admin.tsx | 22 ++++++++- frontend/src/domain.ts | 3 +- frontend/src/i18n/translations.ts | 8 ++++ frontend/src/styles.css | 9 +++- internal/domain/types.go | 14 ++++++ internal/fibe/fibe_assignment_health.go | 64 +++++++++++++++++++++++++ internal/fibe/fibe_test.go | 38 +++++++++++++++ internal/likeable/admin_handlers.go | 38 ++++++++++++++- internal/likeable/server_test.go | 54 +++++++++++++++++++++ internal/likeable/types.go | 1 + 10 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 internal/fibe/fibe_assignment_health.go diff --git a/frontend/src/admin.tsx b/frontend/src/admin.tsx index ba1d3aa..0061972 100644 --- a/frontend/src/admin.tsx +++ b/frontend/src/admin.tsx @@ -4,7 +4,7 @@ import { cleanPoolRows, makePoolRow, parsePoolRows } from './admin_pool'; import { api } from './api'; import { AppDialog, Metric } from './builder_components'; import { ADMIN_CONFIG_SECTIONS } from './config'; -import type { AdminBillingHealth, AdminBillingHealthResponse, AdminConfigEntry, AdminConfigResponse, AdminProjectDiagnostics, AdminProjectDiagnosticsResponse, AdminProjectSummary, AdminRecoveryResponse, AdminUserDetail, AdminUserSummary, AdminUsersResponse, AgentAssignmentSummary, AgentPoolOption, AgentPoolStat, AppDialogConfig, PoolRow } from './domain'; +import type { AdminBillingHealth, AdminBillingHealthResponse, AdminConfigEntry, AdminConfigResponse, AdminProjectDiagnostics, AdminProjectDiagnosticsResponse, AdminProjectSummary, AdminRecoveryResponse, AdminUserDetail, AdminUserSummary, AdminUsersResponse, AgentAssignmentSummary, AgentPoolHealth, AgentPoolOption, AgentPoolStat, AppDialogConfig, PoolRow } from './domain'; import { formatBillingDuration, formatMessageTime, formatShortDate, userInitials } from './format'; import { resetCountdownLabels, statusLabel, TranslationKey, useDocumentTitle, useI18n } from './i18n'; @@ -703,6 +703,13 @@ function poolStatusLabel(status: string | undefined, t: (key: TranslationKey) => } } +function poolHealthLabel(health: AgentPoolHealth | undefined, t: (key: TranslationKey, params?: Record) => string): string { + if (!health) return t('admin.pool.health.unknown'); + if (health.ok) return t('admin.pool.health.ok'); + const problem = health.problems?.find(Boolean); + return problem ? t('admin.pool.health.problem', { problem }) : t('admin.pool.health.warning'); +} + function formatAdminMoney(cents: number, currency: string, locale: string): string { const code = (currency || 'usd').toUpperCase(); try { @@ -850,6 +857,7 @@ export function Admin() { const [allowedEmails, setAllowedEmails] = useState(''); const [poolRows, setPoolRows] = useState([]); const [poolStats, setPoolStats] = useState([]); + const [poolHealth, setPoolHealth] = useState([]); const [retiringPoolRowID, setRetiringPoolRowID] = useState(''); const [saving, setSaving] = useState(false); const [status, setStatus] = useState(''); @@ -866,6 +874,7 @@ export function Admin() { setAllowedEmails(response.config.signup_allowed_emails?.value ?? ''); setPoolRows(parsePoolRows(response.config.fibe_agent_server_pool?.value ?? '[]')); setPoolStats(response.agentPoolStats ?? []); + setPoolHealth(response.agentPoolHealth ?? []); }; const loadRecovery = async () => { @@ -889,6 +898,7 @@ export function Admin() { setPoolRows((rows) => rows.map((row) => row.id === id ? { ...row, ...patch } : row)); }; const statForPoolRow = (row: PoolRow) => poolStats.find((stat) => stat.agentId === row.agentId.trim() && stat.serverId === row.serverId.trim()); + const healthForPoolRow = (row: PoolRow) => poolHealth.find((health) => health.agentId === row.agentId.trim() && health.serverId === row.serverId.trim()); const retirePoolRow = async (row: PoolRow) => { setRetiringPoolRowID(row.id); setStatus(''); @@ -1088,9 +1098,17 @@ export function Admin() {
{(() => { const stat = statForPoolRow(row); - return stat + const health = healthForPoolRow(row); + const stats = stat ? t('admin.pool.stats', { projects: stat.projectCount, active: stat.activeProjectCount ?? Math.max(0, stat.projectCount - stat.archivedCount), archived: stat.archivedCount, archives: stat.readyArchiveCount }) : t('admin.pool.stats.empty'); + const healthLabel = poolHealthLabel(health, t); + return ( + <> + {stats} + {healthLabel} + + ); })()}
+
+ {error &&
{error}
} +
+ + + + +
+ {readiness && ( + failedChecks.length === 0 ? ( +
{t('admin.readiness.noIssues')}
+ ) : ( +
+ {failedChecks.map((check) => ( +
+ + {readinessCheckLabel(check.key, t)} + {check.detail || (check.severity === 'warning' ? t('admin.readiness.warning') : t('admin.readiness.blocker'))} + + {check.key} +
+ ))} +
+ ) + )} +
+ ); +} + function adminConfigLabel(key: string, t: (key: TranslationKey) => string): string { const labelKeys: Record = { github_client_id: 'admin.config.github_client_id', @@ -979,6 +1058,7 @@ export function Admin() {
+
diff --git a/frontend/src/domain.ts b/frontend/src/domain.ts index ee993af..c95c526 100644 --- a/frontend/src/domain.ts +++ b/frontend/src/domain.ts @@ -81,6 +81,9 @@ export type AdminBillingHealth = { recentPayments: AdminBillingPayment[]; }; export type AdminBillingHealthResponse = { health: AdminBillingHealth }; +export type AdminReadinessCheck = { key: string; ok: boolean; severity: 'blocker' | 'warning' | string; detail?: string }; +export type AdminReadiness = { checkedAt: string; ready: boolean; blockerCount: number; warningCount: number; checks: AdminReadinessCheck[] }; +export type AdminReadinessResponse = { readiness: AdminReadiness }; export type AppDialogConfig = { title: string; body: string; diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index ecd8757..86d1403 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -520,6 +520,31 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'admin.billingHealth.recentPayments': 'Recent payments', 'admin.billingHealth.checkedAt': 'Checked at {time}', 'admin.billingHealth.noPayments': 'No payment records yet.', + 'admin.readiness.title': 'Launch readiness', + 'admin.readiness.body': 'Production blockers across Stripe, Fibe runtime, OAuth, support mail, and access policy.', + 'admin.readiness.refresh': 'Refresh', + 'admin.readiness.loadFailed': 'Could not load launch readiness', + 'admin.readiness.status': 'Status', + 'admin.readiness.ready': 'ready', + 'admin.readiness.blocked': 'blocked', + 'admin.readiness.blockers': 'Blockers', + 'admin.readiness.warnings': 'Warnings', + 'admin.readiness.checkedAt': 'Checked at', + 'admin.readiness.noIssues': 'Production launch checks are clear.', + 'admin.readiness.blocker': 'Launch blocker', + 'admin.readiness.warning': 'Needs attention', + 'admin.readiness.check.stripeSecret': 'Stripe secret key', + 'admin.readiness.check.stripeWebhook': 'Stripe webhook secret', + 'admin.readiness.check.hourPrices': 'Stripe hour-pack price', + 'admin.readiness.check.projectQuotaPrice': 'Stripe project-slot price', + 'admin.readiness.check.productionProjectPrice': 'Stripe production-project price', + 'admin.readiness.check.fibeTemplate': 'Fibe template version', + 'admin.readiness.check.activePool': 'Active Fibe pool', + 'admin.readiness.check.activePoolHealth': 'Healthy active Fibe pool', + 'admin.readiness.check.googleOauth': 'Google OAuth', + 'admin.readiness.check.smtp': 'Support email delivery', + 'admin.readiness.check.signup': 'Signup policy', + 'admin.readiness.check.unknown': 'Unknown readiness check: {key}', 'admin.sort': 'Sort', 'admin.sort.newest': 'newest', 'admin.sort.hours': 'hours', @@ -1206,6 +1231,31 @@ Likeable застосовує розумні технічні та органі 'admin.billingHealth.recentPayments': 'Останні платежі', 'admin.billingHealth.checkedAt': 'Перевірено о {time}', 'admin.billingHealth.noPayments': 'Записів платежів ще немає.', + 'admin.readiness.title': 'Готовність до запуску', + 'admin.readiness.body': 'Production блокери у Stripe, Fibe runtime, OAuth, пошті підтримки та політиці доступу.', + 'admin.readiness.refresh': 'Оновити', + 'admin.readiness.loadFailed': 'Не вдалося завантажити готовність до запуску', + 'admin.readiness.status': 'Статус', + 'admin.readiness.ready': 'готово', + 'admin.readiness.blocked': 'заблоковано', + 'admin.readiness.blockers': 'Блокери', + 'admin.readiness.warnings': 'Попередження', + 'admin.readiness.checkedAt': 'Перевірено', + 'admin.readiness.noIssues': 'Production перевірки запуску чисті.', + 'admin.readiness.blocker': 'Блокер запуску', + 'admin.readiness.warning': 'Потребує уваги', + 'admin.readiness.check.stripeSecret': 'Stripe secret key', + 'admin.readiness.check.stripeWebhook': 'Stripe webhook secret', + 'admin.readiness.check.hourPrices': 'Stripe price для пакетів годин', + 'admin.readiness.check.projectQuotaPrice': 'Stripe price для місць проєктів', + 'admin.readiness.check.productionProjectPrice': 'Stripe price для production проєкту', + 'admin.readiness.check.fibeTemplate': 'Версія Fibe template', + 'admin.readiness.check.activePool': 'Активний Fibe pool', + 'admin.readiness.check.activePoolHealth': 'Здоровий активний Fibe pool', + 'admin.readiness.check.googleOauth': 'Google OAuth', + 'admin.readiness.check.smtp': 'Доставка email підтримки', + 'admin.readiness.check.signup': 'Політика реєстрації', + 'admin.readiness.check.unknown': 'Невідома перевірка готовності: {key}', 'admin.sort': 'Сортування', 'admin.sort.newest': 'новіші', 'admin.sort.hours': 'години', diff --git a/internal/likeable/admin_handlers.go b/internal/likeable/admin_handlers.go index b3d92d5..de1e1a1 100644 --- a/internal/likeable/admin_handlers.go +++ b/internal/likeable/admin_handlers.go @@ -17,6 +17,21 @@ import ( const adminMaxHourGrant = 100 const adminMaxProductionGrantDays = maxProductionProjectDays +type AdminReadinessCheck struct { + Key string `json:"key"` + OK bool `json:"ok"` + Severity string `json:"severity"` + Detail string `json:"detail,omitempty"` +} + +type AdminReadiness struct { + CheckedAt string `json:"checkedAt"` + Ready bool `json:"ready"` + BlockerCount int `json:"blockerCount"` + WarningCount int `json:"warningCount"` + Checks []AdminReadinessCheck `json:"checks"` +} + var secretConfigKeys = map[string]bool{ "fibe_api_key": true, "stripe_secret_key": true, @@ -152,6 +167,25 @@ func (s *Server) handleAdminRecovery(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleAdminReadiness(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + cfg, err := s.store.ConfigMap(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + pool, err := adminAgentPoolOptionsFromConfig(cfg) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + poolHealth := s.adminAgentPoolHealth(r.Context(), cfg, activeAdminPoolOptions(pool)) + writeJSON(w, http.StatusOK, map[string]any{"readiness": s.adminReadiness(cfg, pool, poolHealth)}) +} + func (s *Server) handleAdminBillingHealth(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") @@ -168,13 +202,39 @@ func (s *Server) handleAdminBillingHealth(w http.ResponseWriter, r *http.Request writeError(w, http.StatusInternalServerError, err.Error()) return } - priceStatus := map[string]bool{ + priceStatus := stripePriceStatus(stripeCfg) + issues := stripeBillingIssues(stripeCfg, priceStatus) + products := billingProductsFromConfig(stripeCfg) + products["projectQuotaDays"] = s.projectQuotaDays(r.Context()) + products["productionProjectDays"] = s.productionProjectDays(r.Context()) + writeJSON(w, http.StatusOK, map[string]any{ + "health": map[string]any{ + "checkedAt": time.Now().UTC().Format(time.RFC3339Nano), + "configured": map[string]bool{ + "publishableKey": strings.TrimSpace(cfg["stripe_publishable_key"]) != "", + "secretKey": strings.TrimSpace(stripeCfg["secret"]) != "", + "webhookSecret": strings.TrimSpace(stripeCfg["webhook"]) != "", + }, + "products": products, + "prices": priceStatus, + "free": map[string]any{"minutes": s.freeBuildLimitMinutes(r.Context()), "windowHours": s.freeHourWindowHours(r.Context())}, + "issues": issues, + "recentPayments": payments, + }, + }) +} + +func stripePriceStatus(stripeCfg map[string]string) map[string]bool { + return map[string]bool{ "oneHour": strings.TrimSpace(stripeCfg["price_1_hour"]) != "", "tenHours": strings.TrimSpace(stripeCfg["price_10_hours"]) != "", "hundredHours": strings.TrimSpace(stripeCfg["price_100_hours"]) != "", "projectQuota": strings.TrimSpace(stripeCfg["project_quota_price"]) != "", "productionProject": strings.TrimSpace(stripeCfg["production_project_price"]) != "", } +} + +func stripeBillingIssues(stripeCfg map[string]string, priceStatus map[string]bool) []string { issues := []string{} if strings.TrimSpace(stripeCfg["secret"]) == "" { issues = append(issues, "stripe_secret_missing") @@ -191,26 +251,106 @@ func (s *Server) handleAdminBillingHealth(w http.ResponseWriter, r *http.Request if !priceStatus["productionProject"] { issues = append(issues, "stripe_production_project_price_missing") } - products := billingProductsFromConfig(stripeCfg) - products["projectQuotaDays"] = s.projectQuotaDays(r.Context()) - products["productionProjectDays"] = s.productionProjectDays(r.Context()) - writeJSON(w, http.StatusOK, map[string]any{ - "health": map[string]any{ - "checkedAt": time.Now().UTC().Format(time.RFC3339Nano), - "configured": map[string]bool{ - "publishableKey": strings.TrimSpace(cfg["stripe_publishable_key"]) != "", - "secretKey": strings.TrimSpace(stripeCfg["secret"]) != "", - "webhookSecret": strings.TrimSpace(stripeCfg["webhook"]) != "", - }, - "products": products, - "prices": priceStatus, - "free": map[string]any{"minutes": s.freeBuildLimitMinutes(r.Context()), "windowHours": s.freeHourWindowHours(r.Context())}, - "issues": issues, - "recentPayments": payments, - }, + return issues +} + +func (s *Server) adminReadiness(cfg map[string]string, pool []AgentPoolOption, poolHealth []AgentPoolHealth) AdminReadiness { + stripeCfg := stripeConfigFromMap(cfg) + priceStatus := stripePriceStatus(stripeCfg) + checks := []AdminReadinessCheck{} + addAdminReadinessCheck(&checks, "stripe_secret", "blocker", strings.TrimSpace(stripeCfg["secret"]) != "", "") + addAdminReadinessCheck(&checks, "stripe_webhook", "blocker", strings.TrimSpace(stripeCfg["webhook"]) != "", "") + addAdminReadinessCheck(&checks, "stripe_hour_prices", "blocker", priceStatus["oneHour"] || priceStatus["tenHours"] || priceStatus["hundredHours"], "") + addAdminReadinessCheck(&checks, "stripe_project_quota_price", "blocker", priceStatus["projectQuota"], "") + addAdminReadinessCheck(&checks, "stripe_production_project_price", "blocker", priceStatus["productionProject"], "") + addAdminReadinessCheck(&checks, "fibe_template_version", "blocker", strings.TrimSpace(cfg["fibe_template_version_id"]) != "", "") + activePoolCount := 0 + for _, option := range pool { + if strings.TrimSpace(option.Status) == fibe.AssignmentStatusActive { + activePoolCount++ + } + } + addAdminReadinessCheck(&checks, "fibe_active_pool", "blocker", activePoolCount > 0, "") + healthyActivePool, activePoolDetail := activePoolReadiness(poolHealth, activePoolCount) + addAdminReadinessCheck(&checks, "fibe_active_pool_health", "blocker", healthyActivePool, activePoolDetail) + addAdminReadinessCheck(&checks, "google_oauth", "blocker", strings.TrimSpace(cfg["google_client_id"]) != "" && strings.TrimSpace(cfg["google_client_secret"]) != "", "") + addAdminReadinessCheck(&checks, "smtp_delivery", "warning", strings.TrimSpace(cfg["smtp_host"]) != "" && strings.TrimSpace(cfg["smtp_from_email"]) != "", "") + signupMode := strings.TrimSpace(cfg["signup_mode"]) + if signupMode == "" { + signupMode = "forbidden" + } + addAdminReadinessCheck(&checks, "signup_enabled", "warning", signupMode != "forbidden", signupMode) + + readiness := AdminReadiness{ + CheckedAt: time.Now().UTC().Format(time.RFC3339Nano), + Checks: checks, + Ready: true, + } + for _, check := range checks { + if check.OK { + continue + } + if check.Severity == "warning" { + readiness.WarningCount++ + continue + } + readiness.BlockerCount++ + readiness.Ready = false + } + return readiness +} + +func addAdminReadinessCheck(checks *[]AdminReadinessCheck, key, severity string, ok bool, detail string) { + *checks = append(*checks, AdminReadinessCheck{ + Key: key, + OK: ok, + Severity: severity, + Detail: strings.TrimSpace(detail), }) } +func activeAdminPoolOptions(pool []AgentPoolOption) []AgentPoolOption { + active := []AgentPoolOption{} + for _, option := range pool { + if strings.TrimSpace(option.Status) == fibe.AssignmentStatusActive { + active = append(active, option) + } + } + return active +} + +func activePoolReadiness(poolHealth []AgentPoolHealth, activePoolCount int) (bool, string) { + if activePoolCount == 0 { + return false, "no active agent/server pairs configured" + } + failures := []string{} + for _, health := range poolHealth { + if strings.TrimSpace(health.Status) != fibe.AssignmentStatusActive { + continue + } + pair := readinessPoolPairLabel(health.Label, health.AgentID, health.ServerID) + if health.OK { + return true, pair + } + if len(health.Problems) > 0 { + failures = append(failures, pair+": "+strings.Join(health.Problems, "; ")) + } else { + failures = append(failures, pair) + } + } + if len(failures) == 0 { + return false, "active agent/server pairs did not return health" + } + return false, strings.Join(failures, " | ") +} + +func readinessPoolPairLabel(label, agentID, serverID string) string { + if strings.TrimSpace(label) != "" { + return strings.TrimSpace(label) + } + return strings.TrimSpace(agentID) + "/" + strings.TrimSpace(serverID) +} + type adminRecoveryProject struct { ID string `json:"id"` UserID string `json:"userId"` diff --git a/internal/likeable/routes.go b/internal/likeable/routes.go index 1d011a0..a91ab05 100644 --- a/internal/likeable/routes.go +++ b/internal/likeable/routes.go @@ -116,6 +116,8 @@ func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) { s.withAdmin(s.handleAdminConfig)(w, r) case r.URL.Path == "/api/admin/recovery": s.withAdmin(s.handleAdminRecovery)(w, r) + case r.URL.Path == "/api/admin/readiness": + s.withAdmin(s.handleAdminReadiness)(w, r) case r.URL.Path == "/api/admin/billing/health": s.withAdmin(s.handleAdminBillingHealth)(w, r) case r.URL.Path == "/api/admin/agent-pool/retire": diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index e219ba7..30a0139 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -5466,6 +5466,17 @@ func stringSliceContains(values []string, target string) bool { return false } +func readinessCheck(t *testing.T, readiness AdminReadiness, key string) AdminReadinessCheck { + t.Helper() + for _, check := range readiness.Checks { + if check.Key == key { + return check + } + } + t.Fatalf("readiness checks=%+v, want key %q", readiness.Checks, key) + return AdminReadinessCheck{} +} + func TestSignupPolicyDefaultsClosedButAllowsAdminExistingAndAllowlist(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { @@ -5702,6 +5713,118 @@ func TestAdminConfigIncludesAgentPoolHealth(t *testing.T) { } } +func TestAdminReadinessReportsLaunchBlockers(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient} + admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "") + if err != nil { + t.Fatal(err) + } + if err := store.CreateSession(t.Context(), admin.ID, "admin-readiness-token", time.Hour); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/admin/readiness", nil) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-readiness-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("admin readiness returned %d: %s", rec.Code, rec.Body.String()) + } + var body struct { + Readiness AdminReadiness `json:"readiness"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Readiness.Ready || body.Readiness.BlockerCount == 0 { + t.Fatalf("readiness=%+v, want launch blockers", body.Readiness) + } + if check := readinessCheck(t, body.Readiness, "stripe_production_project_price"); check.OK || check.Severity != "blocker" { + t.Fatalf("production price check=%+v, want blocking failure", check) + } + if check := readinessCheck(t, body.Readiness, "fibe_active_pool"); check.OK || check.Severity != "blocker" { + t.Fatalf("active pool check=%+v, want blocking failure", check) + } + if check := readinessCheck(t, body.Readiness, "fibe_active_pool_health"); check.OK || !strings.Contains(check.Detail, "no active") { + t.Fatalf("active pool health check=%+v, want no active detail", check) + } + if check := readinessCheck(t, body.Readiness, "signup_enabled"); check.OK || check.Severity != "warning" { + t.Fatalf("signup check=%+v, want warning failure", check) + } +} + +func TestAdminReadinessPassesWithProductionConfig(t *testing.T) { + store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + fibeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case http.MethodGet + " /api/agents/agent-ready": + writeJSONResponse(t, w, map[string]any{"id": 1, "status": "authenticated", "authenticated": true}) + case http.MethodGet + " /api/marquees/server-ready": + writeJSONResponse(t, w, map[string]any{"id": 1, "status": "active", "billing_runtime_active": true, "chat_launchable": true}) + default: + t.Fatalf("unexpected Fibe request: %s %s", r.Method, r.URL.Path) + } + })) + defer fibeServer.Close() + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: fibeServer.Client()} + admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "") + if err != nil { + t.Fatal(err) + } + if err := store.CreateSession(t.Context(), admin.ID, "admin-readiness-ready-token", time.Hour); err != nil { + t.Fatal(err) + } + if err := store.UpsertConfig(t.Context(), map[string]string{ + "stripe_secret_key": "sk_test_ready", + "stripe_webhook_secret": "whsec_ready", + "stripe_price_id_10_hours": "price_hours", + "stripe_project_quota_price_id": "price_project_quota", + "stripe_production_project_price_id": "price_production_project", + "fibe_base_url": fibeServer.URL, + "fibe_api_key": "test-key", + "fibe_template_version_id": "template-ready", + "fibe_agent_server_pool": `[{"label":"Main","agent_id":"agent-ready","server_id":"server-ready","status":"active"}]`, + "google_client_id": "google-client", + "google_client_secret": "google-secret", + "smtp_host": "smtp.example.test", + "smtp_from_email": "support@example.test", + "signup_mode": "allowlist", + }, secretConfigKeys); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/admin/readiness", nil) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-readiness-ready-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("admin readiness returned %d: %s", rec.Code, rec.Body.String()) + } + var body struct { + Readiness AdminReadiness `json:"readiness"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if !body.Readiness.Ready || body.Readiness.BlockerCount != 0 || body.Readiness.WarningCount != 0 { + t.Fatalf("readiness=%+v, want ready with no blockers or warnings", body.Readiness) + } + if check := readinessCheck(t, body.Readiness, "fibe_active_pool_health"); !check.OK || check.Detail != "Main" { + t.Fatalf("active pool health check=%+v, want healthy Main detail", check) + } +} + func TestAdminRetireAgentPoolArchivesProjectsAndMarksPairRetired(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { From 18d12d8f4977a4f211e7386e6f725d06e5be8a96 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Thu, 28 May 2026 10:22:38 +0300 Subject: [PATCH 27/36] Accept bundled greenfield template in readiness --- frontend/src/i18n/translations.ts | 4 ++-- internal/fibe/fibe_greenfield.go | 5 +++++ internal/likeable/admin_handlers.go | 17 ++++++++++++++++- internal/likeable/server_test.go | 9 ++++++++- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index 86d1403..9d81754 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -538,7 +538,7 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'admin.readiness.check.hourPrices': 'Stripe hour-pack price', 'admin.readiness.check.projectQuotaPrice': 'Stripe project-slot price', 'admin.readiness.check.productionProjectPrice': 'Stripe production-project price', - 'admin.readiness.check.fibeTemplate': 'Fibe template version', + 'admin.readiness.check.fibeTemplate': 'Fibe greenfield template', 'admin.readiness.check.activePool': 'Active Fibe pool', 'admin.readiness.check.activePoolHealth': 'Healthy active Fibe pool', 'admin.readiness.check.googleOauth': 'Google OAuth', @@ -1249,7 +1249,7 @@ Likeable застосовує розумні технічні та органі 'admin.readiness.check.hourPrices': 'Stripe price для пакетів годин', 'admin.readiness.check.projectQuotaPrice': 'Stripe price для місць проєктів', 'admin.readiness.check.productionProjectPrice': 'Stripe price для production проєкту', - 'admin.readiness.check.fibeTemplate': 'Версія Fibe template', + 'admin.readiness.check.fibeTemplate': 'Fibe greenfield template', 'admin.readiness.check.activePool': 'Активний Fibe pool', 'admin.readiness.check.activePoolHealth': 'Здоровий активний Fibe pool', 'admin.readiness.check.googleOauth': 'Google OAuth', diff --git a/internal/fibe/fibe_greenfield.go b/internal/fibe/fibe_greenfield.go index ed352f9..a758925 100644 --- a/internal/fibe/fibe_greenfield.go +++ b/internal/fibe/fibe_greenfield.go @@ -134,6 +134,11 @@ func (c *Client) greenfieldParams(name string, serviceSubdomains map[string]stri } func greenfieldTemplateBody() (string, error) { + return GreenfieldTemplateBody() +} + +// GreenfieldTemplateBody returns the configured inline template body used when no Fibe template version is pinned. +func GreenfieldTemplateBody() (string, error) { path := strings.TrimSpace(os.Getenv("LIKEABLE_GREENFIELD_TEMPLATE_BODY_PATH")) if path == "" { path = "/usr/local/share/likeable/go-fibe-greenfield.yaml" diff --git a/internal/likeable/admin_handlers.go b/internal/likeable/admin_handlers.go index de1e1a1..b2a8cbd 100644 --- a/internal/likeable/admin_handlers.go +++ b/internal/likeable/admin_handlers.go @@ -263,7 +263,8 @@ func (s *Server) adminReadiness(cfg map[string]string, pool []AgentPoolOption, p addAdminReadinessCheck(&checks, "stripe_hour_prices", "blocker", priceStatus["oneHour"] || priceStatus["tenHours"] || priceStatus["hundredHours"], "") addAdminReadinessCheck(&checks, "stripe_project_quota_price", "blocker", priceStatus["projectQuota"], "") addAdminReadinessCheck(&checks, "stripe_production_project_price", "blocker", priceStatus["productionProject"], "") - addAdminReadinessCheck(&checks, "fibe_template_version", "blocker", strings.TrimSpace(cfg["fibe_template_version_id"]) != "", "") + greenfieldReady, greenfieldDetail := greenfieldTemplateReadiness(cfg) + addAdminReadinessCheck(&checks, "fibe_template_version", "blocker", greenfieldReady, greenfieldDetail) activePoolCount := 0 for _, option := range pool { if strings.TrimSpace(option.Status) == fibe.AssignmentStatusActive { @@ -309,6 +310,20 @@ func addAdminReadinessCheck(checks *[]AdminReadinessCheck, key, severity string, }) } +func greenfieldTemplateReadiness(cfg map[string]string) (bool, string) { + if strings.TrimSpace(cfg["fibe_template_version_id"]) != "" { + return true, "template version id" + } + body, err := fibe.GreenfieldTemplateBody() + if err != nil { + return false, err.Error() + } + if strings.TrimSpace(body) != "" { + return true, "bundled template body" + } + return false, "no template version id or bundled template body" +} + func activeAdminPoolOptions(pool []AgentPoolOption) []AgentPoolOption { active := []AgentPoolOption{} for _, option := range pool { diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index 30a0139..d6d8bad 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -5765,6 +5765,11 @@ func TestAdminReadinessPassesWithProductionConfig(t *testing.T) { t.Fatal(err) } defer store.Close() + templatePath := filepath.Join(t.TempDir(), "greenfield.yml") + if err := os.WriteFile(templatePath, []byte("services:\n app:\n image: test\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("LIKEABLE_GREENFIELD_TEMPLATE_BODY_PATH", templatePath) fibeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method + " " + r.URL.Path { case http.MethodGet + " /api/agents/agent-ready": @@ -5792,7 +5797,6 @@ func TestAdminReadinessPassesWithProductionConfig(t *testing.T) { "stripe_production_project_price_id": "price_production_project", "fibe_base_url": fibeServer.URL, "fibe_api_key": "test-key", - "fibe_template_version_id": "template-ready", "fibe_agent_server_pool": `[{"label":"Main","agent_id":"agent-ready","server_id":"server-ready","status":"active"}]`, "google_client_id": "google-client", "google_client_secret": "google-secret", @@ -5823,6 +5827,9 @@ func TestAdminReadinessPassesWithProductionConfig(t *testing.T) { if check := readinessCheck(t, body.Readiness, "fibe_active_pool_health"); !check.OK || check.Detail != "Main" { t.Fatalf("active pool health check=%+v, want healthy Main detail", check) } + if check := readinessCheck(t, body.Readiness, "fibe_template_version"); !check.OK || check.Detail != "bundled template body" { + t.Fatalf("greenfield template check=%+v, want bundled template body", check) + } } func TestAdminRetireAgentPoolArchivesProjectsAndMarksPairRetired(t *testing.T) { From 020f29289a28ae7cf7520416123bc95b074fc089 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Thu, 28 May 2026 10:35:45 +0300 Subject: [PATCH 28/36] Add live readiness smoke script --- README.md | 12 ++++++++ bin/live-readiness | 75 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100755 bin/live-readiness diff --git a/README.md b/README.md index a57280b..0158e12 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,18 @@ bin/deploy-preflight It runs the TypeScript check, frontend build, Go tests, production Docker image build, and a container smoke against `/healthz` and the admin billing health API. Override the image tag or smoke port with `LIKEABLE_IMAGE_TAG` and `LIKEABLE_PREFLIGHT_PORT`. +After deploying a live test environment, run the remote readiness smoke: + +```bash +LIKEABLE_BASE_URL=https://likeable.example.com \ +LIKEABLE_ADMIN_EMAIL=admin@example.com \ +bin/live-readiness +``` + +For an environment without dev auth, pass an existing admin session cookie with `LIKEABLE_SESSION_COOKIE` instead of `LIKEABLE_ADMIN_EMAIL`. + +`bin/live-readiness` exits non-zero when readiness or billing has blockers. Set `LIKEABLE_ALLOW_BLOCKERS=1` to print the full diagnostic payload while a test environment is intentionally incomplete. + ## Test Deployment Runbook Use this flow for the Vyakymenko Likeable test environment or any equivalent test VPS. diff --git a/bin/live-readiness b/bin/live-readiness new file mode 100755 index 0000000..9ba5728 --- /dev/null +++ b/bin/live-readiness @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${LIKEABLE_BASE_URL:-}" +ADMIN_EMAIL="${LIKEABLE_ADMIN_EMAIL:-}" +SESSION_COOKIE="${LIKEABLE_SESSION_COOKIE:-}" +ALLOW_BLOCKERS="${LIKEABLE_ALLOW_BLOCKERS:-0}" + +if [ -z "$BASE_URL" ]; then + echo "missing required env: LIKEABLE_BASE_URL" >&2 + exit 2 +fi + +BASE_URL="${BASE_URL%/}" +cookie_jar="$(mktemp)" +health_file="$(mktemp)" +readiness_file="$(mktemp)" +billing_file="$(mktemp)" +cleanup() { + rm -f "$cookie_jar" "$health_file" "$readiness_file" "$billing_file" +} +trap cleanup EXIT + +echo "==> Health: $BASE_URL/healthz" +curl -fsS "$BASE_URL/healthz" >"$health_file" +node -e 'const fs=require("fs"); const h=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!h.ok) process.exit(1); console.log(JSON.stringify(h.checks || {}));' "$health_file" + +if [ -n "$SESSION_COOKIE" ]; then + auth_args=(-H "Cookie: likeable_session=$SESSION_COOKIE") +else + if [ -z "$ADMIN_EMAIL" ]; then + echo "missing required env: LIKEABLE_ADMIN_EMAIL when LIKEABLE_SESSION_COOKIE is not set" >&2 + exit 2 + fi + echo "==> Admin auth: dev login" + curl -fsS -c "$cookie_jar" "$BASE_URL/api/dev/login?email=$ADMIN_EMAIL" >/dev/null + auth_args=(-b "$cookie_jar") +fi + +echo "==> Admin page" +curl -fsS "${auth_args[@]}" "$BASE_URL/admin" | grep -q '
' +echo "ok" + +echo "==> Launch readiness" +curl -fsS "${auth_args[@]}" "$BASE_URL/api/admin/readiness" >"$readiness_file" +node -e ' +const fs=require("fs"); +const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8")).readiness; +const failed=(r.checks || []).filter((check)=>!check.ok); +console.log(JSON.stringify({ready:r.ready, blockers:r.blockerCount, warnings:r.warningCount, failed:failed.map((check)=>({key:check.key,severity:check.severity,detail:check.detail || ""}))}, null, 2)); +if (!r.ready) process.exit(3); +' "$readiness_file" || readiness_status=$? +readiness_status="${readiness_status:-0}" +if [ "$readiness_status" != "0" ] && [ "$ALLOW_BLOCKERS" != "1" ]; then + exit "$readiness_status" +fi + +echo "==> Billing health" +curl -fsS "${auth_args[@]}" "$BASE_URL/api/admin/billing/health" >"$billing_file" +node -e ' +const fs=require("fs"); +const h=JSON.parse(fs.readFileSync(process.argv[1],"utf8")).health; +console.log(JSON.stringify({issues:h.issues || [], products:h.products || {}, prices:h.prices || {}}, null, 2)); +if ((h.issues || []).length > 0) process.exit(4); +' "$billing_file" || billing_status=$? +billing_status="${billing_status:-0}" +if [ "$billing_status" != "0" ] && [ "$ALLOW_BLOCKERS" != "1" ]; then + exit "$billing_status" +fi + +if [ "$readiness_status" = "0" ] && [ "$billing_status" = "0" ]; then + echo "==> OK: live readiness is clear" +else + echo "==> Completed with accepted live blockers" +fi From 3240f025433668995872f5aa6e12cebb84dcaaf1 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Thu, 28 May 2026 11:07:27 +0300 Subject: [PATCH 29/36] Add live config apply script --- README.md | 15 ++++++++++ bin/live-configure | 74 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100755 bin/live-configure diff --git a/README.md b/README.md index 0158e12..d4270d9 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,21 @@ For an environment without dev auth, pass an existing admin session cookie with `bin/live-readiness` exits non-zero when readiness or billing has blockers. Set `LIKEABLE_ALLOW_BLOCKERS=1` to print the full diagnostic payload while a test environment is intentionally incomplete. +To apply launch-only live config without printing secret values: + +```bash +LIKEABLE_BASE_URL=https://likeable.example.com \ +LIKEABLE_ADMIN_EMAIL=admin@example.com \ +LIKEABLE_STRIPE_PRODUCTION_PROJECT_PRICE_ID=price_... \ +LIKEABLE_GOOGLE_CLIENT_ID=... \ +LIKEABLE_GOOGLE_CLIENT_SECRET=... \ +LIKEABLE_SMTP_HOST=smtp.example.com \ +LIKEABLE_SMTP_FROM_EMAIL=support@example.com \ +bin/live-configure +``` + +Optional SMTP env keys are `LIKEABLE_SMTP_PORT`, `LIKEABLE_SMTP_USERNAME`, `LIKEABLE_SMTP_PASSWORD`, `LIKEABLE_SMTP_FROM_NAME`, and `LIKEABLE_SMTP_TLS_MODE`. Use `LIKEABLE_DRY_RUN=1` to print the config keys that would be applied without changing the live environment. + ## Test Deployment Runbook Use this flow for the Vyakymenko Likeable test environment or any equivalent test VPS. diff --git a/bin/live-configure b/bin/live-configure new file mode 100755 index 0000000..18af26e --- /dev/null +++ b/bin/live-configure @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${LIKEABLE_BASE_URL:-}" +ADMIN_EMAIL="${LIKEABLE_ADMIN_EMAIL:-}" +SESSION_COOKIE="${LIKEABLE_SESSION_COOKIE:-}" +DRY_RUN="${LIKEABLE_DRY_RUN:-0}" + +if [ -z "$BASE_URL" ]; then + echo "missing required env: LIKEABLE_BASE_URL" >&2 + exit 2 +fi + +BASE_URL="${BASE_URL%/}" +cookie_jar="$(mktemp)" +payload_file="$(mktemp)" +cleanup() { + rm -f "$cookie_jar" "$payload_file" +} +trap cleanup EXIT + +node - <<'NODE' >"$payload_file" +const mappings = [ + ["LIKEABLE_STRIPE_PRODUCTION_PROJECT_PRICE_ID", "stripe_production_project_price_id"], + ["LIKEABLE_GOOGLE_CLIENT_ID", "google_client_id"], + ["LIKEABLE_GOOGLE_CLIENT_SECRET", "google_client_secret"], + ["LIKEABLE_SMTP_HOST", "smtp_host"], + ["LIKEABLE_SMTP_PORT", "smtp_port"], + ["LIKEABLE_SMTP_USERNAME", "smtp_username"], + ["LIKEABLE_SMTP_PASSWORD", "smtp_password"], + ["LIKEABLE_SMTP_FROM_EMAIL", "smtp_from_email"], + ["LIKEABLE_SMTP_FROM_NAME", "smtp_from_name"], + ["LIKEABLE_SMTP_TLS_MODE", "smtp_tls_mode"], +]; +const payload = {}; +for (const [envName, configKey] of mappings) { + const value = process.env[envName]; + if (value !== undefined && value !== "") { + payload[configKey] = value; + } +} +if (Object.keys(payload).length === 0) { + console.error("no config env vars were provided"); + process.exit(2); +} +console.error(`config keys: ${Object.keys(payload).join(", ")}`); +process.stdout.write(JSON.stringify(payload)); +NODE + +if [ "$DRY_RUN" = "1" ]; then + echo "==> Dry run only; config was not changed" + exit 0 +fi + +if [ -n "$SESSION_COOKIE" ]; then + auth_args=(-H "Cookie: likeable_session=$SESSION_COOKIE") +else + if [ -z "$ADMIN_EMAIL" ]; then + echo "missing required env: LIKEABLE_ADMIN_EMAIL when LIKEABLE_SESSION_COOKIE is not set" >&2 + exit 2 + fi + echo "==> Admin auth: dev login" + curl -fsS -c "$cookie_jar" "$BASE_URL/api/dev/login?email=$ADMIN_EMAIL" >/dev/null + auth_args=(-b "$cookie_jar") +fi + +echo "==> Applying config to $BASE_URL" +curl -fsS "${auth_args[@]}" \ + -X PUT \ + -H 'Content-Type: application/json' \ + --data-binary "@$payload_file" \ + "$BASE_URL/api/admin/config" >/dev/null + +echo "==> OK: config keys applied" From f9c261176371d77cd2dbee2f3346dde7042e57fa Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Thu, 28 May 2026 11:46:43 +0300 Subject: [PATCH 30/36] Add actionable readiness details --- internal/likeable/admin_handlers.go | 32 +++++++++++++++++++++-------- internal/likeable/server_test.go | 12 +++++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/internal/likeable/admin_handlers.go b/internal/likeable/admin_handlers.go index b2a8cbd..39d0292 100644 --- a/internal/likeable/admin_handlers.go +++ b/internal/likeable/admin_handlers.go @@ -258,11 +258,14 @@ func (s *Server) adminReadiness(cfg map[string]string, pool []AgentPoolOption, p stripeCfg := stripeConfigFromMap(cfg) priceStatus := stripePriceStatus(stripeCfg) checks := []AdminReadinessCheck{} - addAdminReadinessCheck(&checks, "stripe_secret", "blocker", strings.TrimSpace(stripeCfg["secret"]) != "", "") - addAdminReadinessCheck(&checks, "stripe_webhook", "blocker", strings.TrimSpace(stripeCfg["webhook"]) != "", "") - addAdminReadinessCheck(&checks, "stripe_hour_prices", "blocker", priceStatus["oneHour"] || priceStatus["tenHours"] || priceStatus["hundredHours"], "") - addAdminReadinessCheck(&checks, "stripe_project_quota_price", "blocker", priceStatus["projectQuota"], "") - addAdminReadinessCheck(&checks, "stripe_production_project_price", "blocker", priceStatus["productionProject"], "") + stripeSecretOK := strings.TrimSpace(stripeCfg["secret"]) != "" + stripeWebhookOK := strings.TrimSpace(stripeCfg["webhook"]) != "" + stripeHourPricesOK := priceStatus["oneHour"] || priceStatus["tenHours"] || priceStatus["hundredHours"] + addAdminReadinessCheck(&checks, "stripe_secret", "blocker", stripeSecretOK, missingReadinessDetail(stripeSecretOK, "set stripe_secret_key")) + addAdminReadinessCheck(&checks, "stripe_webhook", "blocker", stripeWebhookOK, missingReadinessDetail(stripeWebhookOK, "set stripe_webhook_secret")) + addAdminReadinessCheck(&checks, "stripe_hour_prices", "blocker", stripeHourPricesOK, missingReadinessDetail(stripeHourPricesOK, "set at least one hour-pack price id")) + addAdminReadinessCheck(&checks, "stripe_project_quota_price", "blocker", priceStatus["projectQuota"], missingReadinessDetail(priceStatus["projectQuota"], "set stripe_project_quota_price_id")) + addAdminReadinessCheck(&checks, "stripe_production_project_price", "blocker", priceStatus["productionProject"], missingReadinessDetail(priceStatus["productionProject"], "set stripe_production_project_price_id")) greenfieldReady, greenfieldDetail := greenfieldTemplateReadiness(cfg) addAdminReadinessCheck(&checks, "fibe_template_version", "blocker", greenfieldReady, greenfieldDetail) activePoolCount := 0 @@ -271,16 +274,20 @@ func (s *Server) adminReadiness(cfg map[string]string, pool []AgentPoolOption, p activePoolCount++ } } - addAdminReadinessCheck(&checks, "fibe_active_pool", "blocker", activePoolCount > 0, "") + hasActivePool := activePoolCount > 0 + addAdminReadinessCheck(&checks, "fibe_active_pool", "blocker", hasActivePool, missingReadinessDetail(hasActivePool, "configure at least one active fibe_agent_server_pool row")) healthyActivePool, activePoolDetail := activePoolReadiness(poolHealth, activePoolCount) addAdminReadinessCheck(&checks, "fibe_active_pool_health", "blocker", healthyActivePool, activePoolDetail) - addAdminReadinessCheck(&checks, "google_oauth", "blocker", strings.TrimSpace(cfg["google_client_id"]) != "" && strings.TrimSpace(cfg["google_client_secret"]) != "", "") - addAdminReadinessCheck(&checks, "smtp_delivery", "warning", strings.TrimSpace(cfg["smtp_host"]) != "" && strings.TrimSpace(cfg["smtp_from_email"]) != "", "") + googleOAuthOK := strings.TrimSpace(cfg["google_client_id"]) != "" && strings.TrimSpace(cfg["google_client_secret"]) != "" + smtpDeliveryOK := strings.TrimSpace(cfg["smtp_host"]) != "" && strings.TrimSpace(cfg["smtp_from_email"]) != "" + addAdminReadinessCheck(&checks, "google_oauth", "blocker", googleOAuthOK, missingReadinessDetail(googleOAuthOK, "set google_client_id and google_client_secret")) + addAdminReadinessCheck(&checks, "smtp_delivery", "warning", smtpDeliveryOK, missingReadinessDetail(smtpDeliveryOK, "set smtp_host and smtp_from_email")) signupMode := strings.TrimSpace(cfg["signup_mode"]) if signupMode == "" { signupMode = "forbidden" } - addAdminReadinessCheck(&checks, "signup_enabled", "warning", signupMode != "forbidden", signupMode) + signupEnabled := signupMode != "forbidden" + addAdminReadinessCheck(&checks, "signup_enabled", "warning", signupEnabled, missingReadinessDetail(signupEnabled, "set signup_mode to all or allowlist")) readiness := AdminReadiness{ CheckedAt: time.Now().UTC().Format(time.RFC3339Nano), @@ -310,6 +317,13 @@ func addAdminReadinessCheck(checks *[]AdminReadinessCheck, key, severity string, }) } +func missingReadinessDetail(ok bool, detail string) string { + if ok { + return "" + } + return detail +} + func greenfieldTemplateReadiness(cfg map[string]string) (bool, string) { if strings.TrimSpace(cfg["fibe_template_version_id"]) != "" { return true, "template version id" diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index d6d8bad..8683cd5 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -5747,15 +5747,27 @@ func TestAdminReadinessReportsLaunchBlockers(t *testing.T) { } if check := readinessCheck(t, body.Readiness, "stripe_production_project_price"); check.OK || check.Severity != "blocker" { t.Fatalf("production price check=%+v, want blocking failure", check) + } else if !strings.Contains(check.Detail, "stripe_production_project_price_id") { + t.Fatalf("production price detail=%q, want config key", check.Detail) } if check := readinessCheck(t, body.Readiness, "fibe_active_pool"); check.OK || check.Severity != "blocker" { t.Fatalf("active pool check=%+v, want blocking failure", check) + } else if !strings.Contains(check.Detail, "fibe_agent_server_pool") { + t.Fatalf("active pool detail=%q, want config key", check.Detail) } if check := readinessCheck(t, body.Readiness, "fibe_active_pool_health"); check.OK || !strings.Contains(check.Detail, "no active") { t.Fatalf("active pool health check=%+v, want no active detail", check) } + if check := readinessCheck(t, body.Readiness, "google_oauth"); check.OK || !strings.Contains(check.Detail, "google_client_id") || !strings.Contains(check.Detail, "google_client_secret") { + t.Fatalf("google oauth check=%+v, want actionable config keys", check) + } + if check := readinessCheck(t, body.Readiness, "smtp_delivery"); check.OK || !strings.Contains(check.Detail, "smtp_host") || !strings.Contains(check.Detail, "smtp_from_email") { + t.Fatalf("smtp delivery check=%+v, want actionable config keys", check) + } if check := readinessCheck(t, body.Readiness, "signup_enabled"); check.OK || check.Severity != "warning" { t.Fatalf("signup check=%+v, want warning failure", check) + } else if !strings.Contains(check.Detail, "signup_mode") { + t.Fatalf("signup detail=%q, want config key", check.Detail) } } From 77aa1c9cceb56c7d7ed0a41db82964d795d1cab1 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Thu, 28 May 2026 11:57:55 +0300 Subject: [PATCH 31/36] Validate live config inputs --- README.md | 2 ++ bin/live-configure | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/README.md b/README.md index d4270d9..02380e3 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ bin/live-configure Optional SMTP env keys are `LIKEABLE_SMTP_PORT`, `LIKEABLE_SMTP_USERNAME`, `LIKEABLE_SMTP_PASSWORD`, `LIKEABLE_SMTP_FROM_NAME`, and `LIKEABLE_SMTP_TLS_MODE`. Use `LIKEABLE_DRY_RUN=1` to print the config keys that would be applied without changing the live environment. +`bin/live-configure` validates common operator mistakes before it writes config: production price IDs must start with `price_`, Google client ID/secret must be supplied together, SMTP port must be numeric, and SMTP TLS mode must be one of `auto`, `tls`, `starttls`, or `none`. + ## Test Deployment Runbook Use this flow for the Vyakymenko Likeable test environment or any equivalent test VPS. diff --git a/bin/live-configure b/bin/live-configure index 18af26e..efc6a00 100755 --- a/bin/live-configure +++ b/bin/live-configure @@ -33,16 +33,37 @@ const mappings = [ ["LIKEABLE_SMTP_TLS_MODE", "smtp_tls_mode"], ]; const payload = {}; +const provided = new Set(); for (const [envName, configKey] of mappings) { const value = process.env[envName]; if (value !== undefined && value !== "") { payload[configKey] = value; + provided.add(envName); } } if (Object.keys(payload).length === 0) { console.error("no config env vars were provided"); process.exit(2); } +const errors = []; +if (payload.stripe_production_project_price_id && !payload.stripe_production_project_price_id.startsWith("price_")) { + errors.push("LIKEABLE_STRIPE_PRODUCTION_PROJECT_PRICE_ID must start with price_"); +} +if (provided.has("LIKEABLE_GOOGLE_CLIENT_ID") !== provided.has("LIKEABLE_GOOGLE_CLIENT_SECRET")) { + errors.push("LIKEABLE_GOOGLE_CLIENT_ID and LIKEABLE_GOOGLE_CLIENT_SECRET must be provided together"); +} +if (payload.smtp_port && !/^[0-9]+$/.test(payload.smtp_port)) { + errors.push("LIKEABLE_SMTP_PORT must be numeric"); +} +if (payload.smtp_tls_mode && !["auto", "tls", "starttls", "none"].includes(payload.smtp_tls_mode.trim().toLowerCase())) { + errors.push("LIKEABLE_SMTP_TLS_MODE must be one of auto, tls, starttls, none"); +} +if (errors.length > 0) { + for (const error of errors) { + console.error(error); + } + process.exit(2); +} console.error(`config keys: ${Object.keys(payload).join(", ")}`); process.stdout.write(JSON.stringify(payload)); NODE From 14d2dae1e1e8606435fe8fd7585e1e18e8a84498 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Thu, 28 May 2026 14:55:00 +0300 Subject: [PATCH 32/36] Check ops scripts in deploy preflight --- bin/deploy-preflight | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/bin/deploy-preflight b/bin/deploy-preflight index feaffbe..4b1a470 100755 --- a/bin/deploy-preflight +++ b/bin/deploy-preflight @@ -15,6 +15,23 @@ trap cleanup EXIT cd "$ROOT" +echo "==> Operational script checks" +for script in bin/*; do + if [ -f "$script" ]; then + bash -n "$script" + fi +done +LIKEABLE_BASE_URL=http://localhost \ + LIKEABLE_DRY_RUN=1 \ + LIKEABLE_STRIPE_PRODUCTION_PROJECT_PRICE_ID=price_preflight \ + LIKEABLE_GOOGLE_CLIENT_ID=google-client-preflight \ + LIKEABLE_GOOGLE_CLIENT_SECRET=google-secret-preflight \ + LIKEABLE_SMTP_HOST=smtp.example.test \ + LIKEABLE_SMTP_PORT=587 \ + LIKEABLE_SMTP_TLS_MODE=starttls \ + LIKEABLE_SMTP_FROM_EMAIL=support@example.test \ + bin/live-configure >/dev/null + echo "==> TypeScript check" npm run lint From e8a68f0fefb3955623f36810d91586c1b1d860c1 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Thu, 28 May 2026 15:40:07 +0300 Subject: [PATCH 33/36] Scope Likeable billing to playground usage --- README.md | 28 +- bin/deploy-preflight | 2 +- bin/live-configure | 11 +- frontend/src/admin.tsx | 88 +-- frontend/src/builder_components.tsx | 100 +-- frontend/src/config.ts | 4 +- frontend/src/domain.ts | 4 +- frontend/src/i18n/translations.ts | 68 +- frontend/src/main.tsx | 53 +- internal/domain/types.go | 12 +- internal/likeable/admin_handlers.go | 157 +---- internal/likeable/jobs.go | 72 +- internal/likeable/project_binding.go | 3 - internal/likeable/project_handlers.go | 114 +--- internal/likeable/project_quota.go | 60 +- internal/likeable/server_test.go | 936 ++++---------------------- internal/likeable/stripe.go | 118 +--- internal/store/store_projects.go | 23 +- internal/store/store_projects_test.go | 14 +- 19 files changed, 314 insertions(+), 1553 deletions(-) diff --git a/README.md b/README.md index 02380e3..d99773b 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ After the first admin login, open Admin and configure: - GitHub OAuth for repository export. - Stripe prices and webhook secret. - SMTP delivery. -- Signup mode, free build minutes/window, project cap, paid playground slot duration, and production project duration. +- Signup mode, free build minutes/window, playground idle-stop hours, project cap, and paid playground slot duration. Signup defaults to forbidden until the admin changes it. @@ -89,7 +89,7 @@ To apply launch-only live config without printing secret values: ```bash LIKEABLE_BASE_URL=https://likeable.example.com \ LIKEABLE_ADMIN_EMAIL=admin@example.com \ -LIKEABLE_STRIPE_PRODUCTION_PROJECT_PRICE_ID=price_... \ +LIKEABLE_PLAYGROUND_IDLE_STOP_HOURS=8 \ LIKEABLE_GOOGLE_CLIENT_ID=... \ LIKEABLE_GOOGLE_CLIENT_SECRET=... \ LIKEABLE_SMTP_HOST=smtp.example.com \ @@ -99,7 +99,7 @@ bin/live-configure Optional SMTP env keys are `LIKEABLE_SMTP_PORT`, `LIKEABLE_SMTP_USERNAME`, `LIKEABLE_SMTP_PASSWORD`, `LIKEABLE_SMTP_FROM_NAME`, and `LIKEABLE_SMTP_TLS_MODE`. Use `LIKEABLE_DRY_RUN=1` to print the config keys that would be applied without changing the live environment. -`bin/live-configure` validates common operator mistakes before it writes config: production price IDs must start with `price_`, Google client ID/secret must be supplied together, SMTP port must be numeric, and SMTP TLS mode must be one of `auto`, `tls`, `starttls`, or `none`. +`bin/live-configure` validates common operator mistakes before it writes config: playground idle-stop hours must be between 1 and 168, Google client ID/secret must be supplied together, SMTP port must be numeric, and SMTP TLS mode must be one of `auto`, `tls`, `starttls`, or `none`. ## Test Deployment Runbook @@ -157,13 +157,12 @@ For a private smoke only, temporarily set `LIKEABLE_DEV_AUTH=1` and sign in with 4. Configure Admin. -Set Fibe, Google OAuth, GitHub export, SMTP, signup mode, free minutes/window, project cap, paid project-slot duration, production-project duration, and Stripe: +Set Fibe, Google OAuth, GitHub export, SMTP, signup mode, free minutes/window, playground idle-stop hours, project cap, paid project-slot duration, and Stripe: - `stripe_secret_key` - `stripe_webhook_secret` - `stripe_price_id_1_hour`, `stripe_price_id_10_hours`, `stripe_price_id_100_hours` - `stripe_project_quota_price_id` -- `stripe_production_project_price_id` Stripe webhook URL: `${BASE_URL}/api/stripe/webhook`. @@ -175,26 +174,15 @@ Checkout uses backend-created Stripe Checkout Sessions and does not require `str - Admin billing health has no blocking issues. - A new user can sign in, create a project, and send a first message. - Sending a message to a stopped project wakes the playground and then sends the prompt. +- Idle playgrounds pause after the configured inactive window; users can start them again when they return. - Hour-pack checkout grants build minutes. - Project-slot checkout increases the project cap for the configured number of days. -- Production-project checkout pins that project as always-on until expiry, blocks manual stop, lets the user save a custom domain request, shows the CNAME target, and verifies customer DNS. +- Likeable does not sell production hosting or custom domains. For always-on production hosting and domain setup, continue the project in Fibe. - Admin diagnostics for a user project shows conversation, agent, playground, server, repositories, payments, hour ledger, and work sessions. -### Production Runtime Billing Operations +### Production Handoff -A production-project purchase grants the Likeable project permission to stay online, but the linked Fibe runtime must also be funded. If Fibe rejects a production start with runtime billing/payment status, Likeable keeps the project stopped, creates a warning notice for the user, and reports `runtime_billing_required`. - -Support flow: - -1. Open Admin, select the user, and inspect the production project. -2. Open project diagnostics and check `production_runtime_status`, `production_runtime_message`, `server_id`, and `playground_id`. -3. If the status is `runtime_billing_required`, fund the linked Fibe runtime/marquee outside Likeable. -4. Click `Retry start` in Admin, or ask the user to click `Start playground`. -5. Confirm the project moves to `launching` or `ready`, and `/healthz` remains healthy. - -The hourly production sweep also retries stopped production projects, so `Retry start` is mainly for immediate support verification after funding. - -Custom-domain routing still depends on customer DNS: after a production-project purchase, the user saves the intended custom domain, points a CNAME at the project target, and runs DNS verification from the project menu. Once DNS verifies, Likeable syncs the custom host into the Fibe app routing. Admin diagnostics include the saved domain, DNS status, target, and any routing failure. +Likeable is scoped to experiments and development playgrounds. Keep billing in Likeable limited to build-hour packs and extra playground slots. Production hosting, custom domains, always-on runtimes, and customer DNS work belong in Fibe. ## Development With Live Reload diff --git a/bin/deploy-preflight b/bin/deploy-preflight index 4b1a470..9e5b5fe 100755 --- a/bin/deploy-preflight +++ b/bin/deploy-preflight @@ -23,7 +23,7 @@ for script in bin/*; do done LIKEABLE_BASE_URL=http://localhost \ LIKEABLE_DRY_RUN=1 \ - LIKEABLE_STRIPE_PRODUCTION_PROJECT_PRICE_ID=price_preflight \ + LIKEABLE_PLAYGROUND_IDLE_STOP_HOURS=8 \ LIKEABLE_GOOGLE_CLIENT_ID=google-client-preflight \ LIKEABLE_GOOGLE_CLIENT_SECRET=google-secret-preflight \ LIKEABLE_SMTP_HOST=smtp.example.test \ diff --git a/bin/live-configure b/bin/live-configure index efc6a00..f7d02d6 100755 --- a/bin/live-configure +++ b/bin/live-configure @@ -21,7 +21,7 @@ trap cleanup EXIT node - <<'NODE' >"$payload_file" const mappings = [ - ["LIKEABLE_STRIPE_PRODUCTION_PROJECT_PRICE_ID", "stripe_production_project_price_id"], + ["LIKEABLE_PLAYGROUND_IDLE_STOP_HOURS", "playground_idle_stop_hours"], ["LIKEABLE_GOOGLE_CLIENT_ID", "google_client_id"], ["LIKEABLE_GOOGLE_CLIENT_SECRET", "google_client_secret"], ["LIKEABLE_SMTP_HOST", "smtp_host"], @@ -46,8 +46,13 @@ if (Object.keys(payload).length === 0) { process.exit(2); } const errors = []; -if (payload.stripe_production_project_price_id && !payload.stripe_production_project_price_id.startsWith("price_")) { - errors.push("LIKEABLE_STRIPE_PRODUCTION_PROJECT_PRICE_ID must start with price_"); +if (payload.playground_idle_stop_hours && !/^[0-9]+$/.test(payload.playground_idle_stop_hours)) { + errors.push("LIKEABLE_PLAYGROUND_IDLE_STOP_HOURS must be numeric"); +} else if (payload.playground_idle_stop_hours) { + const hours = Number(payload.playground_idle_stop_hours); + if (hours < 1 || hours > 168) { + errors.push("LIKEABLE_PLAYGROUND_IDLE_STOP_HOURS must be between 1 and 168"); + } } if (provided.has("LIKEABLE_GOOGLE_CLIENT_ID") !== provided.has("LIKEABLE_GOOGLE_CLIENT_SECRET")) { errors.push("LIKEABLE_GOOGLE_CLIENT_ID and LIKEABLE_GOOGLE_CLIENT_SECRET must be provided together"); diff --git a/frontend/src/admin.tsx b/frontend/src/admin.tsx index dc9838b..4679ede 100644 --- a/frontend/src/admin.tsx +++ b/frontend/src/admin.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { Loader2, Plus, RefreshCw, Send, ShieldCheck, Trash2 } from 'lucide-react'; +import { Loader2, Plus, RefreshCw, Send, Trash2 } from 'lucide-react'; import { cleanPoolRows, makePoolRow, parsePoolRows } from './admin_pool'; import { api } from './api'; import { AppDialog, Metric } from './builder_components'; @@ -20,8 +20,6 @@ function AdminCustomersPanel() { const [loading, setLoading] = useState(false); const [actionError, setActionError] = useState(''); const [reassigningProjectID, setReassigningProjectID] = useState(''); - const [productionGrantingProjectID, setProductionGrantingProjectID] = useState(''); - const [productionStartingProjectID, setProductionStartingProjectID] = useState(''); const [diagnosticsProjectID, setDiagnosticsProjectID] = useState(''); const [diagnostics, setDiagnostics] = useState(null); const [diagnosticsLoadingID, setDiagnosticsLoadingID] = useState(''); @@ -228,38 +226,6 @@ function AdminCustomersPanel() { setReassigningProjectID(''); } }; - const applyProductionGrant = async (projectID: string) => { - if (!selectedUserID) return; - setActionError(''); - setProductionGrantingProjectID(projectID); - try { - const response = await api<{ detail: AdminUserDetail }>(`/api/admin/users/${selectedUserID}/projects/${projectID}/production`, { - method: 'POST', - body: JSON.stringify({}) - }); - setDetail(response.detail); - setAgentPool((current) => response.detail.agentPool ?? current); - await loadUsers(); - if (diagnosticsProjectID === projectID) { - setDiagnosticsProjectID(''); - setDiagnostics(null); - } - } catch (err) { - setActionError(err instanceof Error ? err.message : t('admin.productionGrant.failed')); - } finally { - setProductionGrantingProjectID(''); - } - }; - const grantProduction = async (project: AdminProjectSummary['project']) => { - if (!selectedUserID) return; - setDialog({ - title: t('admin.productionGrantDialog.title'), - body: t('admin.productionGrantDialog.body', { title: project.title }), - tone: 'warning', - confirmLabel: t(project.productionExpiresAt ? 'admin.productionGrant.extend' : 'admin.productionGrant.enable'), - onConfirm: () => applyProductionGrant(project.id) - }); - }; const loadProjectDiagnostics = async (projectID: string) => { if (!selectedUserID) return; if (diagnosticsProjectID === projectID) { @@ -279,37 +245,6 @@ function AdminCustomersPanel() { setDiagnosticsLoadingID(''); } }; - const refreshOpenProjectDiagnostics = async (projectID: string) => { - if (!selectedUserID || diagnosticsProjectID !== projectID) return; - try { - const response = await api(`/api/admin/users/${selectedUserID}/projects/${projectID}/diagnostics`); - setDiagnostics(response.diagnostics); - } catch { - setDiagnosticsProjectID(''); - setDiagnostics(null); - } - }; - const retryProductionStart = async (projectID: string) => { - if (!selectedUserID) return; - setActionError(''); - setProductionStartingProjectID(projectID); - try { - const response = await api<{ detail: AdminUserDetail; warning?: string }>(`/api/admin/users/${selectedUserID}/projects/${projectID}/production/start`, { - method: 'POST', - body: JSON.stringify({}) - }); - setDetail(response.detail); - setAgentPool((current) => response.detail.agentPool ?? current); - if (response.warning) setActionError(response.warning); - await refreshOpenProjectDiagnostics(projectID); - await loadUsers(); - } catch (err) { - setActionError(err instanceof Error ? err.message : t('admin.productionStart.failed')); - } finally { - setProductionStartingProjectID(''); - } - }; - return (
{dialog && setDialog(null)} />} @@ -513,7 +448,6 @@ function AdminCustomersPanel() { const assignment = item.assignment; const assignmentValue = pairValue(assignment?.agentId ?? '', assignment?.serverId ?? ''); const canReassign = options.some((option) => option.status === 'active') && item.project.status !== 'archived' && item.project.status !== 'deleting'; - const canRetryProductionStart = Boolean(item.project.productionExpiresAt) && item.project.status === 'stopped'; const previewUrl = item.project.previewUrl; const projectDiagnostics = diagnosticsProjectID === item.project.id ? diagnostics : null; return ( @@ -545,16 +479,6 @@ function AdminCustomersPanel() { {diagnosticsLoadingID === item.project.id ? : null} {projectDiagnostics ? t('admin.diagnostics.hide') : t('admin.diagnostics.show')} - - {item.project.productionExpiresAt && ( - - )}
@@ -725,8 +649,7 @@ function billingIssueLabel(issue: string, t: (key: TranslationKey, params?: Reco stripe_secret_missing: 'admin.billingHealth.issue.secret', stripe_webhook_missing: 'admin.billingHealth.issue.webhook', stripe_hour_prices_missing: 'admin.billingHealth.issue.hourPrices', - stripe_project_quota_price_missing: 'admin.billingHealth.issue.projectQuota', - stripe_production_project_price_missing: 'admin.billingHealth.issue.productionProject' + stripe_project_quota_price_missing: 'admin.billingHealth.issue.projectQuota' }; const key = keys[issue]; return key ? t(key) : t('admin.billingHealth.issue.unknown', { issue }); @@ -756,8 +679,7 @@ function AdminBillingHealthPanel() { const hourPacks = health?.products.hourPacks ?? []; const productsLabel = [ hourPacks.length > 0 ? t('admin.billingHealth.hourPacks', { packs: hourPacks.map((pack) => `${pack}h`).join(', ') }) : t('admin.billingHealth.noHourPacks'), - health?.products.projectQuota ? t('admin.billingHealth.projectQuotaOn') : t('admin.billingHealth.projectQuotaOff'), - health?.products.productionProject ? t('admin.billingHealth.productionProjectOn') : t('admin.billingHealth.productionProjectOff') + health?.products.projectQuota ? t('admin.billingHealth.projectQuotaOn') : t('admin.billingHealth.projectQuotaOff') ].join(' · '); return (
@@ -826,7 +748,6 @@ function readinessCheckLabel(key: string, t: (key: TranslationKey, params?: Reco stripe_webhook: 'admin.readiness.check.stripeWebhook', stripe_hour_prices: 'admin.readiness.check.hourPrices', stripe_project_quota_price: 'admin.readiness.check.projectQuotaPrice', - stripe_production_project_price: 'admin.readiness.check.productionProjectPrice', fibe_template_version: 'admin.readiness.check.fibeTemplate', fibe_active_pool: 'admin.readiness.check.activePool', fibe_active_pool_health: 'admin.readiness.check.activePoolHealth', @@ -913,14 +834,13 @@ function adminConfigLabel(key: string, t: (key: TranslationKey) => string): stri stripe_price_id_10_hours: 'admin.config.stripe_price_id_10_hours', stripe_price_id_100_hours: 'admin.config.stripe_price_id_100_hours', stripe_project_quota_price_id: 'admin.config.stripe_project_quota_price_id', - stripe_production_project_price_id: 'admin.config.stripe_production_project_price_id', stripe_webhook_secret: 'admin.config.stripe_webhook_secret', free_minutes: 'admin.config.free_minutes', free_hour_window_hours: 'admin.config.free_hour_window_hours', + playground_idle_stop_hours: 'admin.config.playground_idle_stop_hours', prompt_improve_charge_minutes: 'admin.config.prompt_improve_charge_minutes', project_cap: 'admin.config.project_cap', project_quota_days: 'admin.config.project_quota_days', - production_project_days: 'admin.config.production_project_days', agent_artefacts: 'admin.config.agent_artefacts' }; const labelKey = labelKeys[key]; diff --git a/frontend/src/builder_components.tsx b/frontend/src/builder_components.tsx index 98336d7..3f7ab41 100644 --- a/frontend/src/builder_components.tsx +++ b/frontend/src/builder_components.tsx @@ -1,16 +1,14 @@ import { useEffect, useState, type MouseEvent, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; -import { Check, ChevronLeft, ChevronRight, CircleAlert, CircleHelp, Download, FileOutput, GitBranch, Globe2, Loader2, MessageSquare, Minimize2, MoreHorizontal, Paperclip, Pencil, Play, Plus, RotateCcw, Sparkles, Square, Trash2, X } from 'lucide-react'; +import { Check, ChevronLeft, ChevronRight, CircleAlert, CircleHelp, Download, FileOutput, GitBranch, Loader2, MessageSquare, Minimize2, MoreHorizontal, Paperclip, Pencil, Play, Plus, RotateCcw, Sparkles, Square, Trash2, X } from 'lucide-react'; import type { AppDialogConfig, MessageAttachment, Project, ProjectService, UserFeedRow } from './domain'; -import { formatElapsedDuration, formatMessageTime, formatShortDate } from './format'; +import { formatElapsedDuration, formatMessageTime } from './format'; import { elapsedDurationLabels, statusLabel, useI18n } from './i18n'; -export function ProjectList({ projects, activeID, projectCap, busy, exportingID, controllingID, productionCheckoutID, productionPurchasable, domainUpdatingID, domainVerifyingID, onSelect, onNew, onRename, onDelete, onExport, onControlPlayground, onCheckoutProductionProject, onUpdateProjectDomain, onVerifyProjectDomain, onClose }: { projects: Project[]; activeID: string; projectCap: number | null; busy: boolean; exportingID: string; controllingID: string; productionCheckoutID: string; productionPurchasable: boolean; domainUpdatingID: string; domainVerifyingID: string; onSelect: (id: string) => void; onNew: () => void; onRename: (project: Project, title: string) => Promise; onDelete: (project: Project) => void; onExport: (project: Project) => void; onControlPlayground: (project: Project, action: 'start' | 'stop' | 'restart') => Promise; onCheckoutProductionProject: (project: Project) => Promise; onUpdateProjectDomain: (project: Project, domain: string) => Promise; onVerifyProjectDomain: (project: Project) => Promise; onClose: () => void }) { +export function ProjectList({ projects, activeID, projectCap, busy, exportingID, controllingID, onSelect, onNew, onRename, onDelete, onExport, onControlPlayground, onClose }: { projects: Project[]; activeID: string; projectCap: number | null; busy: boolean; exportingID: string; controllingID: string; onSelect: (id: string) => void; onNew: () => void; onRename: (project: Project, title: string) => Promise; onDelete: (project: Project) => void; onExport: (project: Project) => void; onControlPlayground: (project: Project, action: 'start' | 'stop' | 'restart') => Promise; onClose: () => void }) { const { locale, t } = useI18n(); const [editingID, setEditingID] = useState(''); const [draftTitle, setDraftTitle] = useState(''); - const [domainEditingID, setDomainEditingID] = useState(''); - const [domainDraft, setDomainDraft] = useState(''); const [menuID, setMenuID] = useState(''); const quotaProjectCount = projects.filter((project) => project.status !== 'archived' && project.status !== 'deleting').length; const capReached = projectCap != null && quotaProjectCount >= projectCap; @@ -38,32 +36,8 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID, setMenuID(''); await onControlPlayground(project, action); }; - const checkoutProductionProject = async (project: Project) => { - setMenuID(''); - await onCheckoutProductionProject(project); - }; - const startDomainEdit = (project: Project) => { - setMenuID(''); - setDomainEditingID(project.id); - setDomainDraft(project.customDomain ?? ''); - }; - const cancelDomainEdit = () => { - setDomainEditingID(''); - setDomainDraft(''); - }; - const saveDomain = async (project: Project) => { - await onUpdateProjectDomain(project, domainDraft.trim()); - cancelDomainEdit(); - }; - const verifyDomain = async (project: Project) => { - await onVerifyProjectDomain(project); - }; - const removeDomain = async (project: Project) => { - setMenuID(''); - await onUpdateProjectDomain(project, ''); - }; const canStartPlayground = (project: Project) => project.status === 'stopped'; - const canStopPlayground = (project: Project) => project.status === 'ready' && !project.productionExpiresAt; + const canStopPlayground = (project: Project) => project.status === 'ready'; const canRestartPlayground = (project: Project) => project.status === 'ready'; const projectRuntimeState = (project: Project) => { if (controllingID === project.id) return t('projects.actions.working'); @@ -91,9 +65,6 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID, : t('projects.rowMeta.servicesOnly', { services: serviceCount }); }; const projectRowDetail = (project: Project) => { - if (project.productionExpiresAt) { - return t('projects.production.until', { date: formatShortDate(project.productionExpiresAt, locale) }); - } switch (project.status) { case 'creating': case 'launching': @@ -108,16 +79,6 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID, return ''; } }; - const projectDomainTarget = (project: Project) => { - const url = project.services?.find((service) => service.name === (project.selectedServiceName || 'app'))?.url || project.previewUrl || project.services?.[0]?.url || ''; - if (!url) return ''; - try { - return new URL(url).host; - } catch { - return url.replace(/^https?:\/\//, '').split('/')[0] ?? ''; - } - }; - const isDomainDNSVerified = (project: Project) => project.customDomainStatus === 'dns_verified' || project.customDomainStatus === 'active'; return (
@@ -131,14 +92,9 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID,
{projects.map((project, index) => { const detail = projectRowDetail(project); - const cnameTarget = project.customDomainTarget || projectDomainTarget(project); - const domainEditing = domainEditingID === project.id; - const domainSaving = domainUpdatingID === project.id; - const domainVerifying = domainVerifyingID === project.id; - const domainBusy = domainSaving || domainVerifying; const menuOpensUp = index > 0; return ( -
+
{editingID === project.id ? (
{ event.preventDefault(); void saveTitle(project); }}> {project.title} {project.id === activeID && {t('service.current')}} - {project.productionExpiresAt && {t('projects.production.badge')}} {projectRowMeta(project)} {detail && {detail}} - {project.customDomain && {t('projects.production.domain', { domain: project.customDomain })} · {isDomainDNSVerified(project) ? t('projects.production.domainActive') : t('projects.production.domainPending')}} - {project.productionExpiresAt && cnameTarget && {t('projects.production.cname', { host: cnameTarget })}} {statusLabel(project.status, t)}
@@ -208,53 +161,10 @@ export function ProjectList({ projects, activeID, projectCap, busy, exportingID, - - - {project.customDomain && ( - - )} - {project.customDomain && ( - - )}
)}
- {domainEditing && ( - { event.preventDefault(); void saveDomain(project); }}> - - setDomainDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Escape') { - event.preventDefault(); - cancelDomainEdit(); - } - }} - /> - - - - )} )}
diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 296110b..c07a7cb 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -25,11 +25,11 @@ export const ADMIN_CONFIG_SECTIONS = [ { titleKey: 'admin.config.stripe.title', bodyKey: 'admin.config.stripe.body', - keys: ['stripe_publishable_key', 'stripe_secret_key', 'stripe_price_id_1_hour', 'stripe_price_id_10_hours', 'stripe_price_id_100_hours', 'stripe_project_quota_price_id', 'stripe_production_project_price_id', 'stripe_webhook_secret'] + keys: ['stripe_publishable_key', 'stripe_secret_key', 'stripe_price_id_1_hour', 'stripe_price_id_10_hours', 'stripe_price_id_100_hours', 'stripe_project_quota_price_id', 'stripe_webhook_secret'] }, { titleKey: 'admin.config.application.title', bodyKey: 'admin.config.application.body', - keys: ['fibe_template_version_id', 'free_minutes', 'free_hour_window_hours', 'prompt_improve_charge_minutes', 'project_cap', 'project_quota_days', 'production_project_days', 'agent_artefacts', 'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'smtp_from_email', 'smtp_from_name', 'smtp_tls_mode'] + keys: ['fibe_template_version_id', 'free_minutes', 'free_hour_window_hours', 'playground_idle_stop_hours', 'prompt_improve_charge_minutes', 'project_cap', 'project_quota_days', 'agent_artefacts', 'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'smtp_from_email', 'smtp_from_name', 'smtp_tls_mode'] } ]; diff --git a/frontend/src/domain.ts b/frontend/src/domain.ts index c95c526..7fddd48 100644 --- a/frontend/src/domain.ts +++ b/frontend/src/domain.ts @@ -4,7 +4,7 @@ export type ProjectService = { id: string; name: string; url: string; type?: str export type Project = { id: string; title: string; previewUrl?: string; selectedServiceName?: string; repositories?: ProjectRepository[]; services?: ProjectService[]; status: string; errorMessage?: string; playgroundLastUsedAt?: string; playgroundIdleStopAt?: string; productionExpiresAt?: string; customDomain?: string; customDomainStatus?: string; customDomainTarget?: string; customDomainUpdatedAt?: string; createdAt: string; updatedAt: string }; export type HourQuota = { usedMs: number; limitMs: number; remainingMs: number; paidRemainingMs?: number; lifetimeUsedMs?: number; resetsAt?: string; windowHours?: number }; export type ProjectQuota = { used: number; limit: number; remaining: number; baseLimit: number; paidSlots: number; nextExpiresAt?: string }; -export type BillingProducts = { hourPacks: number[]; projectQuota: boolean; projectQuotaDays?: number; productionProject?: boolean; productionProjectDays?: number }; +export type BillingProducts = { hourPacks: number[]; projectQuota: boolean; projectQuotaDays?: number }; export type UserNotice = { id: string; userId?: string; sender: 'admin' | 'system' | 'user'; severity: string; body: string; readAt?: string; dismissedAt?: string; unsentAt?: string; createdAt: string }; export type ProjectArchive = { id: string; projectId: string; projectTitle: string; downloadUrl?: string; githubRepoUrl?: string; status: string; error?: string; expiresAt: string; createdAt: string }; export type Me = { user: User | null; isAdmin?: boolean; githubConnected?: boolean; githubNeedsReconnect?: boolean; hourQuota?: HourQuota; projectQuota?: ProjectQuota; billingProducts?: BillingProducts; notices?: UserNotice[]; auth?: { googleConfigured: boolean; devAuth: boolean; devEmail?: string } }; @@ -75,7 +75,7 @@ export type AdminBillingHealth = { checkedAt: string; configured: { publishableKey: boolean; secretKey: boolean; webhookSecret: boolean }; products: BillingProducts; - prices: { oneHour: boolean; tenHours: boolean; hundredHours: boolean; projectQuota: boolean; productionProject?: boolean }; + prices: { oneHour: boolean; tenHours: boolean; hundredHours: boolean; projectQuota: boolean }; free: { minutes: number; windowHours: number }; issues: string[]; recentPayments: AdminBillingPayment[]; diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index 9d81754..ab03074 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -141,6 +141,7 @@ Describe the app you want in the composer. Fibe builds the first playground in a - Free build time resets on a window. Paid time rolls over and is used after free time. - Playgrounds auto-pause when inactive. Start them again from the menu. +- Likeable is for experiments and development. For production hosting, always-on runtimes, and custom domains, continue the project in Fibe. - Archived playgrounds stay downloadable for a while after archiving. - Delete a playground or the whole account from Profile — everything goes with it. @@ -358,21 +359,6 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'projects.rowDetail.error': 'Workspace needs attention before it can run.', 'projects.rowDetail.stopped': 'Preview is paused; start it from actions.', 'projects.rowDetail.archived': 'Archived project. Export or create a new playground.', - 'projects.production.badge': 'Production', - 'projects.production.until': 'Always-on until {date}', - 'projects.production.cname': 'Point CNAME to {host}', - 'projects.production.domain': 'Custom domain: {domain}', - 'projects.production.domainActive': 'DNS verified · routing active', - 'projects.production.domainPending': 'DNS pending', - 'projects.production.domainCheck': 'Check DNS', - 'projects.production.domainChecking': 'Checking DNS', - 'projects.production.domainAction': 'Custom domain', - 'projects.production.domainRemove': 'Remove custom domain', - 'projects.production.domainPlaceholder': 'app.example.com', - 'projects.production.domainSave': 'Save domain', - 'projects.production.domainVerifyFailed': 'Could not verify DNS', - 'projects.production.buy': 'Make production', - 'projects.production.unavailable': 'Production package unavailable', 'projects.rename.aria': 'Rename {title}', 'projects.delete.aria': 'Delete {title}', 'projects.export.aria': 'Export {title}', @@ -506,8 +492,6 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'admin.billingHealth.noHourPacks': 'no hour packs', 'admin.billingHealth.projectQuotaOn': 'project slots on', 'admin.billingHealth.projectQuotaOff': 'project slots off', - 'admin.billingHealth.productionProjectOn': 'production projects on', - 'admin.billingHealth.productionProjectOff': 'production projects off', 'admin.billingHealth.noIssues': 'Billing configuration has no obvious launch blockers.', 'admin.billingHealth.issue': 'Issue', 'admin.billingHealth.issue.publishable': 'Stripe publishable key is missing.', @@ -515,13 +499,12 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'admin.billingHealth.issue.webhook': 'Stripe webhook secret is missing; async payment delivery cannot be verified.', 'admin.billingHealth.issue.hourPrices': 'No hour-pack Stripe price IDs are configured.', 'admin.billingHealth.issue.projectQuota': 'Project-slot Stripe price ID is missing.', - 'admin.billingHealth.issue.productionProject': 'Production-project Stripe price ID is missing.', 'admin.billingHealth.issue.unknown': 'Unknown billing issue: {issue}', 'admin.billingHealth.recentPayments': 'Recent payments', 'admin.billingHealth.checkedAt': 'Checked at {time}', 'admin.billingHealth.noPayments': 'No payment records yet.', 'admin.readiness.title': 'Launch readiness', - 'admin.readiness.body': 'Production blockers across Stripe, Fibe runtime, OAuth, support mail, and access policy.', + 'admin.readiness.body': 'Launch blockers across Stripe, Fibe playgrounds, OAuth, support mail, and access policy.', 'admin.readiness.refresh': 'Refresh', 'admin.readiness.loadFailed': 'Could not load launch readiness', 'admin.readiness.status': 'Status', @@ -530,14 +513,13 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'admin.readiness.blockers': 'Blockers', 'admin.readiness.warnings': 'Warnings', 'admin.readiness.checkedAt': 'Checked at', - 'admin.readiness.noIssues': 'Production launch checks are clear.', + 'admin.readiness.noIssues': 'Launch checks are clear.', 'admin.readiness.blocker': 'Launch blocker', 'admin.readiness.warning': 'Needs attention', 'admin.readiness.check.stripeSecret': 'Stripe secret key', 'admin.readiness.check.stripeWebhook': 'Stripe webhook secret', 'admin.readiness.check.hourPrices': 'Stripe hour-pack price', 'admin.readiness.check.projectQuotaPrice': 'Stripe project-slot price', - 'admin.readiness.check.productionProjectPrice': 'Stripe production-project price', 'admin.readiness.check.fibeTemplate': 'Fibe greenfield template', 'admin.readiness.check.activePool': 'Active Fibe pool', 'admin.readiness.check.activePoolHealth': 'Healthy active Fibe pool', @@ -603,13 +585,6 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'admin.diagnostics.workSessions': 'Work sessions', 'admin.diagnostics.hourLedger': 'Hour ledger', 'admin.diagnostics.empty': 'No records.', - 'admin.productionGrant.enable': 'Enable production', - 'admin.productionGrant.extend': 'Extend production', - 'admin.productionGrant.failed': 'Could not enable production mode', - 'admin.productionStart.retry': 'Retry start', - 'admin.productionStart.failed': 'Could not retry production start', - 'admin.productionGrantDialog.title': 'Enable production mode?', - 'admin.productionGrantDialog.body': 'This keeps {title} online for the configured production-project duration and sends the user a notice.', 'admin.noActiveProjects': 'No active playgrounds.', 'admin.deleteProject.aria': 'Delete {title}', 'admin.loadUsersFailed': 'Could not load users', @@ -698,14 +673,13 @@ Likeable uses reasonable technical and organizational safeguards for account, pr 'admin.config.stripe_price_id_10_hours': '10 hours price ID', 'admin.config.stripe_price_id_100_hours': '100 hours price ID', 'admin.config.stripe_project_quota_price_id': 'Playground quota price ID', - 'admin.config.stripe_production_project_price_id': 'Production project price ID', 'admin.config.stripe_webhook_secret': 'Webhook secret', 'admin.config.free_minutes': 'Free minutes per window', 'admin.config.free_hour_window_hours': 'Free window hours', + 'admin.config.playground_idle_stop_hours': 'Playground idle stop hours', 'admin.config.prompt_improve_charge_minutes': 'Improve prompt charge minutes', 'admin.config.project_cap': 'Base playground cap', 'admin.config.project_quota_days': 'Paid playground slot days', - 'admin.config.production_project_days': 'Production project days', 'admin.config.agent_artefacts': 'Agent artefacts JSON' } as const; @@ -852,6 +826,7 @@ const uk: Record = { - Безплатний час збірки оновлюється кожне вікно. Платний час переходить на наступний період і використовується після безплатного часу. - Майданчики автоматично призупиняються в стані спокою. Запустити знову — з меню. +- Likeable призначений для експериментів і розробки. Для production hosting, always-on runtime і кастомних доменів продовжуйте проєкт у Fibe. - Архівовані майданчики можна завантажити обмежений час після архівування. - Видалення майданчика чи всього акаунта — у Профілі. Усе зникає разом. @@ -1069,21 +1044,6 @@ Likeable застосовує розумні технічні та органі 'projects.rowDetail.error': 'Робочий простір потребує уваги перед запуском.', 'projects.rowDetail.stopped': 'Превʼю призупинено; запустіть його з дій.', 'projects.rowDetail.archived': 'Архівний проєкт. Експортуйте або створіть новий майданчик.', - 'projects.production.badge': 'Production', - 'projects.production.until': 'Always-on до {date}', - 'projects.production.cname': 'Спрямуйте CNAME на {host}', - 'projects.production.domain': 'Кастомний домен: {domain}', - 'projects.production.domainActive': 'DNS перевірено · маршрутизація активна', - 'projects.production.domainPending': 'DNS очікує', - 'projects.production.domainCheck': 'Перевірити DNS', - 'projects.production.domainChecking': 'Перевіряємо DNS', - 'projects.production.domainAction': 'Кастомний домен', - 'projects.production.domainRemove': 'Прибрати кастомний домен', - 'projects.production.domainPlaceholder': 'app.example.com', - 'projects.production.domainSave': 'Зберегти домен', - 'projects.production.domainVerifyFailed': 'Не вдалося перевірити DNS', - 'projects.production.buy': 'Зробити production', - 'projects.production.unavailable': 'Production пакет вимкнено', 'projects.rename.aria': 'Перейменувати {title}', 'projects.delete.aria': 'Видалити {title}', 'projects.export.aria': 'Експортувати {title}', @@ -1217,8 +1177,6 @@ Likeable застосовує розумні технічні та органі 'admin.billingHealth.noHourPacks': 'немає пакетів годин', 'admin.billingHealth.projectQuotaOn': 'місця проєктів увімкнено', 'admin.billingHealth.projectQuotaOff': 'місця проєктів вимкнено', - 'admin.billingHealth.productionProjectOn': 'production проєкти увімкнено', - 'admin.billingHealth.productionProjectOff': 'production проєкти вимкнено', 'admin.billingHealth.noIssues': 'У конфігурації білінгу немає очевидних блокерів запуску.', 'admin.billingHealth.issue': 'Проблема', 'admin.billingHealth.issue.publishable': 'Немає Stripe publishable key.', @@ -1226,13 +1184,12 @@ Likeable застосовує розумні технічні та органі 'admin.billingHealth.issue.webhook': 'Немає Stripe webhook secret; async доставку платежів не можна перевірити.', 'admin.billingHealth.issue.hourPrices': 'Не налаштовано Stripe price ID для пакетів годин.', 'admin.billingHealth.issue.projectQuota': 'Немає Stripe price ID для місць проєктів.', - 'admin.billingHealth.issue.productionProject': 'Немає Stripe price ID для production проєктів.', 'admin.billingHealth.issue.unknown': 'Невідома проблема білінгу: {issue}', 'admin.billingHealth.recentPayments': 'Останні платежі', 'admin.billingHealth.checkedAt': 'Перевірено о {time}', 'admin.billingHealth.noPayments': 'Записів платежів ще немає.', 'admin.readiness.title': 'Готовність до запуску', - 'admin.readiness.body': 'Production блокери у Stripe, Fibe runtime, OAuth, пошті підтримки та політиці доступу.', + 'admin.readiness.body': 'Блокери запуску у Stripe, Fibe playgrounds, OAuth, пошті підтримки та політиці доступу.', 'admin.readiness.refresh': 'Оновити', 'admin.readiness.loadFailed': 'Не вдалося завантажити готовність до запуску', 'admin.readiness.status': 'Статус', @@ -1241,14 +1198,13 @@ Likeable застосовує розумні технічні та органі 'admin.readiness.blockers': 'Блокери', 'admin.readiness.warnings': 'Попередження', 'admin.readiness.checkedAt': 'Перевірено', - 'admin.readiness.noIssues': 'Production перевірки запуску чисті.', + 'admin.readiness.noIssues': 'Перевірки запуску чисті.', 'admin.readiness.blocker': 'Блокер запуску', 'admin.readiness.warning': 'Потребує уваги', 'admin.readiness.check.stripeSecret': 'Stripe secret key', 'admin.readiness.check.stripeWebhook': 'Stripe webhook secret', 'admin.readiness.check.hourPrices': 'Stripe price для пакетів годин', 'admin.readiness.check.projectQuotaPrice': 'Stripe price для місць проєктів', - 'admin.readiness.check.productionProjectPrice': 'Stripe price для production проєкту', 'admin.readiness.check.fibeTemplate': 'Fibe greenfield template', 'admin.readiness.check.activePool': 'Активний Fibe pool', 'admin.readiness.check.activePoolHealth': 'Здоровий активний Fibe pool', @@ -1314,13 +1270,6 @@ Likeable застосовує розумні технічні та органі 'admin.diagnostics.workSessions': 'Work sessions', 'admin.diagnostics.hourLedger': 'Hour ledger', 'admin.diagnostics.empty': 'Записів немає.', - 'admin.productionGrant.enable': 'Увімкнути production', - 'admin.productionGrant.extend': 'Продовжити production', - 'admin.productionGrant.failed': 'Не вдалося увімкнути production режим', - 'admin.productionStart.retry': 'Повторити старт', - 'admin.productionStart.failed': 'Не вдалося повторити старт production', - 'admin.productionGrantDialog.title': 'Увімкнути production режим?', - 'admin.productionGrantDialog.body': 'Це залишить {title} онлайн на налаштований термін production проєкту й надішле користувачу сповіщення.', 'admin.noActiveProjects': 'Активних майданчиків немає.', 'admin.deleteProject.aria': 'Видалити {title}', 'admin.loadUsersFailed': 'Не вдалося завантажити користувачів', @@ -1409,14 +1358,13 @@ Likeable застосовує розумні технічні та органі 'admin.config.stripe_price_id_10_hours': 'Price ID для 10 годин', 'admin.config.stripe_price_id_100_hours': 'Price ID для 100 годин', 'admin.config.stripe_project_quota_price_id': 'Price ID ліміту майданчиків', - 'admin.config.stripe_production_project_price_id': 'Price ID production проєкту', 'admin.config.stripe_webhook_secret': 'Webhook secret', 'admin.config.free_minutes': 'Безплатних хвилин за вікно', 'admin.config.free_hour_window_hours': 'Години безплатного вікна', + 'admin.config.playground_idle_stop_hours': 'Годин до зупинки майданчика', 'admin.config.prompt_improve_charge_minutes': 'Хвилин списання за Improve Prompt', 'admin.config.project_cap': 'Базовий ліміт майданчиків', 'admin.config.project_quota_days': 'Днів платного місця майданчика', - 'admin.config.production_project_days': 'Днів production проєкту', 'admin.config.agent_artefacts': 'JSON артефактів агента' }; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 717eccc..19ada71 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -322,9 +322,6 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; const [exportingID, setExportingID] = useState(''); const [exportingMode, setExportingMode] = useState<'github' | 'zip' | ''>(''); const [controllingProjectID, setControllingProjectID] = useState(''); - const [productionCheckoutProjectID, setProductionCheckoutProjectID] = useState(''); - const [domainUpdatingProjectID, setDomainUpdatingProjectID] = useState(''); - const [domainVerifyingProjectID, setDomainVerifyingProjectID] = useState(''); const [dialog, setDialog] = useState(null); const [iframeLoaded, setIframeLoaded] = useState(false); const [previewStatus, setPreviewStatus] = useState(null); @@ -954,54 +951,6 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; setBusy(false); } }; - const checkoutProductionProject = async (project: Project) => { - if (!signedIn) return; - setBusy(true); - setProductionCheckoutProjectID(project.id); - try { - const res = await api<{ url: string }>('/api/billing/checkout', { - method: 'POST', - body: JSON.stringify({ product: 'production_project', projectId: project.id }) - }); - location.href = res.url; - } catch (err) { - setDialog({ title: t('profile.checkoutFailed'), body: err instanceof Error ? err.message : t('dialog.requestFailed.title'), tone: 'warning', confirmLabel: t('common.close') }); - setProductionCheckoutProjectID(''); - setBusy(false); - } - }; - const updateProjectDomain = async (project: Project, domain: string) => { - if (!signedIn) return; - setBusy(true); - setDomainUpdatingProjectID(project.id); - try { - const res = await api<{ project: Project }>(`/api/projects/${project.id}/domain`, domain - ? { method: 'PUT', body: JSON.stringify({ domain }) } - : { method: 'DELETE' }); - setProjects((current) => current.map((item) => item.id === project.id ? res.project : item)); - setFeed((current) => current?.project.id === project.id ? { ...current, project: res.project } : current); - } catch (err) { - setDialog({ title: t('dialog.requestFailed.title'), body: err instanceof Error ? err.message : t('dialog.requestFailed.title'), tone: 'warning', confirmLabel: t('common.close') }); - } finally { - setDomainUpdatingProjectID(''); - setBusy(false); - } - }; - const verifyProjectDomain = async (project: Project) => { - if (!signedIn) return; - setBusy(true); - setDomainVerifyingProjectID(project.id); - try { - const res = await api<{ project: Project }>(`/api/projects/${project.id}/domain/verify`, { method: 'POST' }); - setProjects((current) => current.map((item) => item.id === project.id ? res.project : item)); - setFeed((current) => current?.project.id === project.id ? { ...current, project: res.project } : current); - } catch (err) { - setDialog({ title: t('projects.production.domainVerifyFailed'), body: err instanceof Error ? err.message : t('dialog.requestFailed.title'), tone: 'warning', confirmLabel: t('common.close') }); - } finally { - setDomainVerifyingProjectID(''); - setBusy(false); - } - }; const requestProjectExport = (project: Project) => { setExportTarget(project); }; @@ -1454,7 +1403,7 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void; {projectTitleButton('chatProjectTitle', true, true)} {builderChrome} - {showProjects && { setActiveID(id); setShowProjects(false); }} onNew={() => setConfirmNewProject(true)} onRename={renameProject} onDelete={setDeleteTarget} onExport={requestProjectExport} onControlPlayground={controlProjectPlayground} onCheckoutProductionProject={checkoutProductionProject} onUpdateProjectDomain={updateProjectDomain} onVerifyProjectDomain={verifyProjectDomain} onClose={() => setShowProjects(false)} />} + {showProjects && { setActiveID(id); setShowProjects(false); }} onNew={() => setConfirmNewProject(true)} onRename={renameProject} onDelete={setDeleteTarget} onExport={requestProjectExport} onControlPlayground={controlProjectPlayground} onClose={() => setShowProjects(false)} />} {showProfile && } {showHelp && setShowHelp(false)} />} {showServices && activeProject?.services && void selectService(service)} onClose={() => setShowServices(false)} />} diff --git a/internal/domain/types.go b/internal/domain/types.go index bd856d3..9ce2bed 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -5,7 +5,7 @@ import ( "time" ) -const PlaygroundIdleStopAfter = 8 * time.Hour +const DefaultPlaygroundIdleStopAfter = 8 * time.Hour const ( ProjectDomainStatusPendingDNS = "pending_dns" @@ -64,9 +64,13 @@ type ProjectDomain struct { } func (p *Project) RefreshComputedFields() { + p.RefreshComputedFieldsWithIdleStopAfter(DefaultPlaygroundIdleStopAfter) +} + +func (p *Project) RefreshComputedFieldsWithIdleStopAfter(idleStopAfter time.Duration) { p.PlaygroundIdleStopAt = "" - if strings.TrimSpace(p.ProductionExpiresAt) != "" { - return + if idleStopAfter <= 0 { + idleStopAfter = DefaultPlaygroundIdleStopAfter } if p.Status != "ready" || strings.TrimSpace(p.PlaygroundLastUsedAt) == "" { return @@ -75,7 +79,7 @@ func (p *Project) RefreshComputedFields() { if err != nil { return } - p.PlaygroundIdleStopAt = lastUsedAt.UTC().Add(PlaygroundIdleStopAfter).Format(time.RFC3339Nano) + p.PlaygroundIdleStopAt = lastUsedAt.UTC().Add(idleStopAfter).Format(time.RFC3339Nano) } type ProjectRepository struct { diff --git a/internal/likeable/admin_handlers.go b/internal/likeable/admin_handlers.go index 39d0292..cb7997f 100644 --- a/internal/likeable/admin_handlers.go +++ b/internal/likeable/admin_handlers.go @@ -15,7 +15,6 @@ import ( ) const adminMaxHourGrant = 100 -const adminMaxProductionGrantDays = maxProductionProjectDays type AdminReadinessCheck struct { Key string `json:"key"` @@ -206,7 +205,6 @@ func (s *Server) handleAdminBillingHealth(w http.ResponseWriter, r *http.Request issues := stripeBillingIssues(stripeCfg, priceStatus) products := billingProductsFromConfig(stripeCfg) products["projectQuotaDays"] = s.projectQuotaDays(r.Context()) - products["productionProjectDays"] = s.productionProjectDays(r.Context()) writeJSON(w, http.StatusOK, map[string]any{ "health": map[string]any{ "checkedAt": time.Now().UTC().Format(time.RFC3339Nano), @@ -226,11 +224,10 @@ func (s *Server) handleAdminBillingHealth(w http.ResponseWriter, r *http.Request func stripePriceStatus(stripeCfg map[string]string) map[string]bool { return map[string]bool{ - "oneHour": strings.TrimSpace(stripeCfg["price_1_hour"]) != "", - "tenHours": strings.TrimSpace(stripeCfg["price_10_hours"]) != "", - "hundredHours": strings.TrimSpace(stripeCfg["price_100_hours"]) != "", - "projectQuota": strings.TrimSpace(stripeCfg["project_quota_price"]) != "", - "productionProject": strings.TrimSpace(stripeCfg["production_project_price"]) != "", + "oneHour": strings.TrimSpace(stripeCfg["price_1_hour"]) != "", + "tenHours": strings.TrimSpace(stripeCfg["price_10_hours"]) != "", + "hundredHours": strings.TrimSpace(stripeCfg["price_100_hours"]) != "", + "projectQuota": strings.TrimSpace(stripeCfg["project_quota_price"]) != "", } } @@ -248,9 +245,6 @@ func stripeBillingIssues(stripeCfg map[string]string, priceStatus map[string]boo if !priceStatus["projectQuota"] { issues = append(issues, "stripe_project_quota_price_missing") } - if !priceStatus["productionProject"] { - issues = append(issues, "stripe_production_project_price_missing") - } return issues } @@ -265,7 +259,6 @@ func (s *Server) adminReadiness(cfg map[string]string, pool []AgentPoolOption, p addAdminReadinessCheck(&checks, "stripe_webhook", "blocker", stripeWebhookOK, missingReadinessDetail(stripeWebhookOK, "set stripe_webhook_secret")) addAdminReadinessCheck(&checks, "stripe_hour_prices", "blocker", stripeHourPricesOK, missingReadinessDetail(stripeHourPricesOK, "set at least one hour-pack price id")) addAdminReadinessCheck(&checks, "stripe_project_quota_price", "blocker", priceStatus["projectQuota"], missingReadinessDetail(priceStatus["projectQuota"], "set stripe_project_quota_price_id")) - addAdminReadinessCheck(&checks, "stripe_production_project_price", "blocker", priceStatus["productionProject"], missingReadinessDetail(priceStatus["productionProject"], "set stripe_production_project_price_id")) greenfieldReady, greenfieldDetail := greenfieldTemplateReadiness(cfg) addAdminReadinessCheck(&checks, "fibe_template_version", "blocker", greenfieldReady, greenfieldDetail) activePoolCount := 0 @@ -748,125 +741,11 @@ func (s *Server) handleAdminUserProjectDiagnostics(w http.ResponseWriter, r *htt } func (s *Server) handleAdminUserProjectProductionGrant(w http.ResponseWriter, r *http.Request, userID, projectID string) { - var body struct { - Days int `json:"days"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, http.StatusBadRequest, "invalid json") - return - } - days := body.Days - if days == 0 { - days = s.productionProjectDays(r.Context()) - } - if days <= 0 || days > adminMaxProductionGrantDays { - writeError(w, http.StatusBadRequest, "days must be between 1 and "+strconv.Itoa(adminMaxProductionGrantDays)) - return - } - if _, err := s.store.UserByID(r.Context(), userID); err != nil { - writeError(w, http.StatusNotFound, "user not found") - return - } - project, err := s.store.ProjectForUser(r.Context(), userID, projectID) - if err != nil { - writeError(w, http.StatusNotFound, "project not found") - return - } - if project.Status == "archived" || project.Status == "deleting" { - writeError(w, http.StatusConflict, "production grant requires an active project") - return - } - expiresAt := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour) - granted, err := s.store.GrantProjectProduction(r.Context(), userID, projectID, "admin_production_"+uuid.NewString(), expiresAt) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - if granted { - s.startProductionProjectIfStopped(r.Context(), userID, projectID) - s.notifyProductionProjectPurchased(r.Context(), userID, projectID, expiresAt) - } - updated, err := s.store.ProjectForUser(r.Context(), userID, projectID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - windowStart, windowEnd := s.freeHourWindow(time.Now(), r.Context()) - detail, err := s.store.AdminUserDetail(r.Context(), userID, s.freeHourLimitMs(r.Context()), windowStart, windowEnd) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - detail.Summary.ProjectLimit = s.baseProjectCap(r.Context()) + detail.Summary.PaidProjectSlots - pool, err := s.adminAgentPoolOptions(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - decorateAdminDetailAssignmentStatuses(detail, pool) - detail.AgentPool = pool - writeJSON(w, http.StatusOK, map[string]any{"detail": detail, "project": updated, "granted": granted, "days": days}) + writeError(w, http.StatusGone, "production hosting is handled in Fibe") } func (s *Server) handleAdminUserProjectProductionStart(w http.ResponseWriter, r *http.Request, userID, projectID string) { - target, err := s.store.UserByID(r.Context(), userID) - if err != nil { - writeError(w, http.StatusNotFound, "user not found") - return - } - project, err := s.store.ProjectForUser(r.Context(), userID, projectID) - if err != nil { - writeError(w, http.StatusNotFound, "project not found") - return - } - if strings.TrimSpace(project.ProductionExpiresAt) == "" { - writeError(w, http.StatusConflict, "production start retry requires an active production grant") - return - } - if project.Status == "archived" || project.Status == "deleting" { - writeError(w, http.StatusConflict, "production start retry requires an active project") - return - } - - started := false - blockedCode := "" - warning := "" - updated := project - if project.Status == "stopped" { - updated, err = s.controlProjectPlayground(r.Context(), target, project, "start") - if err != nil { - if fibe.IsRuntimeBillingRequiredError(err) { - blockedCode = "runtime_billing_required" - warning = productionRuntimeBillingRequiredMessage - s.notifyProductionProjectStartBlocked(r.Context(), target, project) - updated = project - } else if isPlatformRateLimited(err) { - writeError(w, http.StatusServiceUnavailable, "workspace platform is rate limited; try again shortly") - return - } else { - writeError(w, http.StatusBadGateway, "could not start production playground") - return - } - } else { - started = true - } - } - - windowStart, windowEnd := s.freeHourWindow(time.Now(), r.Context()) - detail, err := s.store.AdminUserDetail(r.Context(), userID, s.freeHourLimitMs(r.Context()), windowStart, windowEnd) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - detail.Summary.ProjectLimit = s.baseProjectCap(r.Context()) + detail.Summary.PaidProjectSlots - pool, err := s.adminAgentPoolOptions(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - decorateAdminDetailAssignmentStatuses(detail, pool) - detail.AgentPool = pool - writeJSON(w, http.StatusAccepted, map[string]any{"detail": detail, "project": updated, "started": started, "blockedCode": blockedCode, "warning": warning}) + writeError(w, http.StatusGone, "production hosting is handled in Fibe") } func (s *Server) decorateAdminProjectRuntimeDiagnostics(ctx context.Context, diagnostics *AdminProjectDiagnostics) { @@ -1130,7 +1009,7 @@ func firstNonEmptyString(values ...string) string { func publicAdminConfig(cfg map[string]string) map[string]any { out := map[string]any{} - for _, key := range []string{"fibe_base_url", "fibe_agent_server_pool", "fibe_template_version_id", "free_minutes", "free_hour_window_hours", "prompt_improve_charge_minutes", "project_cap", "project_quota_days", "production_project_days", "signup_mode", "signup_allowed_emails", "stripe_publishable_key", "stripe_price_id_1_hour", "stripe_price_id_10_hours", "stripe_price_id_100_hours", "stripe_project_quota_price_id", "stripe_production_project_price_id", "github_client_id", "github_username", "google_client_id", "smtp_host", "smtp_port", "smtp_username", "smtp_from_email", "smtp_from_name", "smtp_tls_mode"} { + for _, key := range []string{"fibe_base_url", "fibe_agent_server_pool", "fibe_template_version_id", "free_minutes", "free_hour_window_hours", "playground_idle_stop_hours", "prompt_improve_charge_minutes", "project_cap", "project_quota_days", "signup_mode", "signup_allowed_emails", "stripe_publishable_key", "stripe_price_id_1_hour", "stripe_price_id_10_hours", "stripe_price_id_100_hours", "stripe_project_quota_price_id", "github_client_id", "github_username", "google_client_id", "smtp_host", "smtp_port", "smtp_username", "smtp_from_email", "smtp_from_name", "smtp_tls_mode"} { value := cfg[key] set := strings.TrimSpace(cfg[key]) != "" if key == "free_minutes" && strings.TrimSpace(value) == "" { @@ -1169,14 +1048,14 @@ func publicConfigDefault(key string) string { return strconv.Itoa(defaultFreeBuildMinutes) case "free_hour_window_hours": return strconv.Itoa(defaultFreeHourWindowHours) + case "playground_idle_stop_hours": + return strconv.Itoa(defaultPlaygroundIdleStopHours) case "prompt_improve_charge_minutes": return "0" case "project_cap": return "3" case "project_quota_days": return strconv.Itoa(defaultProjectQuotaDays) - case "production_project_days": - return strconv.Itoa(defaultProductionProjectDays) case "signup_mode": return "forbidden" case "fibe_agent_server_pool": @@ -1235,37 +1114,37 @@ func normalizeAdminConfigValues(values map[string]string) (map[string]string, er return nil, errors.New("free_hour_window_hours must be between 1 and 24") } out[key] = strconv.Itoa(n) - case "prompt_improve_charge_minutes": + case "playground_idle_stop_hours": trimmed := strings.TrimSpace(value) if trimmed == "" { out[key] = "" continue } n, err := strconv.Atoi(trimmed) - if err != nil || n < 0 || n > maxPromptImproveChargeMin { - return nil, errors.New("prompt_improve_charge_minutes must be between 0 and 60") + if err != nil || n <= 0 || n > maxPlaygroundIdleStopHours { + return nil, errors.New("playground_idle_stop_hours must be between 1 and 168") } out[key] = strconv.Itoa(n) - case "project_quota_days": + case "prompt_improve_charge_minutes": trimmed := strings.TrimSpace(value) if trimmed == "" { out[key] = "" continue } n, err := strconv.Atoi(trimmed) - if err != nil || n <= 0 || n > maxProjectQuotaDays { - return nil, errors.New("project_quota_days must be between 1 and 365") + if err != nil || n < 0 || n > maxPromptImproveChargeMin { + return nil, errors.New("prompt_improve_charge_minutes must be between 0 and 60") } out[key] = strconv.Itoa(n) - case "production_project_days": + case "project_quota_days": trimmed := strings.TrimSpace(value) if trimmed == "" { out[key] = "" continue } n, err := strconv.Atoi(trimmed) - if err != nil || n <= 0 || n > maxProductionProjectDays { - return nil, errors.New("production_project_days must be between 1 and 365") + if err != nil || n <= 0 || n > maxProjectQuotaDays { + return nil, errors.New("project_quota_days must be between 1 and 365") } out[key] = strconv.Itoa(n) default: diff --git a/internal/likeable/jobs.go b/internal/likeable/jobs.go index 65f6cfd..0639c22 100644 --- a/internal/likeable/jobs.go +++ b/internal/likeable/jobs.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/fibegg/likeable/internal/domain" projecttext "github.com/fibegg/likeable/internal/project" "github.com/hibiken/asynq" ) @@ -27,11 +26,8 @@ const projectCleanupLeaseTTL = projectCleanupTaskTimeout + 30*time.Second const projectCleanupSweepTimeout = 30 * time.Second const projectDeletionSweepInterval = 5 * time.Minute const projectDeletionSweepUniqueTTL = 4 * time.Minute -const projectDomainVerificationSweepUniqueTTL = 50 * time.Minute -const productionProjectStartSweepUniqueTTL = 30 * time.Minute const maxConcurrentProjectCleanup = 1 const defaultJobWorkerConcurrency = 32 -const idleProjectStopAfter = domain.PlaygroundIdleStopAfter const ( taskProvisionProject = "likeable:project:provision" @@ -86,11 +82,11 @@ func newJobSystem(redisOpt asynq.RedisClientOpt, s *Server) *JobSystem { mux.HandleFunc(taskArchiveDeleteProject, s.handleArchiveDeleteProjectTask) mux.HandleFunc(taskStopIdleProjectsSweep, s.handleStopIdleProjectsSweepTask) mux.HandleFunc(taskStopIdleProject, s.handleStopIdleProjectTask) - mux.HandleFunc(taskStartProductionProjectsSweep, s.handleStartProductionProjectsSweepTask) mux.HandleFunc(taskMonitorProjectNotifications, s.handleMonitorProjectNotificationsTask) mux.HandleFunc(taskSendEmail, s.handleSendEmailTask) mux.HandleFunc(taskProjectQuotaSweep, s.handleProjectQuotaSweepTask) mux.HandleFunc(taskProjectDomainVerifySweep, s.handleProjectDomainVerifySweepTask) + mux.HandleFunc(taskStartProductionProjectsSweep, s.handleStartProductionProjectsSweepTask) server := asynq.NewServer(redisOpt, asynq.Config{ Concurrency: jobWorkerConcurrency(), ShutdownTimeout: 20 * time.Second, @@ -261,34 +257,6 @@ func (s *Server) enqueueStopIdleProjectsSweep(ctx context.Context, delay time.Du } } -func (s *Server) enqueueProjectDomainVerifySweep(ctx context.Context, delay time.Duration) { - if s.jobs == nil { - return - } - opts := []asynq.Option{asynq.Queue("low"), asynq.MaxRetry(2), asynq.Timeout(2 * time.Minute), asynq.Unique(projectDomainVerificationSweepUniqueTTL)} - if delay > 0 { - opts = append(opts, asynq.ProcessIn(delay)) - } - _, err := s.jobs.client.EnqueueContext(ctx, asynq.NewTask(taskProjectDomainVerifySweep, nil), opts...) - if err != nil && !errors.Is(err, asynq.ErrDuplicateTask) { - log.Printf("enqueue custom domain DNS verification sweep: %v", err) - } -} - -func (s *Server) enqueueStartProductionProjectsSweep(ctx context.Context, delay time.Duration) { - if s.jobs == nil { - return - } - opts := []asynq.Option{asynq.Queue("low"), asynq.MaxRetry(2), asynq.Timeout(15 * time.Minute), asynq.Unique(productionProjectStartSweepUniqueTTL)} - if delay > 0 { - opts = append(opts, asynq.ProcessIn(delay)) - } - _, err := s.jobs.client.EnqueueContext(ctx, asynq.NewTask(taskStartProductionProjectsSweep, nil), opts...) - if err != nil && !errors.Is(err, asynq.ErrDuplicateTask) { - log.Printf("enqueue production playground start sweep: %v", err) - } -} - func (s *Server) startRecurringJobs(ctx context.Context) { if s.jobs == nil { return @@ -296,8 +264,6 @@ func (s *Server) startRecurringJobs(ctx context.Context) { s.enqueueProjectQuotaSweep(ctx, 0) s.enqueueProjectDeletionSweep(ctx, 0) s.enqueueStopIdleProjectsSweep(ctx, 0) - s.enqueueProjectDomainVerifySweep(ctx, 0) - s.enqueueStartProductionProjectsSweep(ctx, 0) go func() { hourlyTicker := time.NewTicker(time.Hour) deletionTicker := time.NewTicker(projectDeletionSweepInterval) @@ -313,8 +279,6 @@ func (s *Server) startRecurringJobs(ctx context.Context) { s.enqueueProjectQuotaSweep(context.Background(), 0) s.enqueueProjectDeletionSweep(context.Background(), 0) s.enqueueStopIdleProjectsSweep(context.Background(), 0) - s.enqueueProjectDomainVerifySweep(context.Background(), 0) - s.enqueueStartProductionProjectsSweep(context.Background(), 0) } } }() @@ -652,39 +616,16 @@ func (s *Server) handleProjectQuotaSweepTask(ctx context.Context, _ *asynq.Task) } func (s *Server) handleProjectDomainVerifySweepTask(ctx context.Context, _ *asynq.Task) error { - projectDomains, err := s.store.PendingProjectDomains(ctx, 100) - if err != nil { - return err - } - for _, projectDomain := range projectDomains { - project, err := s.store.ProjectForUser(ctx, projectDomain.UserID, projectDomain.ProjectID) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - log.Printf("load custom domain project=%s domain=%s: %v", projectDomain.ProjectID, projectDomain.Domain, err) - } - continue - } - if _, err := s.verifyProjectCustomDomain(ctx, project); err != nil { - log.Printf("verify custom domain project=%s domain=%s: %v", projectDomain.ProjectID, projectDomain.Domain, err) - } - } return nil } func (s *Server) handleStartProductionProjectsSweepTask(ctx context.Context, _ *asynq.Task) error { - projects, err := s.store.StoppedProductionProjects(ctx, 100) - if err != nil { - return err - } - for i := range projects { - project := &projects[i] - s.startProductionProjectIfStopped(ctx, project.UserID, project.ID) - } return nil } func (s *Server) handleStopIdleProjectsSweepTask(ctx context.Context, _ *asynq.Task) error { - cutoff := time.Now().UTC().Add(-idleProjectStopAfter) + idleStopAfter := s.playgroundIdleStopAfter(ctx) + cutoff := time.Now().UTC().Add(-idleStopAfter) projects, err := s.store.IdleProjectsForPlaygroundStop(ctx, cutoff, 100) if err != nil { return err @@ -698,7 +639,7 @@ func (s *Server) handleStopIdleProjectsSweepTask(ctx context.Context, _ *asynq.T } return err } - if err := s.enqueueProjectJob(ctx, taskStopIdleProject, projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID, Reason: "idle for 8 hours"}, asynq.Queue("low"), asynq.MaxRetry(6), asynq.Timeout(2*time.Minute), asynq.Unique(30*time.Minute)); err != nil { + if err := s.enqueueProjectJob(ctx, taskStopIdleProject, projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID, Reason: "idle for " + idleStopAfter.String()}, asynq.Queue("low"), asynq.MaxRetry(6), asynq.Timeout(2*time.Minute), asynq.Unique(30*time.Minute)); err != nil { return err } } @@ -717,7 +658,8 @@ func (s *Server) handleStopIdleProjectTask(ctx context.Context, task *asynq.Task } return err } - idle, skipReason, err := s.store.ProjectIdleForPlaygroundStop(ctx, project.ID, time.Now().UTC().Add(-idleProjectStopAfter)) + idleStopAfter := s.playgroundIdleStopAfter(ctx) + idle, skipReason, err := s.store.ProjectIdleForPlaygroundStop(ctx, project.ID, time.Now().UTC().Add(-idleStopAfter)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil @@ -738,7 +680,7 @@ func (s *Server) handleStopIdleProjectTask(ctx context.Context, task *asynq.Task } return err } - log.Printf("stop idle playground for project %s: no explicit playground activity for %s", project.ID, idleProjectStopAfter) + log.Printf("stop idle playground for project %s: no explicit playground activity for %s", project.ID, idleStopAfter) _, err = s.controlProjectPlayground(ctx, user, project, "stop") return err } diff --git a/internal/likeable/project_binding.go b/internal/likeable/project_binding.go index 6f91096..9cb3ef3 100644 --- a/internal/likeable/project_binding.go +++ b/internal/likeable/project_binding.go @@ -101,9 +101,6 @@ func (s *Server) projectBindingStatus(ctx context.Context, project *Project) (st } func developmentBlockedMessage(err error) string { - if errors.Is(err, errProductionProjectCannotStop) { - return errProductionProjectCannotStop.Error() - } if errors.Is(err, errProjectRetiring) { return errProjectRetiring.Error() } diff --git a/internal/likeable/project_handlers.go b/internal/likeable/project_handlers.go index 0c98521..43b1273 100644 --- a/internal/likeable/project_handlers.go +++ b/internal/likeable/project_handlers.go @@ -18,13 +18,10 @@ import ( ) var ( - errInvalidPlaygroundAction = errors.New("invalid playground action") - errProjectPlaygroundMissing = errors.New("project has no playground") - errProductionProjectCannotStop = errors.New("production project cannot be stopped") + errInvalidPlaygroundAction = errors.New("invalid playground action") + errProjectPlaygroundMissing = errors.New("project has no playground") ) -const productionRuntimeBillingRequiredMessage = "production runtime is not funded yet; support has been notified and Likeable will retry starting it automatically" - func (s *Server) handleProjects(w http.ResponseWriter, r *http.Request) { user := userFromContext(r.Context()) switch r.Method { @@ -35,6 +32,7 @@ func (s *Server) handleProjects(w http.ResponseWriter, r *http.Request) { return } projects = s.refreshProjectListResources(r.Context(), user, projects) + s.decorateProjectsComputedFields(r.Context(), projects) s.recoverProjectsAsync(user.ID, user.Email, projects) projectQuota := s.projectQuota(r.Context(), user) writeJSON(w, http.StatusOK, map[string]any{"projects": projects, "projectCap": projectQuota["limit"], "projectQuota": projectQuota}) @@ -71,6 +69,20 @@ func (s *Server) refreshProjectListResources(ctx context.Context, user *User, pr return out } +func (s *Server) decorateProjectComputedFields(ctx context.Context, project *Project) { + if project == nil { + return + } + project.RefreshComputedFieldsWithIdleStopAfter(s.playgroundIdleStopAfter(ctx)) +} + +func (s *Server) decorateProjectsComputedFields(ctx context.Context, projects []Project) { + idleStopAfter := s.playgroundIdleStopAfter(ctx) + for i := range projects { + projects[i].RefreshComputedFieldsWithIdleStopAfter(idleStopAfter) + } +} + func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request, user *User) { var body struct { Prompt string `json:"prompt"` @@ -132,6 +144,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request, use } s.notifyProjectQuotaIfNeeded(r.Context(), user) created, _ := s.store.ProjectForUser(r.Context(), user.ID, project.ID) + s.decorateProjectComputedFields(r.Context(), created) writeJSON(w, http.StatusCreated, map[string]any{"project": created}) } @@ -152,6 +165,7 @@ func (s *Server) handleProjectRoute(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: s.recoverProjectAsync(user.ID, user.Email, project) + s.decorateProjectComputedFields(r.Context(), project) writeJSON(w, http.StatusOK, map[string]any{"project": project}) case http.MethodPatch: s.handleProjectUpdate(w, r, user, project) @@ -244,12 +258,14 @@ func (s *Server) handleProjectUpdate(w http.ResponseWriter, r *http.Request, use writeError(w, http.StatusInternalServerError, err.Error()) return } + s.decorateProjectComputedFields(r.Context(), updated) writeJSON(w, http.StatusOK, map[string]any{"project": updated}) } func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request, user *User, project *Project) { if project.Status == "deleting" { s.deleteProjectResourcesAsync(user.ID, user.Email, project) + s.decorateProjectComputedFields(r.Context(), project) writeJSON(w, http.StatusAccepted, map[string]any{"project": project}) return } @@ -260,6 +276,7 @@ func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request, use project.Status = "deleting" s.notifyProjectDeletionScheduled(r.Context(), user, project) s.deleteProjectResourcesAsync(user.ID, user.Email, project) + s.decorateProjectComputedFields(r.Context(), project) writeJSON(w, http.StatusAccepted, map[string]any{"project": project}) } @@ -307,7 +324,7 @@ func (s *Server) handleProjectPlaygroundAction(w http.ResponseWriter, r *http.Re action := strings.ToLower(strings.TrimSpace(body.Action)) updated, err := s.controlProjectPlayground(r.Context(), user, project, action) if err != nil { - if errors.Is(err, errProjectExportOnly) || errors.Is(err, errProjectRetiring) || errors.Is(err, errProductionProjectCannotStop) { + if errors.Is(err, errProjectExportOnly) || errors.Is(err, errProjectRetiring) { writeError(w, http.StatusConflict, developmentBlockedMessage(err)) return } @@ -315,12 +332,6 @@ func (s *Server) handleProjectPlaygroundAction(w http.ResponseWriter, r *http.Re writeError(w, http.StatusBadRequest, err.Error()) return } - if action == "start" && strings.TrimSpace(project.ProductionExpiresAt) != "" && fibegateway.IsRuntimeBillingRequiredError(err) { - log.Printf("project playground start blocked by Fibe runtime billing project=%s playground=%s: %v", project.ID, strings.TrimSpace(project.PlaygroundID), err) - s.notifyProductionProjectStartBlocked(r.Context(), user, project) - writeErrorCode(w, http.StatusConflict, "runtime_billing_required", productionRuntimeBillingRequiredMessage) - return - } log.Printf("project playground action %s for project %s: %v", body.Action, project.ID, err) if isPlatformRateLimited(err) { writeError(w, http.StatusServiceUnavailable, "workspace platform is rate limited; try again shortly") @@ -330,6 +341,7 @@ func (s *Server) handleProjectPlaygroundAction(w http.ResponseWriter, r *http.Re return } s.clearPlatformBackoff() + s.decorateProjectComputedFields(r.Context(), updated) writeJSON(w, http.StatusAccepted, map[string]any{"project": updated}) } @@ -343,9 +355,6 @@ func (s *Server) controlProjectPlayground(ctx context.Context, user *User, proje if project.Status == "deleting" { return nil, errInvalidPlaygroundAction } - if action == "stop" && strings.TrimSpace(project.ProductionExpiresAt) != "" { - return nil, errProductionProjectCannotStop - } playgroundID := strings.TrimSpace(project.PlaygroundID) if playgroundID == "" { return nil, errProjectPlaygroundMissing @@ -400,80 +409,11 @@ func (s *Server) controlProjectPlayground(ctx context.Context, user *User, proje } func (s *Server) handleProjectDomain(w http.ResponseWriter, r *http.Request, user *User, project *Project) { - switch r.Method { - case http.MethodPut: - if strings.TrimSpace(project.ProductionExpiresAt) == "" { - writeError(w, http.StatusConflict, "production project is required before adding a custom domain") - return - } - var body struct { - Domain string `json:"domain"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, http.StatusBadRequest, "invalid json") - return - } - domain, err := normalizeProjectCustomDomain(body.Domain) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - target := projectCustomDomainTarget(project) - if target == "" { - writeError(w, http.StatusConflict, "project CNAME target is not available") - return - } - if existing := strings.TrimSpace(project.CustomDomain); existing != "" && existing != domain && projectCustomDomainRoutingSynced(project) { - if err := s.syncProjectCustomDomainRouting(r.Context(), project, ""); err != nil { - log.Printf("clear old custom domain routing for project %s: %v", project.ID, err) - writeError(w, http.StatusBadGateway, "could not update custom domain routing") - return - } - } - if err := s.store.UpsertProjectDomain(r.Context(), user.ID, project.ID, domain, target); err != nil { - log.Printf("project domain upsert for project %s: %v", project.ID, err) - writeError(w, http.StatusConflict, "custom domain is already linked to another project") - return - } - case http.MethodDelete: - if projectCustomDomainRoutingSynced(project) { - if err := s.syncProjectCustomDomainRouting(r.Context(), project, ""); err != nil { - log.Printf("clear custom domain routing for project %s: %v", project.ID, err) - writeError(w, http.StatusBadGateway, "could not update custom domain routing") - return - } - } - if err := s.store.DeleteProjectDomain(r.Context(), user.ID, project.ID); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - default: - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - updated, err := s.store.ProjectForUser(r.Context(), user.ID, project.ID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]any{"project": updated}) + writeError(w, http.StatusGone, "custom domains are managed in Fibe") } func (s *Server) handleProjectDomainVerify(w http.ResponseWriter, r *http.Request, user *User, project *Project) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - if strings.TrimSpace(project.CustomDomain) == "" { - writeError(w, http.StatusConflict, "custom domain is not configured") - return - } - updated, err := s.verifyProjectCustomDomain(r.Context(), project) - if err != nil { - writeError(w, http.StatusBadGateway, "could not verify custom domain DNS") - return - } - writeJSON(w, http.StatusOK, map[string]any{"project": updated}) + writeError(w, http.StatusGone, "custom domains are managed in Fibe") } func normalizeProjectCustomDomain(value string) (string, error) { @@ -610,6 +550,7 @@ func (s *Server) handleProjectFeed(w http.ResponseWriter, r *http.Request, user log.Printf("load project notification timings for project %s: %v", project.ID, timingErr) timings = map[string]ProjectNotificationTiming{} } + s.decorateProjectComputedFields(r.Context(), project) writeJSON(w, http.StatusOK, map[string]any{"project": project, "localMessages": local, "messages": []any{}, "activity": []any{}, "live": nil, "notificationTimings": timings, "warning": "This project is archived. Export it or create a new project."}) return } @@ -623,6 +564,7 @@ func (s *Server) handleProjectFeed(w http.ResponseWriter, r *http.Request, user } cancel() } + s.decorateProjectComputedFields(r.Context(), project) snapshot, err := s.loadProjectFeedSnapshot(r.Context(), user, project, true) if err != nil { log.Printf("load project feed for project %s: %v", project.ID, err) diff --git a/internal/likeable/project_quota.go b/internal/likeable/project_quota.go index fe6a146..ebda841 100644 --- a/internal/likeable/project_quota.go +++ b/internal/likeable/project_quota.go @@ -8,18 +8,18 @@ import ( ) const ( - defaultFreeBuildMinutes = 30 - maxFreeBuildMinutes = 24 * 60 - defaultFreeHourWindowHours = 5 - maxFreeHourWindowHours = 24 - defaultProjectQuotaDays = 30 - maxProjectQuotaDays = 365 - defaultProductionProjectDays = 30 - maxProductionProjectDays = 365 - maxPromptImproveChargeMin = 60 - freeHourWindow = time.Duration(defaultFreeHourWindowHours) * time.Hour - msPerHour = int64(time.Hour / time.Millisecond) - msPerMinute = int64(time.Minute / time.Millisecond) + defaultFreeBuildMinutes = 30 + maxFreeBuildMinutes = 24 * 60 + defaultFreeHourWindowHours = 5 + maxFreeHourWindowHours = 24 + defaultPlaygroundIdleStopHours = 8 + maxPlaygroundIdleStopHours = 168 + defaultProjectQuotaDays = 30 + maxProjectQuotaDays = 365 + maxPromptImproveChargeMin = 60 + freeHourWindow = time.Duration(defaultFreeHourWindowHours) * time.Hour + msPerHour = int64(time.Hour / time.Millisecond) + msPerMinute = int64(time.Minute / time.Millisecond) ) func (s *Server) canSendMessage(ctx context.Context, user *User) bool { @@ -158,6 +158,26 @@ func (s *Server) freeHourWindowHours(ctx context.Context) int { return n } +func (s *Server) playgroundIdleStopHours(ctx context.Context) int { + cfg, _ := s.store.ConfigMap(ctx) + raw := strings.TrimSpace(cfg["playground_idle_stop_hours"]) + if raw == "" { + return defaultPlaygroundIdleStopHours + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return defaultPlaygroundIdleStopHours + } + if n > maxPlaygroundIdleStopHours { + return maxPlaygroundIdleStopHours + } + return n +} + +func (s *Server) playgroundIdleStopAfter(ctx context.Context) time.Duration { + return time.Duration(s.playgroundIdleStopHours(ctx)) * time.Hour +} + func (s *Server) promptImproveChargeMinutes(ctx context.Context) int { cfg, _ := s.store.ConfigMap(ctx) raw := strings.TrimSpace(cfg["prompt_improve_charge_minutes"]) @@ -190,22 +210,6 @@ func (s *Server) projectQuotaDays(ctx context.Context) int { return n } -func (s *Server) productionProjectDays(ctx context.Context) int { - cfg, _ := s.store.ConfigMap(ctx) - raw := strings.TrimSpace(cfg["production_project_days"]) - if raw == "" { - return defaultProductionProjectDays - } - n, err := strconv.Atoi(raw) - if err != nil || n <= 0 { - return defaultProductionProjectDays - } - if n > maxProductionProjectDays { - return maxProductionProjectDays - } - return n -} - func (s *Server) projectCap(ctx context.Context) int { return s.baseProjectCap(ctx) } diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index 8683cd5..97b10e5 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -13,7 +13,6 @@ import ( "fmt" "io" "mime/multipart" - "net" "net/http" "net/http/httptest" "net/url" @@ -46,21 +45,6 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } -type fakeCNAMEResolver map[string]fakeCNAMEResponse - -type fakeCNAMEResponse struct { - cname string - err error -} - -func (f fakeCNAMEResolver) LookupCNAME(_ context.Context, host string) (string, error) { - response, ok := f[host] - if !ok { - return "", &net.DNSError{Err: "no such host", Name: host, IsNotFound: true} - } - return response.cname, response.err -} - func testStripeSignature(secret string, body []byte) string { timestamp := time.Now().Unix() mac := hmac.New(sha256.New, []byte(secret)) @@ -599,11 +583,9 @@ func TestMeIncludesOnlyConfiguredBillingProducts(t *testing.T) { t.Fatal(err) } readProducts := func() struct { - HourPacks []int `json:"hourPacks"` - ProjectQuota bool `json:"projectQuota"` - ProjectQuotaDays int `json:"projectQuotaDays"` - ProductionProject bool `json:"productionProject"` - ProductionProjectDays int `json:"productionProjectDays"` + HourPacks []int `json:"hourPacks"` + ProjectQuota bool `json:"projectQuota"` + ProjectQuotaDays int `json:"projectQuotaDays"` } { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/me", nil) @@ -615,11 +597,9 @@ func TestMeIncludesOnlyConfiguredBillingProducts(t *testing.T) { } var body struct { BillingProducts struct { - HourPacks []int `json:"hourPacks"` - ProjectQuota bool `json:"projectQuota"` - ProjectQuotaDays int `json:"projectQuotaDays"` - ProductionProject bool `json:"productionProject"` - ProductionProjectDays int `json:"productionProjectDays"` + HourPacks []int `json:"hourPacks"` + ProjectQuota bool `json:"projectQuota"` + ProjectQuotaDays int `json:"projectQuotaDays"` } `json:"billingProducts"` } if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { @@ -629,22 +609,20 @@ func TestMeIncludesOnlyConfiguredBillingProducts(t *testing.T) { } products := readProducts() - if len(products.HourPacks) != 0 || products.ProjectQuota || products.ProductionProject { + if len(products.HourPacks) != 0 || products.ProjectQuota { t.Fatalf("billing products=%+v before Stripe config, want none", products) } if err := store.UpsertConfig(t.Context(), map[string]string{ - "stripe_secret_key": "sk_test", - "stripe_price_id_1_hour": "price_1h", - "stripe_price_id_100_hours": "price_100h", - "stripe_project_quota_price_id": "price_project_slot", - "stripe_production_project_price_id": "price_production_project", - "project_quota_days": "21", - "production_project_days": "45", + "stripe_secret_key": "sk_test", + "stripe_price_id_1_hour": "price_1h", + "stripe_price_id_100_hours": "price_100h", + "stripe_project_quota_price_id": "price_project_slot", + "project_quota_days": "21", }, secretConfigKeys); err != nil { t.Fatal(err) } products = readProducts() - if !reflect.DeepEqual(products.HourPacks, []int{1, 100}) || !products.ProjectQuota || products.ProjectQuotaDays != 21 || !products.ProductionProject || products.ProductionProjectDays != 45 { + if !reflect.DeepEqual(products.HourPacks, []int{1, 100}) || !products.ProjectQuota || products.ProjectQuotaDays != 21 { t.Fatalf("billing products=%+v, want configured packs and project quota", products) } } @@ -3914,7 +3892,7 @@ func TestProjectPlaygroundStartPromotesReachableStoppedPreview(t *testing.T) { } } -func TestProductionProjectPlaygroundCannotBeStopped(t *testing.T) { +func TestLegacyProductionProjectPlaygroundCanBeStopped(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { t.Fatal(err) @@ -3949,27 +3927,24 @@ func TestProductionProjectPlaygroundCannotBeStopped(t *testing.T) { rec := httptest.NewRecorder() server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusConflict { - t.Fatalf("stop returned %d, want 409; body=%s", rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "production project cannot be stopped") { - t.Fatalf("body=%s, want production stop error", rec.Body.String()) + if rec.Code != http.StatusAccepted { + t.Fatalf("stop returned %d, want 202; body=%s", rec.Code, rec.Body.String()) } stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID) if err != nil { t.Fatal(err) } - if stored.Status != "ready" || stored.ProductionExpiresAt == "" { - t.Fatalf("project=%+v, want ready production project", stored) + if stored.Status != "stopped" || stored.ProductionExpiresAt == "" { + t.Fatalf("project=%+v, want stopped legacy production project", stored) } - if log, err := os.ReadFile(logPath); err == nil && strings.Contains(string(log), "playgrounds stop playground-production-stop") { - t.Fatalf("unexpected stop command for production project; log=%s", string(log)) + if log, err := os.ReadFile(logPath); err == nil && !strings.Contains(string(log), "playgrounds stop playground-production-stop") { + t.Fatalf("missing stop command for legacy production project; log=%s", string(log)) } else if err != nil && !errors.Is(err, os.ErrNotExist) { t.Fatal(err) } } -func TestProductionProjectPlaygroundStartRuntimeBillingReturnsConflict(t *testing.T) { +func TestLegacyProductionProjectPlaygroundStartRuntimeBillingIsGenericFailure(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { t.Fatal(err) @@ -4016,19 +3991,11 @@ func TestProductionProjectPlaygroundStartRuntimeBillingReturnsConflict(t *testin rec := httptest.NewRecorder() server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusConflict { - t.Fatalf("start returned %d, want 409; body=%s", rec.Code, rec.Body.String()) - } - var errorBody struct { - Error string `json:"error"` - Code string `json:"code"` - Message string `json:"message"` - } - if err := json.NewDecoder(rec.Body).Decode(&errorBody); err != nil { - t.Fatal(err) + if rec.Code != http.StatusBadGateway { + t.Fatalf("start returned %d, want 502; body=%s", rec.Code, rec.Body.String()) } - if errorBody.Code != "runtime_billing_required" || errorBody.Message != errorBody.Error || !strings.Contains(errorBody.Error, "production runtime is not funded yet") { - t.Fatalf("errorBody=%+v, want coded runtime billing message", errorBody) + if !strings.Contains(rec.Body.String(), "could not update the playground") { + t.Fatalf("body=%s, want generic playground update failure", rec.Body.String()) } stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID) if err != nil { @@ -4041,15 +4008,15 @@ func TestProductionProjectPlaygroundStartRuntimeBillingReturnsConflict(t *testin if err != nil { t.Fatal(err) } - if len(notices) != 1 || notices[0].Severity != "warning" || !strings.Contains(notices[0].Body, "Production runtime paused") || !strings.Contains(notices[0].Body, "not funded") { - t.Fatalf("notices=%+v, want one runtime billing warning", notices) + if len(notices) != 0 { + t.Fatalf("notices=%+v, want no production runtime warning", notices) } if log := readFile(t, logPath); strings.Count(log, "playgrounds start playground-production-start-billing") != 1 { t.Fatalf("log=%s, want one start attempt", log) } } -func TestProductionProjectStartSweepStartsStoppedProductionProjects(t *testing.T) { +func TestProductionProjectStartSweepIsDisabled(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { t.Fatal(err) @@ -4087,8 +4054,8 @@ func TestProductionProjectStartSweepStartsStoppedProductionProjects(t *testing.T if err != nil { t.Fatal(err) } - if storedProduction.Status != "launching" || storedProduction.ProductionExpiresAt == "" || storedProduction.PlaygroundIdleStopAt != "" { - t.Fatalf("production project=%+v, want launching production project without idle stop", storedProduction) + if storedProduction.Status != "stopped" || storedProduction.ProductionExpiresAt == "" { + t.Fatalf("production project=%+v, want unchanged stopped legacy production project", storedProduction) } storedOrdinary, err := store.ProjectForUser(t.Context(), user.ID, ordinary.ID) if err != nil { @@ -4097,16 +4064,14 @@ func TestProductionProjectStartSweepStartsStoppedProductionProjects(t *testing.T if storedOrdinary.Status != "stopped" { t.Fatalf("ordinary project status=%q, want stopped", storedOrdinary.Status) } - log := readFile(t, logPath) - if !strings.Contains(log, "playgrounds start playground-production-sweep") { - t.Fatalf("missing production start command; log=%s", log) - } - if strings.Contains(log, "playgrounds start playground-ordinary-sweep") { - t.Fatalf("unexpected ordinary start command; log=%s", log) + if log, err := os.ReadFile(logPath); err == nil && strings.Contains(string(log), "playgrounds start") { + t.Fatalf("unexpected start command from disabled production sweep; log=%s", string(log)) + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) } } -func TestProductionProjectStartSweepPromotesReachableStoppedPreview(t *testing.T) { +func TestProductionProjectStartSweepDoesNotPromoteReachableStoppedPreview(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { t.Fatal(err) @@ -4159,8 +4124,8 @@ func TestProductionProjectStartSweepPromotesReachableStoppedPreview(t *testing.T if err != nil { t.Fatal(err) } - if stored.Status != "ready" || stored.ProductionExpiresAt == "" || stored.PlaygroundLastUsedAt == "" { - t.Fatalf("project=%+v, want ready production project with touched usage", stored) + if stored.Status != "stopped" || stored.ProductionExpiresAt == "" { + t.Fatalf("project=%+v, want unchanged stopped legacy production project", stored) } if log, err := os.ReadFile(logPath); err == nil && strings.Contains(string(log), "playgrounds start playground-production-sweep-preview") { t.Fatalf("unexpected Fibe start for reachable production preview; log=%s", log) @@ -4169,7 +4134,7 @@ func TestProductionProjectStartSweepPromotesReachableStoppedPreview(t *testing.T } } -func TestProjectCustomDomainRequiresProductionProject(t *testing.T) { +func TestProjectCustomDomainEndpointsAreDisabled(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { t.Fatal(err) @@ -4188,90 +4153,28 @@ func TestProjectCustomDomainRequiresProductionProject(t *testing.T) { t.Fatal(err) } - req := httptest.NewRequest(http.MethodPut, "/api/projects/project-domain-locked/domain", strings.NewReader(`{"domain":"app.example.com"}`)) - req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-token"}) - rec := httptest.NewRecorder() - server.routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusConflict { - t.Fatalf("domain returned %d, want 409; body=%s", rec.Code, rec.Body.String()) - } -} - -func TestProjectCustomDomainCanBeSavedAndDeleted(t *testing.T) { - store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient} - user, err := store.UpsertUser(t.Context(), "domain-buyer@example.com", "Domain Buyer", "") - if err != nil { - t.Fatal(err) - } - if err := store.CreateSession(t.Context(), user.ID, "domain-buyer-token", time.Hour); err != nil { - t.Fatal(err) - } - project := &Project{ID: "project-domain", UserID: user.ID, Title: "Domain", ConversationID: "conv-domain", Status: "ready", PreviewURL: "https://fallback.example.test", SelectedService: "app"} - if err := store.CreateProject(t.Context(), project); err != nil { - t.Fatal(err) - } - if err := store.ReplaceProjectResources(t.Context(), project.ID, nil, []ProjectService{ - {ProjectID: project.ID, Name: "app", URL: "https://app-target.example.test:8443/live", Type: "dynamic", Visibility: "external"}, - }); err != nil { - t.Fatal(err) - } - if granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_domain_project", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted { - t.Fatalf("production grant=%v err=%v, want granted", granted, err) - } - - req := httptest.NewRequest(http.MethodPut, "/api/projects/project-domain/domain", strings.NewReader(`{"domain":"HTTPS://App.Customer.Example/"}`)) - req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-buyer-token"}) - rec := httptest.NewRecorder() - server.routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("domain save returned %d, want 200; body=%s", rec.Code, rec.Body.String()) - } - var body struct { - Project Project `json:"project"` - } - if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { - t.Fatal(err) - } - if body.Project.CustomDomain != "app.customer.example" || body.Project.CustomDomainStatus != "pending_dns" || body.Project.CustomDomainTarget != "app-target.example.test" { - t.Fatalf("project domain=%+v, want normalized pending domain with target", body.Project) - } - - req = httptest.NewRequest(http.MethodGet, "/api/projects/project-domain", nil) - req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-buyer-token"}) - rec = httptest.NewRecorder() - server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("project get returned %d: %s", rec.Code, rec.Body.String()) - } - if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { - t.Fatal(err) - } - if body.Project.CustomDomain != "app.customer.example" || body.Project.CustomDomainTarget != "app-target.example.test" { - t.Fatalf("project get domain=%+v, want persisted domain", body.Project) - } - - req = httptest.NewRequest(http.MethodDelete, "/api/projects/project-domain/domain", nil) - req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-buyer-token"}) - rec = httptest.NewRecorder() - server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("domain delete returned %d: %s", rec.Code, rec.Body.String()) - } - body = struct { - Project Project `json:"project"` - }{} - if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { - t.Fatal(err) - } - if body.Project.CustomDomain != "" || body.Project.CustomDomainTarget != "" { - t.Fatalf("project domain=%+v, want cleared domain", body.Project) + for _, tc := range []struct { + name string + method string + path string + body string + }{ + {name: "save", method: http.MethodPut, path: "/api/projects/project-domain-locked/domain", body: `{"domain":"app.example.com"}`}, + {name: "delete", method: http.MethodDelete, path: "/api/projects/project-domain-locked/domain"}, + {name: "verify", method: http.MethodPost, path: "/api/projects/project-domain-locked/domain/verify"}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-token"}) + rec := httptest.NewRecorder() + server.routes().ServeHTTP(rec, req) + if rec.Code != http.StatusGone { + t.Fatalf("domain endpoint returned %d, want 410; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "Fibe") { + t.Fatalf("body=%s, want Fibe handoff message", rec.Body.String()) + } + }) } } @@ -4299,386 +4202,6 @@ func TestProjectCustomDomainRejectsInvalidHostnames(t *testing.T) { } } -func TestProjectCustomDomainRejectsDomainAlreadyLinked(t *testing.T) { - store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient} - user, err := store.UpsertUser(t.Context(), "domain-duplicate@example.com", "Domain Duplicate", "") - if err != nil { - t.Fatal(err) - } - if err := store.CreateSession(t.Context(), user.ID, "domain-duplicate-token", time.Hour); err != nil { - t.Fatal(err) - } - first := &Project{ID: "project-domain-first", UserID: user.ID, Title: "First", ConversationID: "conv-domain-first", Status: "ready", PreviewURL: "https://first-target.example.test"} - second := &Project{ID: "project-domain-second", UserID: user.ID, Title: "Second", ConversationID: "conv-domain-second", Status: "ready", PreviewURL: "https://second-target.example.test"} - for _, project := range []*Project{first, second} { - if err := store.CreateProject(t.Context(), project); err != nil { - t.Fatal(err) - } - if granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_"+project.ID, time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted { - t.Fatalf("production grant for %s=%v err=%v, want granted", project.ID, granted, err) - } - } - - req := httptest.NewRequest(http.MethodPut, "/api/projects/project-domain-first/domain", strings.NewReader(`{"domain":"app.customer.example"}`)) - req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-duplicate-token"}) - rec := httptest.NewRecorder() - server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("first domain save returned %d: %s", rec.Code, rec.Body.String()) - } - - req = httptest.NewRequest(http.MethodPut, "/api/projects/project-domain-second/domain", strings.NewReader(`{"domain":"app.customer.example"}`)) - req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-duplicate-token"}) - rec = httptest.NewRecorder() - server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusConflict { - t.Fatalf("duplicate domain save returned %d, want 409; body=%s", rec.Code, rec.Body.String()) - } - stored, err := store.ProjectForUser(t.Context(), user.ID, second.ID) - if err != nil { - t.Fatal(err) - } - if stored.CustomDomain != "" { - t.Fatalf("second project domain=%q, want unchanged after duplicate rejection", stored.CustomDomain) - } -} - -func TestProjectCustomDomainVerifyMarksDNSVerifiedCNAME(t *testing.T) { - store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - server := &Server{ - store: store, - config: RuntimeConfig{BaseURL: "http://example.test"}, - http: http.DefaultClient, - domainDNS: fakeCNAMEResolver{ - "app.customer.example": {cname: "app-target.example.test."}, - }, - } - user, err := store.UpsertUser(t.Context(), "domain-verify@example.com", "Domain Verify", "") - if err != nil { - t.Fatal(err) - } - if err := store.CreateSession(t.Context(), user.ID, "domain-verify-token", time.Hour); err != nil { - t.Fatal(err) - } - project := &Project{ID: "project-domain-verify", UserID: user.ID, Title: "Domain Verify", ConversationID: "conv-domain-verify", Status: "ready", PreviewURL: "https://app-target.example.test"} - if err := store.CreateProject(t.Context(), project); err != nil { - t.Fatal(err) - } - if granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_domain_verify_project", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted { - t.Fatalf("production grant=%v err=%v, want granted", granted, err) - } - - req := httptest.NewRequest(http.MethodPut, "/api/projects/project-domain-verify/domain", strings.NewReader(`{"domain":"app.customer.example"}`)) - req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-verify-token"}) - rec := httptest.NewRecorder() - server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("domain save returned %d: %s", rec.Code, rec.Body.String()) - } - - req = httptest.NewRequest(http.MethodPost, "/api/projects/project-domain-verify/domain/verify", nil) - req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-verify-token"}) - rec = httptest.NewRecorder() - server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("domain verify returned %d: %s", rec.Code, rec.Body.String()) - } - var body struct { - Project Project `json:"project"` - } - if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { - t.Fatal(err) - } - if body.Project.CustomDomain != "app.customer.example" || body.Project.CustomDomainStatus != "dns_verified" { - t.Fatalf("project domain=%+v, want DNS verified custom domain", body.Project) - } -} - -func TestProjectCustomDomainVerifyActivatesFibeRouting(t *testing.T) { - store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - var patches []map[string]any - var actions []string - fibeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodPatch && r.URL.Path == "/api/playgrounds/123": - if got := r.Header.Get("Authorization"); got != "Bearer test-key" { - t.Fatalf("Authorization=%q, want bearer token", got) - } - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatal(err) - } - patches = append(patches, body) - case r.Method == http.MethodPost && r.URL.Path == "/api/playgrounds/123/operations": - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatal(err) - } - actions = append(actions, fmt.Sprint(body["action_type"])) - default: - t.Fatalf("Fibe request=%s %s, want custom-host PATCH or rollout action", r.Method, r.URL.Path) - } - writeJSONResponse(t, w, map[string]any{"id": 123, "status": "running"}) - })) - defer fibeServer.Close() - if err := store.UpsertConfig(t.Context(), map[string]string{ - "fibe_base_url": fibeServer.URL, - "fibe_api_key": "test-key", - }, secretConfigKeys); err != nil { - t.Fatal(err) - } - server := &Server{ - store: store, - config: RuntimeConfig{BaseURL: "http://example.test"}, - http: fibeServer.Client(), - domainDNS: fakeCNAMEResolver{ - "app.customer.example": {cname: "app-target.example.test."}, - }, - } - user, err := store.UpsertUser(t.Context(), "domain-routing@example.com", "Domain Routing", "") - if err != nil { - t.Fatal(err) - } - if err := store.CreateSession(t.Context(), user.ID, "domain-routing-token", time.Hour); err != nil { - t.Fatal(err) - } - project := &Project{ID: "project-domain-routing", UserID: user.ID, Title: "Domain Routing", ConversationID: "conv-domain-routing", AgentID: "agent-1", MarqueeID: "server-1", PlaygroundID: "123", Status: "ready", PreviewURL: "https://app-target.example.test", SelectedService: "app"} - if err := store.CreateProject(t.Context(), project); err != nil { - t.Fatal(err) - } - if err := store.ReplaceProjectResources(t.Context(), project.ID, nil, []ProjectService{ - {ProjectID: project.ID, Name: "app", URL: "https://app-target.example.test", Type: "dynamic", Visibility: "external"}, - {ProjectID: project.ID, Name: "api", URL: "https://api-target.example.test", Type: "dynamic", Visibility: "external"}, - }); err != nil { - t.Fatal(err) - } - if granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_domain_routing_project", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted { - t.Fatalf("production grant=%v err=%v, want granted", granted, err) - } - - req := httptest.NewRequest(http.MethodPut, "/api/projects/project-domain-routing/domain", strings.NewReader(`{"domain":"app.customer.example"}`)) - req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-routing-token"}) - rec := httptest.NewRecorder() - server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("domain save returned %d: %s", rec.Code, rec.Body.String()) - } - - req = httptest.NewRequest(http.MethodPost, "/api/projects/project-domain-routing/domain/verify", nil) - req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-routing-token"}) - rec = httptest.NewRecorder() - server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("domain verify returned %d: %s", rec.Code, rec.Body.String()) - } - var body struct { - Project Project `json:"project"` - } - if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { - t.Fatal(err) - } - if body.Project.CustomDomainStatus != "active" { - t.Fatalf("custom domain status=%q, want active", body.Project.CustomDomainStatus) - } - if len(patches) != 1 { - t.Fatalf("patches=%d, want one Fibe routing patch", len(patches)) - } - if len(actions) != 1 || actions[0] != "rollout" { - t.Fatalf("actions=%v, want rollout after routing patch", actions) - } - playground := patches[0]["playground"].(map[string]any) - services := playground["services"].(map[string]any) - app := services["app"].(map[string]any) - api := services["api"].(map[string]any) - if hosts := app["custom_hosts"].([]any); len(hosts) != 1 || hosts[0] != "app.customer.example" { - t.Fatalf("app custom_hosts=%#v, want custom domain", app["custom_hosts"]) - } - if hosts := api["custom_hosts"].([]any); len(hosts) != 0 { - t.Fatalf("api custom_hosts=%#v, want cleared hosts", api["custom_hosts"]) - } -} - -func TestProjectCustomDomainDeleteClearsFibeRouting(t *testing.T) { - store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - var patch map[string]any - var actions []string - fibeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodPatch && r.URL.Path == "/api/playgrounds/123": - if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { - t.Fatal(err) - } - case r.Method == http.MethodPost && r.URL.Path == "/api/playgrounds/123/operations": - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatal(err) - } - actions = append(actions, fmt.Sprint(body["action_type"])) - default: - t.Fatalf("Fibe request=%s %s, want custom-host PATCH or rollout action", r.Method, r.URL.Path) - } - writeJSONResponse(t, w, map[string]any{"id": 123, "status": "running"}) - })) - defer fibeServer.Close() - if err := store.UpsertConfig(t.Context(), map[string]string{ - "fibe_base_url": fibeServer.URL, - "fibe_api_key": "test-key", - }, secretConfigKeys); err != nil { - t.Fatal(err) - } - server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: fibeServer.Client()} - user, err := store.UpsertUser(t.Context(), "domain-clear@example.com", "Domain Clear", "") - if err != nil { - t.Fatal(err) - } - if err := store.CreateSession(t.Context(), user.ID, "domain-clear-token", time.Hour); err != nil { - t.Fatal(err) - } - project := &Project{ID: "project-domain-clear", UserID: user.ID, Title: "Domain Clear", ConversationID: "conv-domain-clear", AgentID: "agent-1", MarqueeID: "server-1", PlaygroundID: "123", Status: "ready", SelectedService: "app"} - if err := store.CreateProject(t.Context(), project); err != nil { - t.Fatal(err) - } - if err := store.ReplaceProjectResources(t.Context(), project.ID, nil, []ProjectService{ - {ProjectID: project.ID, Name: "app", URL: "https://app-target.example.test", Type: "dynamic", Visibility: "external"}, - }); err != nil { - t.Fatal(err) - } - if err := store.UpsertProjectDomain(t.Context(), user.ID, project.ID, "app.customer.example", "app-target.example.test"); err != nil { - t.Fatal(err) - } - if err := store.UpdateProjectDomainStatus(t.Context(), user.ID, project.ID, "active"); err != nil { - t.Fatal(err) - } - - req := httptest.NewRequest(http.MethodDelete, "/api/projects/project-domain-clear/domain", nil) - req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-clear-token"}) - rec := httptest.NewRecorder() - server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("domain delete returned %d: %s", rec.Code, rec.Body.String()) - } - if patch == nil { - t.Fatal("missing Fibe routing clear patch") - } - if len(actions) != 1 || actions[0] != "rollout" { - t.Fatalf("actions=%v, want rollout after routing clear", actions) - } - playground := patch["playground"].(map[string]any) - services := playground["services"].(map[string]any) - app := services["app"].(map[string]any) - if hosts := app["custom_hosts"].([]any); len(hosts) != 0 { - t.Fatalf("app custom_hosts=%#v, want cleared hosts", app["custom_hosts"]) - } -} - -func TestProjectCustomDomainDeletePendingDNSSkipsFibeRoutingClear(t *testing.T) { - store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - fibeCalls := 0 - fibeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fibeCalls++ - t.Fatalf("unexpected Fibe request for pending DNS domain delete: %s %s", r.Method, r.URL.Path) - })) - defer fibeServer.Close() - if err := store.UpsertConfig(t.Context(), map[string]string{ - "fibe_base_url": fibeServer.URL, - "fibe_api_key": "test-key", - }, secretConfigKeys); err != nil { - t.Fatal(err) - } - server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: fibeServer.Client()} - user, err := store.UpsertUser(t.Context(), "domain-pending-clear@example.com", "Domain Pending Clear", "") - if err != nil { - t.Fatal(err) - } - if err := store.CreateSession(t.Context(), user.ID, "domain-pending-clear-token", time.Hour); err != nil { - t.Fatal(err) - } - project := &Project{ID: "project-domain-pending-clear", UserID: user.ID, Title: "Domain Pending Clear", ConversationID: "conv-domain-pending-clear", AgentID: "agent-1", MarqueeID: "server-1", PlaygroundID: "123", Status: "ready", PreviewURL: "https://app-target.example.test"} - if err := store.CreateProject(t.Context(), project); err != nil { - t.Fatal(err) - } - if err := store.UpsertProjectDomain(t.Context(), user.ID, project.ID, "app.customer.example", "app-target.example.test"); err != nil { - t.Fatal(err) - } - - req := httptest.NewRequest(http.MethodDelete, "/api/projects/project-domain-pending-clear/domain", nil) - req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-pending-clear-token"}) - rec := httptest.NewRecorder() - server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("domain delete returned %d: %s", rec.Code, rec.Body.String()) - } - if fibeCalls != 0 { - t.Fatalf("fibeCalls=%d, want none for pending DNS delete", fibeCalls) - } - stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID) - if err != nil { - t.Fatal(err) - } - if stored.CustomDomain != "" || stored.CustomDomainStatus != "" || stored.CustomDomainTarget != "" { - t.Fatalf("custom domain=%q status=%q target=%q, want deleted", stored.CustomDomain, stored.CustomDomainStatus, stored.CustomDomainTarget) - } -} - -func TestProjectDomainVerifySweepMarksDNSVerifiedCNAME(t *testing.T) { - store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - server := &Server{ - store: store, - config: RuntimeConfig{BaseURL: "http://example.test"}, - http: http.DefaultClient, - domainDNS: fakeCNAMEResolver{ - "app.customer.example": {cname: "app-target.example.test"}, - }, - } - user, err := store.UpsertUser(t.Context(), "domain-sweep@example.com", "Domain Sweep", "") - if err != nil { - t.Fatal(err) - } - project := &Project{ID: "project-domain-sweep", UserID: user.ID, Title: "Domain Sweep", ConversationID: "conv-domain-sweep", Status: "ready", PreviewURL: "https://app-target.example.test"} - if err := store.CreateProject(t.Context(), project); err != nil { - t.Fatal(err) - } - if err := store.UpsertProjectDomain(t.Context(), user.ID, project.ID, "app.customer.example", "app-target.example.test"); err != nil { - t.Fatal(err) - } - - if err := server.handleProjectDomainVerifySweepTask(t.Context(), asynq.NewTask(taskProjectDomainVerifySweep, nil)); err != nil { - t.Fatalf("verify sweep returned error: %v", err) - } - stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID) - if err != nil { - t.Fatal(err) - } - if stored.CustomDomainStatus != "dns_verified" { - t.Fatalf("custom domain status=%q, want DNS verified", stored.CustomDomainStatus) - } -} - func TestProjectPassiveActionsDoNotTouchPlaygroundUsage(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { @@ -4875,11 +4398,11 @@ func TestIdleProjectStopTaskStopsPlayground(t *testing.T) { if err != nil { t.Fatal(err) } - project := &Project{ID: "project-idle", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle", AgentID: "agent-1", PlaygroundID: "playground-idle", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-idleProjectStopAfter - time.Minute).Format(time.RFC3339Nano)} + project := &Project{ID: "project-idle", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle", AgentID: "agent-1", PlaygroundID: "playground-idle", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour - time.Minute).Format(time.RFC3339Nano)} if err := store.CreateProject(t.Context(), project); err != nil { t.Fatal(err) } - if _, err := store.AddMessageAt(t.Context(), project.ID, "user", "old", time.Now().UTC().Add(-idleProjectStopAfter-time.Minute).Format(time.RFC3339Nano)); err != nil { + if _, err := store.AddMessageAt(t.Context(), project.ID, "user", "old", time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour-time.Minute).Format(time.RFC3339Nano)); err != nil { t.Fatal(err) } payload, _ := json.Marshal(projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID}) @@ -4922,7 +4445,7 @@ func TestIdleProjectStopTaskSkipsAfterRecentUsageReset(t *testing.T) { if err != nil { t.Fatal(err) } - project := &Project{ID: "project-idle-reset", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle-reset", AgentID: "agent-1", PlaygroundID: "playground-idle-reset", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-idleProjectStopAfter - time.Minute).Format(time.RFC3339Nano)} + project := &Project{ID: "project-idle-reset", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle-reset", AgentID: "agent-1", PlaygroundID: "playground-idle-reset", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour - time.Minute).Format(time.RFC3339Nano)} if err := store.CreateProject(t.Context(), project); err != nil { t.Fatal(err) } @@ -4947,7 +4470,7 @@ func TestIdleProjectStopTaskSkipsAfterRecentUsageReset(t *testing.T) { } } -func TestIdleProjectStopTaskSkipsProductionProject(t *testing.T) { +func TestIdleProjectStopTaskStopsLegacyProductionProject(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { t.Fatal(err) @@ -4969,7 +4492,7 @@ func TestIdleProjectStopTaskSkipsProductionProject(t *testing.T) { if err != nil { t.Fatal(err) } - project := &Project{ID: "project-idle-production", UserID: user.ID, Title: "Production", ConversationID: "conv-idle-production", AgentID: "agent-1", PlaygroundID: "playground-idle-production", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-idleProjectStopAfter - time.Minute).Format(time.RFC3339Nano)} + project := &Project{ID: "project-idle-production", UserID: user.ID, Title: "Production", ConversationID: "conv-idle-production", AgentID: "agent-1", PlaygroundID: "playground-idle-production", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour - time.Minute).Format(time.RFC3339Nano)} if err := store.CreateProject(t.Context(), project); err != nil { t.Fatal(err) } @@ -4987,11 +4510,11 @@ func TestIdleProjectStopTaskSkipsProductionProject(t *testing.T) { if err != nil { t.Fatal(err) } - if stored.Status != "ready" || stored.PlaygroundIdleStopAt != "" || stored.ProductionExpiresAt == "" { - t.Fatalf("project=%+v, want ready production project without idle stop deadline", stored) + if stored.Status != "stopped" || stored.ProductionExpiresAt == "" { + t.Fatalf("project=%+v, want stopped legacy production project", stored) } - if log := readFile(t, logPath); strings.Contains(log, "playgrounds stop playground-idle-production") { - t.Fatalf("unexpected stop command for production project; log=%s", log) + if log := readFile(t, logPath); !strings.Contains(log, "playgrounds stop playground-idle-production") { + t.Fatalf("missing stop command for legacy production project; log=%s", log) } } @@ -5014,11 +4537,11 @@ func TestIdleProjectStopTaskTreatsAlreadyStoppedAsSuccess(t *testing.T) { if err != nil { t.Fatal(err) } - project := &Project{ID: "project-idle-already-stopped", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle-already-stopped", AgentID: "agent-1", PlaygroundID: "playground-idle", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-idleProjectStopAfter - time.Minute).Format(time.RFC3339Nano)} + project := &Project{ID: "project-idle-already-stopped", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle-already-stopped", AgentID: "agent-1", PlaygroundID: "playground-idle", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour - time.Minute).Format(time.RFC3339Nano)} if err := store.CreateProject(t.Context(), project); err != nil { t.Fatal(err) } - if _, err := store.AddMessageAt(t.Context(), project.ID, "user", "old", time.Now().UTC().Add(-idleProjectStopAfter-time.Minute).Format(time.RFC3339Nano)); err != nil { + if _, err := store.AddMessageAt(t.Context(), project.ID, "user", "old", time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour-time.Minute).Format(time.RFC3339Nano)); err != nil { t.Fatal(err) } payload, _ := json.Marshal(projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID}) @@ -5058,11 +4581,11 @@ func TestIdleProjectStopTaskTreatsMissingPlaygroundAsStopped(t *testing.T) { if err != nil { t.Fatal(err) } - project := &Project{ID: "project-idle-missing", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle-missing", AgentID: "agent-1", PlaygroundID: "playground-missing", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-idleProjectStopAfter - time.Minute).Format(time.RFC3339Nano)} + project := &Project{ID: "project-idle-missing", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle-missing", AgentID: "agent-1", PlaygroundID: "playground-missing", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour - time.Minute).Format(time.RFC3339Nano)} if err := store.CreateProject(t.Context(), project); err != nil { t.Fatal(err) } - if _, err := store.AddMessageAt(t.Context(), project.ID, "user", "old", time.Now().UTC().Add(-idleProjectStopAfter-time.Minute).Format(time.RFC3339Nano)); err != nil { + if _, err := store.AddMessageAt(t.Context(), project.ID, "user", "old", time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour-time.Minute).Format(time.RFC3339Nano)); err != nil { t.Fatal(err) } payload, _ := json.Marshal(projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID}) @@ -5745,10 +5268,10 @@ func TestAdminReadinessReportsLaunchBlockers(t *testing.T) { if body.Readiness.Ready || body.Readiness.BlockerCount == 0 { t.Fatalf("readiness=%+v, want launch blockers", body.Readiness) } - if check := readinessCheck(t, body.Readiness, "stripe_production_project_price"); check.OK || check.Severity != "blocker" { - t.Fatalf("production price check=%+v, want blocking failure", check) - } else if !strings.Contains(check.Detail, "stripe_production_project_price_id") { - t.Fatalf("production price detail=%q, want config key", check.Detail) + for _, check := range body.Readiness.Checks { + if check.Key == "stripe_production_project_price" { + t.Fatalf("readiness checks=%+v, want no Likeable production-project price check", body.Readiness.Checks) + } } if check := readinessCheck(t, body.Readiness, "fibe_active_pool"); check.OK || check.Severity != "blocker" { t.Fatalf("active pool check=%+v, want blocking failure", check) @@ -6829,7 +6352,7 @@ func TestProjectQuotaDaysConfig(t *testing.T) { } } -func TestProductionProjectDaysConfig(t *testing.T) { +func TestPlaygroundIdleStopHoursConfig(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { t.Fatal(err) @@ -6837,22 +6360,22 @@ func TestProductionProjectDaysConfig(t *testing.T) { defer store.Close() server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient} - if got := server.productionProjectDays(t.Context()); got != defaultProductionProjectDays { - t.Fatalf("default production project days=%d, want %d", got, defaultProductionProjectDays) + if got := server.playgroundIdleStopHours(t.Context()); got != defaultPlaygroundIdleStopHours { + t.Fatalf("default idle stop hours=%d, want %d", got, defaultPlaygroundIdleStopHours) } - if err := store.UpsertConfig(t.Context(), map[string]string{"production_project_days": "45"}, secretConfigKeys); err != nil { + if err := store.UpsertConfig(t.Context(), map[string]string{"playground_idle_stop_hours": "12"}, secretConfigKeys); err != nil { t.Fatal(err) } - if got := server.productionProjectDays(t.Context()); got != 45 { - t.Fatalf("configured production project days=%d, want 45", got) + if got := server.playgroundIdleStopHours(t.Context()); got != 12 { + t.Fatalf("configured idle stop hours=%d, want 12", got) } - if err := store.UpsertConfig(t.Context(), map[string]string{"production_project_days": "900"}, secretConfigKeys); err != nil { + if err := store.UpsertConfig(t.Context(), map[string]string{"playground_idle_stop_hours": "900"}, secretConfigKeys); err != nil { t.Fatal(err) } - if got := server.productionProjectDays(t.Context()); got != maxProductionProjectDays { - t.Fatalf("clamped production project days=%d, want %d", got, maxProductionProjectDays) + if got := server.playgroundIdleStopHours(t.Context()); got != maxPlaygroundIdleStopHours { + t.Fatalf("clamped idle stop hours=%d, want %d", got, maxPlaygroundIdleStopHours) } } @@ -6886,14 +6409,13 @@ func TestAdminBillingHealthReportsStripeConfigAndRecentPayments(t *testing.T) { t.Fatal(err) } if err := appStore.UpsertConfig(t.Context(), map[string]string{ - "stripe_publishable_key": "pk_test_health", - "stripe_secret_key": "sk_test_health", - "stripe_webhook_secret": "whsec_health", - "stripe_price_id_1_hour": "price_hour_1", - "stripe_project_quota_price_id": "price_project_quota", - "stripe_production_project_price_id": "price_production_project", - "free_minutes": "30", - "free_hour_window_hours": "5", + "stripe_publishable_key": "pk_test_health", + "stripe_secret_key": "sk_test_health", + "stripe_webhook_secret": "whsec_health", + "stripe_price_id_1_hour": "price_hour_1", + "stripe_project_quota_price_id": "price_project_quota", + "free_minutes": "30", + "free_hour_window_hours": "5", }, secretConfigKeys); err != nil { t.Fatal(err) } @@ -6925,9 +6447,8 @@ func TestAdminBillingHealthReportsStripeConfigAndRecentPayments(t *testing.T) { WebhookSecret bool `json:"webhookSecret"` } `json:"configured"` Products struct { - HourPacks []int `json:"hourPacks"` - ProjectQuota bool `json:"projectQuota"` - ProductionProject bool `json:"productionProject"` + HourPacks []int `json:"hourPacks"` + ProjectQuota bool `json:"projectQuota"` } `json:"products"` Free struct { Minutes int `json:"minutes"` @@ -6948,8 +6469,8 @@ func TestAdminBillingHealthReportsStripeConfigAndRecentPayments(t *testing.T) { if !resp.Health.Configured.PublishableKey || !resp.Health.Configured.SecretKey || !resp.Health.Configured.WebhookSecret { t.Fatalf("configured=%+v, want all Stripe keys set", resp.Health.Configured) } - if !reflect.DeepEqual(resp.Health.Products.HourPacks, []int{1}) || !resp.Health.Products.ProjectQuota || !resp.Health.Products.ProductionProject { - t.Fatalf("products=%+v, want 1h pack, project quota, and production project", resp.Health.Products) + if !reflect.DeepEqual(resp.Health.Products.HourPacks, []int{1}) || !resp.Health.Products.ProjectQuota { + t.Fatalf("products=%+v, want 1h pack and project quota", resp.Health.Products) } if resp.Health.Free.Minutes != 30 || resp.Health.Free.WindowHours != 5 { t.Fatalf("free=%+v, want 30m/5h", resp.Health.Free) @@ -6977,11 +6498,10 @@ func TestAdminBillingHealthDoesNotRequirePublishableKey(t *testing.T) { t.Fatal(err) } if err := appStore.UpsertConfig(t.Context(), map[string]string{ - "stripe_secret_key": "sk_test_health", - "stripe_webhook_secret": "whsec_health", - "stripe_price_id_1_hour": "price_hour_1", - "stripe_project_quota_price_id": "price_project_quota", - "stripe_production_project_price_id": "price_production_project", + "stripe_secret_key": "sk_test_health", + "stripe_webhook_secret": "whsec_health", + "stripe_price_id_1_hour": "price_hour_1", + "stripe_project_quota_price_id": "price_project_quota", }, secretConfigKeys); err != nil { t.Fatal(err) } @@ -7131,13 +6651,12 @@ func TestAdminProjectDiagnosticsExposeSupportContext(t *testing.T) { } } -func TestAdminCanGrantProjectProduction(t *testing.T) { +func TestAdminProductionGrantIsDisabled(t *testing.T) { appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { t.Fatal(err) } defer appStore.Close() - cliPath, logPath, _ := fakeFibeCLI(t) server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient} admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "") if err != nil { @@ -7150,14 +6669,6 @@ func TestAdminCanGrantProjectProduction(t *testing.T) { if err := appStore.CreateSession(t.Context(), admin.ID, "admin-production-token", time.Hour); err != nil { t.Fatal(err) } - if err := appStore.UpsertConfig(t.Context(), map[string]string{ - "production_project_days": "14", - "fibe_base_url": "server.test:3000", - "fibe_api_key": "test-key", - "fibe_cli_path": cliPath, - }, secretConfigKeys); err != nil { - t.Fatal(err) - } project := &Project{ ID: "project-admin-production", UserID: user.ID, @@ -7177,37 +6688,25 @@ func TestAdminCanGrantProjectProduction(t *testing.T) { rec := httptest.NewRecorder() server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("production grant returned %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusGone { + t.Fatalf("production grant returned %d, want 410; body=%s", rec.Code, rec.Body.String()) } - var resp struct { - Detail AdminUserDetail `json:"detail"` - Project Project `json:"project"` - Granted bool `json:"granted"` - Days int `json:"days"` - } - if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { - t.Fatal(err) - } - if !resp.Granted || resp.Days != 14 || resp.Project.ProductionExpiresAt == "" || resp.Project.PlaygroundIdleStopAt != "" || resp.Project.Status != "launching" { - t.Fatalf("response=%+v, want 14 day production grant with playground starting and no idle stop deadline", resp) + if !strings.Contains(rec.Body.String(), "Fibe") { + t.Fatalf("body=%s, want Fibe handoff message", rec.Body.String()) } stored, err := appStore.ProjectForUser(t.Context(), user.ID, project.ID) if err != nil { t.Fatal(err) } - if stored.ProductionExpiresAt == "" || stored.PlaygroundIdleStopAt != "" || stored.Status != "launching" { - t.Fatalf("stored project=%+v, want production grant reflected with playground starting", stored) - } - if log := readFile(t, logPath); !strings.Contains(log, "playgrounds start playground-admin-production") { - t.Fatalf("missing production start command; log=%s", log) + if stored.ProductionExpiresAt != "" || stored.Status != "stopped" { + t.Fatalf("stored project=%+v, want no production grant", stored) } notices, err := appStore.NoticesForUser(t.Context(), user.ID, 10) if err != nil { t.Fatal(err) } - if len(notices) == 0 || !strings.Contains(notices[0].Body, "Production project enabled") { - t.Fatalf("notices=%+v, want production notice", notices) + if len(notices) != 0 { + t.Fatalf("notices=%+v, want none", notices) } } @@ -7272,20 +6771,12 @@ func TestProductionProjectStartBlockedByRuntimeBillingNotifiesUser(t *testing.T) } } -func TestAdminCanRetryProductionProjectStart(t *testing.T) { +func TestAdminProductionProjectStartRetryIsDisabled(t *testing.T) { appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { t.Fatal(err) } defer appStore.Close() - cliPath, logPath, _ := fakeFibeCLI(t) - if err := appStore.UpsertConfig(t.Context(), map[string]string{ - "fibe_base_url": "server.test:3000", - "fibe_api_key": "test-key", - "fibe_cli_path": cliPath, - }, secretConfigKeys); err != nil { - t.Fatal(err) - } server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient} admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "") if err != nil { @@ -7320,125 +6811,11 @@ func TestAdminCanRetryProductionProjectStart(t *testing.T) { rec := httptest.NewRecorder() server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusAccepted { - t.Fatalf("production start returned %d: %s", rec.Code, rec.Body.String()) - } - var resp struct { - Detail AdminUserDetail `json:"detail"` - Project Project `json:"project"` - Started bool `json:"started"` - BlockedCode string `json:"blockedCode"` - Warning string `json:"warning"` - } - if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { - t.Fatal(err) - } - if !resp.Started || resp.BlockedCode != "" || resp.Warning != "" || resp.Project.Status != "launching" { - t.Fatalf("response=%+v, want successful production start", resp) - } - if len(resp.Detail.Projects) != 1 || resp.Detail.Projects[0].Project.Status != "launching" { - t.Fatalf("detail projects=%+v, want launching project", resp.Detail.Projects) + if rec.Code != http.StatusGone { + t.Fatalf("production start returned %d, want 410; body=%s", rec.Code, rec.Body.String()) } - if log := readFile(t, logPath); !strings.Contains(log, "playgrounds start playground-admin-production-start") { - t.Fatalf("missing production start command; log=%s", log) - } -} - -func TestAdminProductionProjectStartRuntimeBillingReturnsWarning(t *testing.T) { - appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) - if err != nil { - t.Fatal(err) - } - defer appStore.Close() - if err := appStore.UpsertConfig(t.Context(), map[string]string{ - "fibe_base_url": "server.test:3000", - "fibe_api_key": "test-key", - }, secretConfigKeys); err != nil { - t.Fatal(err) - } - logPath := filepath.Join(t.TempDir(), "fibe.log") - server := &Server{ - store: appStore, - config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, - http: fakeFibeHTTPClient(http.DefaultClient, fakeFibeTransportConfig{Mode: "runtime-billing-required", LogPath: logPath}), - } - admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "") - if err != nil { - t.Fatal(err) - } - user, err := appStore.UpsertUser(t.Context(), "admin-runtime-billing@example.com", "Admin Runtime Billing", "") - if err != nil { - t.Fatal(err) - } - if err := appStore.CreateSession(t.Context(), admin.ID, "admin-runtime-billing-token", time.Hour); err != nil { - t.Fatal(err) - } - project := &Project{ - ID: "project-admin-runtime-billing", - UserID: user.ID, - Title: "Admin Runtime Billing", - ConversationID: "conv-admin-runtime-billing", - AgentID: "agent-1", - MarqueeID: "server-1", - PlaygroundID: "playground-admin-runtime-billing", - Status: "stopped", - } - if err := appStore.CreateProject(t.Context(), project); err != nil { - t.Fatal(err) - } - if granted, err := appStore.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_admin_runtime_billing", time.Now().UTC().Add(7*24*time.Hour)); err != nil || !granted { - t.Fatalf("production grant=%v err=%v, want granted", granted, err) - } - - req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/production/start", strings.NewReader(`{}`)) - req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-runtime-billing-token"}) - rec := httptest.NewRecorder() - server.routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusAccepted { - t.Fatalf("production start returned %d: %s", rec.Code, rec.Body.String()) - } - var resp struct { - Project Project `json:"project"` - Started bool `json:"started"` - BlockedCode string `json:"blockedCode"` - Warning string `json:"warning"` - } - if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { - t.Fatal(err) - } - if resp.Started || resp.BlockedCode != "runtime_billing_required" || !strings.Contains(resp.Warning, "production runtime is not funded") || resp.Project.Status != "stopped" { - t.Fatalf("response=%+v, want runtime billing warning with stopped project", resp) - } - notices, err := appStore.NoticesForUser(t.Context(), user.ID, 10) - if err != nil { - t.Fatal(err) - } - if len(notices) != 1 || !strings.Contains(notices[0].Body, "Production runtime paused") { - t.Fatalf("notices=%+v, want production runtime notice", notices) - } - if log := readFile(t, logPath); strings.Count(log, "playgrounds start playground-admin-runtime-billing") != 1 { - t.Fatalf("log=%s, want one start attempt", log) - } - - diagReq := httptest.NewRequest(http.MethodGet, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/diagnostics", nil) - diagReq.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-runtime-billing-token"}) - diagRec := httptest.NewRecorder() - server.routes().ServeHTTP(diagRec, diagReq) - - if diagRec.Code != http.StatusOK { - t.Fatalf("diagnostics returned %d: %s", diagRec.Code, diagRec.Body.String()) - } - var diagResp struct { - Diagnostics AdminProjectDiagnostics `json:"diagnostics"` - } - if err := json.NewDecoder(diagRec.Body).Decode(&diagResp); err != nil { - t.Fatal(err) - } - if diagResp.Diagnostics.Internal.ProductionRuntimeStatus != "runtime_billing_required" || - !strings.Contains(diagResp.Diagnostics.Internal.ProductionRuntimeMessage, "not funded") || - diagResp.Diagnostics.Internal.ProductionRuntimeBlockedAt == "" { - t.Fatalf("diagnostics internal=%+v, want runtime billing support context", diagResp.Diagnostics.Internal) + if !strings.Contains(rec.Body.String(), "Fibe") { + t.Fatalf("body=%s, want Fibe handoff message", rec.Body.String()) } } @@ -7470,8 +6847,8 @@ func TestAdminProductionGrantRejectsInactiveProject(t *testing.T) { rec := httptest.NewRecorder() server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusConflict { - t.Fatalf("production grant returned %d, want 409; body=%s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusGone { + t.Fatalf("production grant returned %d, want 410; body=%s", rec.Code, rec.Body.String()) } stored, err := appStore.ProjectForUser(t.Context(), user.ID, project.ID) if err != nil { @@ -7665,16 +7042,14 @@ func TestProjectQuotaCheckoutBuildsStripeMetadata(t *testing.T) { assertStripeCheckoutUsesDynamicPaymentMethods(t, form) } -func TestProductionProjectCheckoutBuildsStripeMetadata(t *testing.T) { +func TestProductionProjectCheckoutIsDisabledBeforeStripe(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { t.Fatal(err) } defer store.Close() if err := store.UpsertConfig(t.Context(), map[string]string{ - "stripe_secret_key": "sk_test", - "stripe_production_project_price_id": "price_production_project", - "production_project_days": "45", + "stripe_secret_key": "sk_test", }, secretConfigKeys); err != nil { t.Fatal(err) } @@ -7689,16 +7064,9 @@ func TestProductionProjectCheckoutBuildsStripeMetadata(t *testing.T) { if err := store.CreateProject(t.Context(), project); err != nil { t.Fatal(err) } - var form url.Values + stripeCalled := false client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - body, err := io.ReadAll(req.Body) - if err != nil { - return nil, err - } - form, err = url.ParseQuery(string(body)) - if err != nil { - return nil, err - } + stripeCalled = true return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), @@ -7712,19 +7080,15 @@ func TestProductionProjectCheckoutBuildsStripeMetadata(t *testing.T) { server.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("checkout returned %d, want 200; body=%s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusGone { + t.Fatalf("checkout returned %d, want 410; body=%s", rec.Code, rec.Body.String()) } - if form.Get("line_items[0][price]") != "price_production_project" || - form.Get("metadata[purchase_kind]") != "production_project" || - form.Get("metadata[project_id]") != project.ID || - form.Get("metadata[production_project_days]") != "45" { - t.Fatalf("stripe form=%v, want production project metadata", form) + if stripeCalled { + t.Fatal("Stripe was called for disabled production checkout") } - assertStripeCheckoutUsesDynamicPaymentMethods(t, form) } -func TestProductionProjectCheckoutRejectsInvalidProjectStateBeforeStripe(t *testing.T) { +func TestProductionProjectCheckoutAlwaysReturnsGoneBeforeStripe(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { t.Fatal(err) @@ -7769,10 +7133,10 @@ func TestProductionProjectCheckoutRejectsInvalidProjectStateBeforeStripe(t *test body string code int }{ - {name: "missing project id", body: `{"product":"production_project"}`, code: http.StatusBadRequest}, - {name: "project owned by another user", body: `{"product":"production_project","projectId":"project-production-other"}`, code: http.StatusNotFound}, - {name: "archived project", body: `{"product":"production_project","projectId":"project-production-archived"}`, code: http.StatusConflict}, - {name: "already active production", body: `{"product":"production_project","projectId":"project-production-active"}`, code: http.StatusConflict}, + {name: "missing project id", body: `{"product":"production_project"}`, code: http.StatusGone}, + {name: "project owned by another user", body: `{"product":"production_project","projectId":"project-production-other"}`, code: http.StatusGone}, + {name: "archived project", body: `{"product":"production_project","projectId":"project-production-archived"}`, code: http.StatusGone}, + {name: "already active production", body: `{"product":"production_project","projectId":"project-production-active"}`, code: http.StatusGone}, } { t.Run(tc.name, func(t *testing.T) { stripeCalled = false @@ -8006,20 +7370,15 @@ func TestStripeWebhookGrantsProjectQuota(t *testing.T) { } } -func TestStripeWebhookGrantsProductionProject(t *testing.T) { +func TestStripeWebhookIgnoresLegacyProductionProjectPurchase(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { t.Fatal(err) } defer store.Close() - cliPath, logPath, _ := fakeFibeCLI(t) if err := store.UpsertConfig(t.Context(), map[string]string{ - "stripe_secret_key": "sk_test", - "stripe_webhook_secret": "whsec_test", - "stripe_production_project_price_id": "price_production_project", - "fibe_base_url": "server.test:3000", - "fibe_api_key": "test-key", - "fibe_cli_path": cliPath, + "stripe_secret_key": "sk_test", + "stripe_webhook_secret": "whsec_test", }, secretConfigKeys); err != nil { t.Fatal(err) } @@ -8032,16 +7391,10 @@ func TestStripeWebhookGrantsProductionProject(t *testing.T) { t.Fatal(err) } client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if !strings.Contains(req.URL.Path, "/line_items") { - t.Fatalf("unexpected stripe request %s", req.URL.String()) - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(`{"data":[{"price":{"id":"price_production_project"}}]}`)), - }, nil + t.Fatalf("unexpected stripe request %s", req.URL.String()) + return nil, nil })} - server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: fakeFibeHTTPClient(client, fakeFibeTransportConfig{LogPath: logPath})} + server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: client} event := map[string]any{ "type": "checkout.session.completed", "data": map[string]any{"object": map[string]any{ @@ -8072,25 +7425,22 @@ func TestStripeWebhookGrantsProductionProject(t *testing.T) { if err != nil { t.Fatal(err) } - if stored.ProductionExpiresAt == "" || stored.PlaygroundIdleStopAt != "" || stored.Status != "launching" { - t.Fatalf("project=%+v, want production grant with playground starting and no idle stop", stored) - } - if log := readFile(t, logPath); !strings.Contains(log, "playgrounds start playground-production-webhook") { - t.Fatalf("missing production start command; log=%s", log) + if stored.ProductionExpiresAt != "" || stored.Status != "stopped" { + t.Fatalf("project=%+v, want no production grant or start", stored) } - expires, err := time.Parse(time.RFC3339Nano, stored.ProductionExpiresAt) + payments, err := store.UserPayments(t.Context(), user.ID, 10) if err != nil { t.Fatal(err) } - if expires.Before(time.Now().UTC().Add(44*24*time.Hour)) || expires.After(time.Now().UTC().Add(46*24*time.Hour)) { - t.Fatalf("production expiresAt=%s, want roughly 45 days from now", stored.ProductionExpiresAt) + if len(payments) != 1 || payments[0].ProviderPaymentID != "cs_production_project" { + t.Fatalf("payments=%+v, want legacy payment recorded without grant", payments) } notices, err := store.NoticesForUser(t.Context(), user.ID, 10) if err != nil { t.Fatal(err) } - if len(notices) == 0 || !strings.Contains(notices[0].Body, "Production project enabled") { - t.Fatalf("notices=%+v, want production project purchase notice", notices) + if len(notices) != 0 { + t.Fatalf("notices=%+v, want no production notice", notices) } } diff --git a/internal/likeable/stripe.go b/internal/likeable/stripe.go index 69fce20..11b87bd 100644 --- a/internal/likeable/stripe.go +++ b/internal/likeable/stripe.go @@ -30,13 +30,12 @@ func (s *Server) stripeConfig(r *http.Request) (map[string]string, error) { func stripeConfigFromMap(cfg map[string]string) map[string]string { out := map[string]string{ - "secret": strings.TrimSpace(cfg["stripe_secret_key"]), - "price_1_hour": strings.TrimSpace(cfg["stripe_price_id_1_hour"]), - "price_10_hours": strings.TrimSpace(cfg["stripe_price_id_10_hours"]), - "price_100_hours": strings.TrimSpace(cfg["stripe_price_id_100_hours"]), - "project_quota_price": strings.TrimSpace(cfg["stripe_project_quota_price_id"]), - "production_project_price": strings.TrimSpace(cfg["stripe_production_project_price_id"]), - "webhook": strings.TrimSpace(cfg["stripe_webhook_secret"]), + "secret": strings.TrimSpace(cfg["stripe_secret_key"]), + "price_1_hour": strings.TrimSpace(cfg["stripe_price_id_1_hour"]), + "price_10_hours": strings.TrimSpace(cfg["stripe_price_id_10_hours"]), + "price_100_hours": strings.TrimSpace(cfg["stripe_price_id_100_hours"]), + "project_quota_price": strings.TrimSpace(cfg["stripe_project_quota_price_id"]), + "webhook": strings.TrimSpace(cfg["stripe_webhook_secret"]), } return out } @@ -48,7 +47,6 @@ func (s *Server) billingProducts(ctx context.Context) map[string]any { } products := billingProductsFromConfig(stripeConfigFromMap(cfg)) products["projectQuotaDays"] = s.projectQuotaDays(ctx) - products["productionProjectDays"] = s.productionProjectDays(ctx) return products } @@ -67,20 +65,17 @@ func billingProductsFromConfig(cfg map[string]string) map[string]any { hourPacks = append(hourPacks, 100) } return map[string]any{ - "hourPacks": hourPacks, - "projectQuota": strings.TrimSpace(cfg["project_quota_price"]) != "", - "productionProject": strings.TrimSpace(cfg["production_project_price"]) != "", - "productionProjectDays": defaultProductionProjectDays, + "hourPacks": hourPacks, + "projectQuota": strings.TrimSpace(cfg["project_quota_price"]) != "", + "projectQuotaDays": defaultProjectQuotaDays, } } func emptyBillingProducts() map[string]any { return map[string]any{ - "hourPacks": []int{}, - "projectQuota": false, - "projectQuotaDays": defaultProjectQuotaDays, - "productionProject": false, - "productionProjectDays": defaultProductionProjectDays, + "hourPacks": []int{}, + "projectQuota": false, + "projectQuotaDays": defaultProjectQuotaDays, } } @@ -90,11 +85,6 @@ func (s *Server) handleBillingCheckout(w http.ResponseWriter, r *http.Request) { return } user := userFromContext(r.Context()) - cfg, err := s.stripeConfig(r) - if err != nil { - writeError(w, http.StatusServiceUnavailable, err.Error()) - return - } var body struct { Pack int `json:"pack"` Product string `json:"product"` @@ -106,12 +96,19 @@ func (s *Server) handleBillingCheckout(w http.ResponseWriter, r *http.Request) { return } product := normalizeStripeProduct(body.Product) + if product == "production_project" { + writeError(w, http.StatusGone, "production hosting is handled in Fibe") + return + } + cfg, err := s.stripeConfig(r) + if err != nil { + writeError(w, http.StatusServiceUnavailable, err.Error()) + return + } pack := 0 slots := 0 priceID := "" switch product { - case "production_project": - priceID, err = stripeProductionProjectPrice(cfg) case "project_quota": slots, priceID, err = stripeProjectQuotaPrice(cfg, body.Slots) default: @@ -128,28 +125,7 @@ func (s *Server) handleBillingCheckout(w http.ResponseWriter, r *http.Request) { form.Set("success_url", s.config.BaseURL+"/profile?billing=success&session_id={CHECKOUT_SESSION_ID}") form.Set("cancel_url", s.config.BaseURL+"/profile?billing=cancel") form.Set("metadata[purchase_kind]", product) - if product == "production_project" { - projectID := strings.TrimSpace(body.ProjectID) - if projectID == "" { - writeError(w, http.StatusBadRequest, "project_id is required") - return - } - project, err := s.store.ProjectForUser(r.Context(), user.ID, projectID) - if err != nil { - writeError(w, http.StatusNotFound, "project not found") - return - } - if project.Status == "archived" || project.Status == "deleting" { - writeError(w, http.StatusConflict, "production project requires an active project") - return - } - if strings.TrimSpace(project.ProductionExpiresAt) != "" { - writeError(w, http.StatusConflict, "production project is already active") - return - } - form.Set("metadata[project_id]", projectID) - form.Set("metadata[production_project_days]", strconv.Itoa(s.productionProjectDays(r.Context()))) - } else if product == "project_quota" { + if product == "project_quota" { form.Set("metadata[project_slots]", strconv.Itoa(slots)) form.Set("metadata[project_quota_days]", strconv.Itoa(s.projectQuotaDays(r.Context()))) } else { @@ -267,13 +243,6 @@ func stripeProjectQuotaPrice(cfg map[string]string, slots int) (int, string, err return slots, cfg["project_quota_price"], nil } -func stripeProductionProjectPrice(cfg map[string]string) (string, error) { - if cfg["production_project_price"] == "" { - return "", fmt.Errorf("Stripe price for production project is not configured") - } - return cfg["production_project_price"], nil -} - func (s *Server) handleStripeWebhook(w http.ResponseWriter, r *http.Request) { body := readAll(r.Body) rawCfg, _ := s.store.ConfigMap(r.Context()) @@ -384,25 +353,6 @@ func (s *Server) applyStripeCheckoutSession(ctx context.Context, cfg map[string] } } } - productionProjectID := stripeProductionProjectID(session["metadata"]) - if productionProjectID != "" { - result.PurchaseKind = "production_project" - if err := s.verifyStripeCheckoutPrice(ctx, cfg, sessionID, cfg["production_project_price"]); err != nil { - return result, err - } - result.Applied = true - if sessionID != "" && sessionID != "" { - days := stripeProductionProjectDays(session["metadata"], s.productionProjectDays(ctx)) - expiresAt := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour) - if granted, err := s.store.GrantProjectProduction(ctx, userID, productionProjectID, sessionID, expiresAt); err == nil && granted { - result.Granted = true - s.startProductionProjectIfStopped(ctx, userID, productionProjectID) - s.notifyProductionProjectPurchased(ctx, userID, productionProjectID, expiresAt) - } else if err != nil { - return result, err - } - } - } projectSlots := stripeProjectSlots(session["metadata"]) if projectSlots > 0 { result.PurchaseKind = "project_quota" @@ -421,6 +371,9 @@ func (s *Server) applyStripeCheckoutSession(ctx context.Context, cfg map[string] } } } + if stripeProductionProjectID(session["metadata"]) != "" { + result.PurchaseKind = "production_project" + } if subscriptionID != "" && subscriptionID != "" { result.Applied = true _ = s.store.UpsertSubscription(ctx, Subscription{ @@ -534,27 +487,6 @@ func stripeProductionProjectID(metadata any) string { return "" } -func stripeProductionProjectDays(metadata any, fallback int) int { - if fallback <= 0 || fallback > maxProductionProjectDays { - fallback = defaultProductionProjectDays - } - raw := "" - if m, ok := metadata.(map[string]any); ok { - raw = fmt.Sprint(m["production_project_days"]) - } - if raw == "" || raw == "" { - return fallback - } - n, err := strconv.Atoi(raw) - if err != nil || n <= 0 { - return fallback - } - if n > maxProductionProjectDays { - return maxProductionProjectDays - } - return n -} - func expectedStripeHourPackPrice(cfg map[string]string, pack int) string { switch pack { case 1: diff --git a/internal/store/store_projects.go b/internal/store/store_projects.go index e2c3da7..8905627 100644 --- a/internal/store/store_projects.go +++ b/internal/store/store_projects.go @@ -612,15 +612,9 @@ func (s *Store) IdleProjectsForPlaygroundStop(ctx context.Context, cutoff time.T projects.selected_service_name, projects.status, projects.error_message, projects.provisioning_lock_until, projects.cleanup_last_error, projects.playground_last_used_at, projects.created_at, projects.updated_at FROM projects WHERE projects.status = 'ready' AND TRIM(projects.playground_id) != '' AND TRIM(projects.playground_last_used_at) != '' AND projects.playground_last_used_at < ? - AND NOT EXISTS ( - SELECT 1 - FROM project_production_grants - WHERE project_production_grants.project_id = projects.id - AND project_production_grants.expires_at > ? - ) ORDER BY projects.playground_last_used_at ASC LIMIT ? - `, cutoffString, nowString(), limit) + `, cutoffString, limit) if err != nil { return nil, err } @@ -652,24 +646,17 @@ func (s *Store) IdleProjectsForPlaygroundStop(ctx context.Context, cutoff time.T func (s *Store) ProjectIdleForPlaygroundStop(ctx context.Context, projectID string, cutoff time.Time) (bool, string, error) { row := s.db.QueryRowContext(ctx, ` - SELECT projects.status, projects.playground_id, projects.playground_last_used_at, - COALESCE((SELECT MAX(project_production_grants.expires_at) - FROM project_production_grants - WHERE project_production_grants.project_id = projects.id - AND project_production_grants.expires_at > ?), '') + SELECT projects.status, projects.playground_id, projects.playground_last_used_at FROM projects WHERE projects.id = ? - `, nowString(), projectID) - var status, playgroundID, lastUsedAt, productionExpiresAt string - if err := row.Scan(&status, &playgroundID, &lastUsedAt, &productionExpiresAt); err != nil { + `, projectID) + var status, playgroundID, lastUsedAt string + if err := row.Scan(&status, &playgroundID, &lastUsedAt); err != nil { return false, "", err } if status != "ready" || strings.TrimSpace(playgroundID) == "" { return false, "", nil } - if strings.TrimSpace(productionExpiresAt) != "" { - return false, "production project active until " + productionExpiresAt, nil - } lastUsedAt = strings.TrimSpace(lastUsedAt) if lastUsedAt == "" { return false, "missing playground_last_used_at", nil diff --git a/internal/store/store_projects_test.go b/internal/store/store_projects_test.go index 3770d9a..be289ec 100644 --- a/internal/store/store_projects_test.go +++ b/internal/store/store_projects_test.go @@ -293,7 +293,7 @@ func TestIdleProjectsForPlaygroundStopUsesDedicatedUsageTimestamp(t *testing.T) } } -func TestIdleProjectsForPlaygroundStopSkipsOnlyActiveProductionProjects(t *testing.T) { +func TestIdleProjectsForPlaygroundStopIncludesLegacyProductionProjects(t *testing.T) { store, err := Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { t.Fatal(err) @@ -322,15 +322,19 @@ func TestIdleProjectsForPlaygroundStopSkipsOnlyActiveProductionProjects(t *testi if err != nil { t.Fatal(err) } - if len(projects) != 1 || projects[0].ID != expiredProject.ID { - t.Fatalf("idle projects=%+v, want only expired production project", projects) + got := map[string]bool{} + for _, project := range projects { + got[project.ID] = true + } + if len(projects) != 2 || !got[activeProject.ID] || !got[expiredProject.ID] { + t.Fatalf("idle projects=%+v, want active and expired legacy production projects", projects) } idle, reason, err := store.ProjectIdleForPlaygroundStop(t.Context(), activeProject.ID, time.Now().UTC().Add(-8*time.Hour)) if err != nil { t.Fatal(err) } - if idle || reason == "" { - t.Fatalf("active production idle=%v reason=%q, want skipped with reason", idle, reason) + if !idle || reason != "" { + t.Fatalf("active production idle=%v reason=%q, want eligible idle project", idle, reason) } idle, reason, err = store.ProjectIdleForPlaygroundStop(t.Context(), expiredProject.ID, time.Now().UTC().Add(-8*time.Hour)) if err != nil { From 9e1d8b478ad8a0326df7bd52e3cc9ea32e8947c5 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Thu, 28 May 2026 15:55:03 +0300 Subject: [PATCH 34/36] Hide legacy production and domain internals --- frontend/src/admin.tsx | 7 - frontend/src/domain.ts | 4 +- frontend/src/styles.css | 68 ------- internal/domain/types.go | 52 ++--- internal/likeable/admin_handlers.go | 33 --- internal/likeable/notifications.go | 31 --- internal/likeable/production_project.go | 69 ------- .../likeable/project_domain_verification.go | 120 ----------- internal/likeable/project_handlers.go | 122 ------------ internal/likeable/server.go | 1 - internal/likeable/server_test.go | 99 +-------- internal/store/store_projects.go | 188 ------------------ internal/store/types.go | 7 - 13 files changed, 26 insertions(+), 775 deletions(-) delete mode 100644 internal/likeable/production_project.go delete mode 100644 internal/likeable/project_domain_verification.go diff --git a/frontend/src/admin.tsx b/frontend/src/admin.tsx index 4679ede..b28997c 100644 --- a/frontend/src/admin.tsx +++ b/frontend/src/admin.tsx @@ -551,13 +551,6 @@ function diagnosticEntries(diagnostics: AdminProjectDiagnostics): [string, strin ['prop_id', internal.propId ?? ''], ['repo_url', internal.repoUrl ?? ''], ['selected_service', diagnostics.project.selectedServiceName ?? ''], - ['production_expires_at', diagnostics.project.productionExpiresAt ?? ''], - ['production_runtime_status', internal.productionRuntimeStatus ?? ''], - ['production_runtime_blocked_at', internal.productionRuntimeBlockedAt ?? ''], - ['production_runtime_message', internal.productionRuntimeMessage ?? ''], - ['custom_domain', diagnostics.project.customDomain ?? ''], - ['custom_domain_status', diagnostics.project.customDomainStatus ?? ''], - ['custom_domain_target', diagnostics.project.customDomainTarget ?? ''], ['services', services.map((service) => `${service.name}:${service.url}`).join(' | ')], ['repositories', repositories.map((repository) => `${repository.role}:${repository.sourceRepoUrl || repository.id}`).join(' | ')], ['internal_error', internal.internalErrorMessage ?? ''], diff --git a/frontend/src/domain.ts b/frontend/src/domain.ts index 7fddd48..2442f07 100644 --- a/frontend/src/domain.ts +++ b/frontend/src/domain.ts @@ -1,7 +1,7 @@ export type User = { id: string; email: string; name: string; avatarUrl: string; accessStatus?: string; accessNote?: string }; export type ProjectRepository = { id: string; role: string; sourceRepoUrl?: string; provider?: string; serviceNames?: string[]; createdAt?: string }; export type ProjectService = { id: string; name: string; url: string; type?: string; visibility?: string; authRequired?: boolean; createdAt?: string }; -export type Project = { id: string; title: string; previewUrl?: string; selectedServiceName?: string; repositories?: ProjectRepository[]; services?: ProjectService[]; status: string; errorMessage?: string; playgroundLastUsedAt?: string; playgroundIdleStopAt?: string; productionExpiresAt?: string; customDomain?: string; customDomainStatus?: string; customDomainTarget?: string; customDomainUpdatedAt?: string; createdAt: string; updatedAt: string }; +export type Project = { id: string; title: string; previewUrl?: string; selectedServiceName?: string; repositories?: ProjectRepository[]; services?: ProjectService[]; status: string; errorMessage?: string; playgroundLastUsedAt?: string; playgroundIdleStopAt?: string; createdAt: string; updatedAt: string }; export type HourQuota = { usedMs: number; limitMs: number; remainingMs: number; paidRemainingMs?: number; lifetimeUsedMs?: number; resetsAt?: string; windowHours?: number }; export type ProjectQuota = { used: number; limit: number; remaining: number; baseLimit: number; paidSlots: number; nextExpiresAt?: string }; export type BillingProducts = { hourPacks: number[]; projectQuota: boolean; projectQuotaDays?: number }; @@ -66,7 +66,7 @@ export type AdminProjectSummary = { project: Project; workMs: number; assignment export type AdminUserDetail = { summary: AdminUserSummary; projects: AdminProjectSummary[]; notices: UserNotice[]; agentPool?: AgentPoolOption[] }; export type AdminUsersResponse = { users: AdminUserSummary[]; agentPool?: AgentPoolOption[]; pagination: { page: number; perPage: number; total: number } }; export type AdminBillingPayment = { id: string; userId: string; userEmail: string; providerPaymentId: string; amountCents: number; currency: string; status: string; createdAt: string }; -export type AdminProjectInternal = { userId: string; conversationId?: string; agentId?: string; serverId?: string; playgroundId?: string; playgroundName?: string; playspecId?: string; propId?: string; repoUrl?: string; provisioningLockUntil?: string; internalErrorMessage?: string; cleanupLastError?: string; productionRuntimeStatus?: string; productionRuntimeMessage?: string; productionRuntimeBlockedAt?: string }; +export type AdminProjectInternal = { userId: string; conversationId?: string; agentId?: string; serverId?: string; playgroundId?: string; playgroundName?: string; playspecId?: string; propId?: string; repoUrl?: string; provisioningLockUntil?: string; internalErrorMessage?: string; cleanupLastError?: string }; export type AdminProjectWorkSession = { projectId: string; userId: string; sessionKey: string; startedAt: string; completedAt?: string; elapsedMs: number; freeBilledMs: number; paidBilledMs: number; billedAt?: string; createdAt: string; updatedAt: string }; export type AdminHourCreditLedgerEntry = { id: string; userId: string; deltaMs: number; reason: string; paymentId?: string; workSessionKey?: string; createdAt: string }; export type AdminProjectDiagnostics = { project: Project; internal: AdminProjectInternal; workSessions: AdminProjectWorkSession[]; hourLedger: AdminHourCreditLedgerEntry[]; payments: AdminBillingPayment[] }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 073c6e3..a0aa0f6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -6027,10 +6027,6 @@ body { transition: border-color .16s ease, background .16s ease, box-shadow .16s ease; } -.projectRow.domainEditing { - flex-wrap: wrap; -} - .projectRow:hover, .projectRow:focus-within, .serviceOption:hover, @@ -6109,12 +6105,6 @@ body { text-transform: uppercase; } -.projectCurrentBadge.production { - border-color: rgba(80, 202, 154, .22); - background: rgba(80, 202, 154, .1); - color: #7ee0b9; -} - .projectSelectMeta { width: 100%; min-width: 0; @@ -6176,64 +6166,6 @@ body { color: var(--danger); } -.projectSelectDetail.production { - color: #7ee0b9; -} - -.projectDomainForm { - flex: 1 0 calc(100% - 42px); - margin-left: 38px; - min-width: 0; - display: flex; - align-items: center; - gap: 8px; - padding: 8px; - border: 1px solid rgba(80, 202, 154, .2); - border-radius: 10px; - background: rgba(80, 202, 154, .06); -} - -.projectDomainForm > svg { - flex: 0 0 auto; - color: #7ee0b9; -} - -.projectDomainForm input { - flex: 1 1 auto; - min-width: 0; - border: 0; - outline: 0; - background: transparent; - color: var(--text-primary); - font-size: 12px; - font-weight: 650; -} - -.projectDomainForm button { - min-height: 28px; - border: 1px solid var(--border-subtle); - border-radius: 8px; - background: rgba(4, 12, 16, .55); - color: var(--text-secondary); - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 0 9px; - font-size: 11px; - font-weight: 760; -} - -.projectDomainForm button:hover { - border-color: rgba(80, 202, 154, .3); - color: #7ee0b9; -} - -.projectDomainForm button:disabled { - opacity: .48; - cursor: not-allowed; -} - .projectSelectDetail.archived, .projectSelectDetail.stopped { color: var(--text-tertiary); diff --git a/internal/domain/types.go b/internal/domain/types.go index 9ce2bed..b684a59 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -7,12 +7,6 @@ import ( const DefaultPlaygroundIdleStopAfter = 8 * time.Hour -const ( - ProjectDomainStatusPendingDNS = "pending_dns" - ProjectDomainStatusDNSVerified = "dns_verified" - ProjectDomainStatusActive = "active" -) - type User struct { ID string `json:"id"` Email string `json:"email"` @@ -45,24 +39,15 @@ type Project struct { CleanupLastError string `json:"-"` PlaygroundLastUsedAt string `json:"playgroundLastUsedAt,omitempty"` PlaygroundIdleStopAt string `json:"playgroundIdleStopAt,omitempty"` - ProductionExpiresAt string `json:"productionExpiresAt,omitempty"` - CustomDomain string `json:"customDomain,omitempty"` - CustomDomainStatus string `json:"customDomainStatus,omitempty"` - CustomDomainTarget string `json:"customDomainTarget,omitempty"` - CustomDomainUpdatedAt string `json:"customDomainUpdatedAt,omitempty"` + ProductionExpiresAt string `json:"-"` + CustomDomain string `json:"-"` + CustomDomainStatus string `json:"-"` + CustomDomainTarget string `json:"-"` + CustomDomainUpdatedAt string `json:"-"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } -type ProjectDomain struct { - ProjectID string - UserID string - Domain string - Target string - Status string - UpdatedAt string -} - func (p *Project) RefreshComputedFields() { p.RefreshComputedFieldsWithIdleStopAfter(DefaultPlaygroundIdleStopAfter) } @@ -299,21 +284,18 @@ type AdminHourCreditLedgerEntry struct { } type AdminProjectInternal struct { - UserID string `json:"userId"` - ConversationID string `json:"conversationId,omitempty"` - AgentID string `json:"agentId,omitempty"` - ServerID string `json:"serverId,omitempty"` - PlaygroundID string `json:"playgroundId,omitempty"` - PlaygroundName string `json:"playgroundName,omitempty"` - PlayspecID string `json:"playspecId,omitempty"` - PropID string `json:"propId,omitempty"` - RepoURL string `json:"repoUrl,omitempty"` - ProvisioningLockUntil string `json:"provisioningLockUntil,omitempty"` - InternalErrorMessage string `json:"internalErrorMessage,omitempty"` - CleanupLastError string `json:"cleanupLastError,omitempty"` - ProductionRuntimeStatus string `json:"productionRuntimeStatus,omitempty"` - ProductionRuntimeMessage string `json:"productionRuntimeMessage,omitempty"` - ProductionRuntimeBlockedAt string `json:"productionRuntimeBlockedAt,omitempty"` + UserID string `json:"userId"` + ConversationID string `json:"conversationId,omitempty"` + AgentID string `json:"agentId,omitempty"` + ServerID string `json:"serverId,omitempty"` + PlaygroundID string `json:"playgroundId,omitempty"` + PlaygroundName string `json:"playgroundName,omitempty"` + PlayspecID string `json:"playspecId,omitempty"` + PropID string `json:"propId,omitempty"` + RepoURL string `json:"repoUrl,omitempty"` + ProvisioningLockUntil string `json:"provisioningLockUntil,omitempty"` + InternalErrorMessage string `json:"internalErrorMessage,omitempty"` + CleanupLastError string `json:"cleanupLastError,omitempty"` } type AdminProjectDiagnostics struct { diff --git a/internal/likeable/admin_handlers.go b/internal/likeable/admin_handlers.go index cb7997f..f1b561f 100644 --- a/internal/likeable/admin_handlers.go +++ b/internal/likeable/admin_handlers.go @@ -736,7 +736,6 @@ func (s *Server) handleAdminUserProjectDiagnostics(w http.ResponseWriter, r *htt writeError(w, http.StatusInternalServerError, err.Error()) return } - s.decorateAdminProjectRuntimeDiagnostics(r.Context(), diagnostics) writeJSON(w, http.StatusOK, map[string]any{"diagnostics": diagnostics}) } @@ -748,38 +747,6 @@ func (s *Server) handleAdminUserProjectProductionStart(w http.ResponseWriter, r writeError(w, http.StatusGone, "production hosting is handled in Fibe") } -func (s *Server) decorateAdminProjectRuntimeDiagnostics(ctx context.Context, diagnostics *AdminProjectDiagnostics) { - if diagnostics == nil || strings.TrimSpace(diagnostics.Project.ProductionExpiresAt) == "" { - return - } - switch diagnostics.Project.Status { - case "ready": - diagnostics.Internal.ProductionRuntimeStatus = "running" - case "creating", "launching": - diagnostics.Internal.ProductionRuntimeStatus = "starting" - case "stopped": - diagnostics.Internal.ProductionRuntimeStatus = "stopped" - default: - diagnostics.Internal.ProductionRuntimeStatus = diagnostics.Project.Status - } - if diagnostics.Project.Status != "stopped" { - return - } - notices, err := s.store.NoticesForUser(ctx, diagnostics.Project.UserID, 50) - if err != nil { - return - } - prefix := "Production runtime paused: " + strconv.Quote(diagnostics.Project.Title) - for _, notice := range notices { - if notice.Sender == "system" && strings.HasPrefix(notice.Body, prefix) { - diagnostics.Internal.ProductionRuntimeStatus = "runtime_billing_required" - diagnostics.Internal.ProductionRuntimeMessage = notice.Body - diagnostics.Internal.ProductionRuntimeBlockedAt = notice.CreatedAt - return - } - } -} - func (s *Server) handleAdminUserProjectAssignment(w http.ResponseWriter, r *http.Request, userID, projectID string) { var body struct { AgentID string `json:"agent_id"` diff --git a/internal/likeable/notifications.go b/internal/likeable/notifications.go index 27f98f4..6c5bdd4 100644 --- a/internal/likeable/notifications.go +++ b/internal/likeable/notifications.go @@ -156,37 +156,6 @@ func (s *Server) notifyProjectQuotaPurchased(ctx context.Context, userID string, s.addSystemNoticeAndEmail(ctx, user, "info", body, "Likeable project quota added", body+"\n\nManage projects:\n"+s.profileURL()) } -func (s *Server) notifyProductionProjectPurchased(ctx context.Context, userID, projectID string, expiresAt time.Time) { - user, err := s.store.UserByID(ctx, userID) - if err != nil { - log.Printf("load user for production project purchase notice %s: %v", userID, err) - return - } - project, err := s.store.ProjectForUser(ctx, userID, projectID) - if err != nil { - log.Printf("load project for production project purchase notice %s/%s: %v", userID, projectID, err) - return - } - body := fmt.Sprintf("Production project enabled: %q will stay online until %s. Use the project menu for CNAME instructions.", project.Title, expiresAt.UTC().Format("2006-01-02 15:04 UTC")) - s.addSystemNoticeAndEmail(ctx, user, "info", body, "Likeable production project enabled", body+"\n\nOpen Likeable:\n"+s.config.BaseURL) -} - -func (s *Server) notifyProductionProjectStartBlocked(ctx context.Context, user *User, project *Project) { - if user == nil || project == nil { - return - } - prefix := fmt.Sprintf("Production runtime paused: %q", project.Title) - exists, err := s.store.NoticeExistsSince(ctx, user.ID, "system", prefix, time.Now().UTC().Add(-24*time.Hour)) - if err == nil && exists { - return - } - if err != nil { - log.Printf("production runtime notice dedupe for %s project=%s: %v", user.Email, project.ID, err) - } - body := prefix + " has an active production grant, but the linked Fibe runtime is not funded yet. Support must fund the runtime, then Likeable will retry starting it automatically." - s.addSystemNoticeAndEmail(ctx, user, "warning", body, "Likeable production runtime paused", body+"\n\nOpen Likeable:\n"+s.config.BaseURL) -} - func (s *Server) notifyProjectExportReady(ctx context.Context, user *User, project *Project, repoURL string) { if user == nil || project == nil || strings.TrimSpace(repoURL) == "" { return diff --git a/internal/likeable/production_project.go b/internal/likeable/production_project.go deleted file mode 100644 index cbb4c46..0000000 --- a/internal/likeable/production_project.go +++ /dev/null @@ -1,69 +0,0 @@ -package likeable - -import ( - "context" - "log" - "strings" - "time" - - fibegateway "github.com/fibegg/likeable/internal/fibe" -) - -func (s *Server) startProductionProjectIfStopped(ctx context.Context, userID, projectID string) { - project, err := s.store.ProjectForUser(ctx, userID, projectID) - if err != nil { - log.Printf("load production project for start %s/%s: %v", userID, projectID, err) - return - } - if project.Status != "stopped" { - return - } - playgroundID := strings.TrimSpace(project.PlaygroundID) - if playgroundID == "" { - return - } - user, err := s.store.UserByID(ctx, userID) - if err != nil { - log.Printf("load production user for start %s: %v", userID, err) - return - } - if strings.TrimSpace(project.PreviewURL) != "" { - probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - updated, ready, _, _, err := s.promoteProjectFromReachablePreview(probeCtx, user.ID, project) - cancel() - if err == nil && ready && updated != nil { - if err := s.store.TouchProjectPlaygroundUsage(ctx, project.ID, user.ID); err != nil { - log.Printf("touch already-running production project usage project=%s playground=%s: %v", project.ID, playgroundID, err) - return - } - s.clearPlatformBackoff() - return - } - } - fibeClient, err := s.fibeClientForProject(ctx, project, user.Email) - if err != nil { - log.Printf("load Fibe client for production start project=%s playground=%s: %v", project.ID, playgroundID, err) - return - } - actionCtx, cancel := context.WithTimeout(ctx, 45*time.Second) - defer cancel() - if err := fibeClient.StartPlayground(actionCtx, playgroundID); err != nil { - s.observePlatformError(err) - if fibegateway.IsRuntimeBillingRequiredError(err) { - log.Printf("start production project blocked by Fibe runtime billing project=%s playground=%s: %v", project.ID, playgroundID, err) - s.notifyProductionProjectStartBlocked(ctx, user, project) - return - } - log.Printf("start production project playground project=%s playground=%s: %v", project.ID, playgroundID, err) - return - } - if err := s.store.UpdateProjectStatus(ctx, project.ID, user.ID, "launching"); err != nil { - log.Printf("mark production project launching project=%s playground=%s: %v", project.ID, playgroundID, err) - return - } - if err := s.store.TouchProjectPlaygroundUsage(ctx, project.ID, user.ID); err != nil { - log.Printf("touch production project usage project=%s playground=%s: %v", project.ID, playgroundID, err) - return - } - s.clearPlatformBackoff() -} diff --git a/internal/likeable/project_domain_verification.go b/internal/likeable/project_domain_verification.go deleted file mode 100644 index 08e5d25..0000000 --- a/internal/likeable/project_domain_verification.go +++ /dev/null @@ -1,120 +0,0 @@ -package likeable - -import ( - "context" - "errors" - "net" - "strings" - - "github.com/fibegg/likeable/internal/store" -) - -type customDomainResolver interface { - LookupCNAME(ctx context.Context, host string) (string, error) -} - -func (s *Server) customDomainResolver() customDomainResolver { - if s.domainDNS != nil { - return s.domainDNS - } - return net.DefaultResolver -} - -func (s *Server) projectCustomDomainDNSStatus(ctx context.Context, domain, target string) (string, error) { - domain = normalizeDNSHost(domain) - target = normalizeDNSHost(target) - if domain == "" || target == "" { - return store.ProjectDomainStatusPendingDNS, nil - } - cname, err := s.customDomainResolver().LookupCNAME(ctx, domain) - if err != nil { - var dnsErr *net.DNSError - if errors.As(err, &dnsErr) && dnsErr.IsNotFound { - return store.ProjectDomainStatusPendingDNS, nil - } - return "", err - } - if normalizeDNSHost(cname) == target { - return store.ProjectDomainStatusDNSVerified, nil - } - return store.ProjectDomainStatusPendingDNS, nil -} - -func (s *Server) verifyProjectCustomDomain(ctx context.Context, project *Project) (*Project, error) { - if project == nil || strings.TrimSpace(project.CustomDomain) == "" { - return project, nil - } - status, err := s.projectCustomDomainDNSStatus(ctx, project.CustomDomain, project.CustomDomainTarget) - if err != nil { - return nil, err - } - if status == store.ProjectDomainStatusDNSVerified && strings.TrimSpace(project.PlaygroundID) != "" { - if err := s.syncProjectCustomDomainRouting(ctx, project, project.CustomDomain); err != nil { - return nil, err - } - status = store.ProjectDomainStatusActive - } - if status != project.CustomDomainStatus { - if err := s.store.UpdateProjectDomainStatus(ctx, project.UserID, project.ID, status); err != nil { - return nil, err - } - return s.store.ProjectForUser(ctx, project.UserID, project.ID) - } - return project, nil -} - -func (s *Server) syncProjectCustomDomainRouting(ctx context.Context, project *Project, domain string) error { - if project == nil || strings.TrimSpace(project.PlaygroundID) == "" { - return nil - } - serviceHosts := projectCustomDomainServiceHosts(project, domain) - if len(serviceHosts) == 0 { - return nil - } - client, err := s.fibeClientForProject(ctx, project, "") - if err != nil { - return err - } - if err := client.UpdatePlaygroundServiceCustomHosts(ctx, project.PlaygroundID, serviceHosts); err != nil { - return err - } - return client.RolloutPlayground(ctx, project.PlaygroundID) -} - -func projectCustomDomainRoutingSynced(project *Project) bool { - return project != nil && - strings.TrimSpace(project.CustomDomain) != "" && - strings.TrimSpace(project.CustomDomainStatus) == store.ProjectDomainStatusActive -} - -func projectCustomDomainServiceHosts(project *Project, domain string) map[string][]string { - if project == nil { - return nil - } - domain = strings.Trim(strings.ToLower(strings.TrimSpace(domain)), ".") - selected := projectCustomDomainServiceName(project) - out := map[string][]string{} - for _, service := range project.Services { - name := strings.TrimSpace(service.Name) - if name == "" { - continue - } - if name == selected && domain != "" { - out[name] = []string{domain} - } else { - out[name] = []string{} - } - } - if len(out) == 0 && selected != "" { - if domain != "" { - out[selected] = []string{domain} - } else { - out[selected] = []string{} - } - } - return out -} - -func normalizeDNSHost(value string) string { - return strings.Trim(strings.ToLower(strings.TrimSpace(value)), ".") -} diff --git a/internal/likeable/project_handlers.go b/internal/likeable/project_handlers.go index 43b1273..52f4ca1 100644 --- a/internal/likeable/project_handlers.go +++ b/internal/likeable/project_handlers.go @@ -7,9 +7,7 @@ import ( "errors" "fmt" "log" - "net" "net/http" - "net/url" "strings" "time" @@ -416,126 +414,6 @@ func (s *Server) handleProjectDomainVerify(w http.ResponseWriter, r *http.Reques writeError(w, http.StatusGone, "custom domains are managed in Fibe") } -func normalizeProjectCustomDomain(value string) (string, error) { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "" { - return "", errors.New("custom domain is required") - } - if strings.Contains(value, "://") { - parsed, err := url.Parse(value) - if err != nil || parsed.Hostname() == "" { - return "", errors.New("custom domain must be a valid hostname") - } - if parsed.User != nil || parsed.Port() != "" || parsed.RawQuery != "" || parsed.Fragment != "" { - return "", errors.New("custom domain must be a hostname without user info, path, port, query, or fragment") - } - if parsed.Path != "" && parsed.Path != "/" { - return "", errors.New("custom domain must not include a path") - } - value = parsed.Hostname() - } - value = strings.Trim(value, ".") - if strings.ContainsAny(value, "/:?#[]@") || strings.Contains(value, "*") { - return "", errors.New("custom domain must be a hostname without path, port, or wildcard") - } - if len(value) > 253 || !strings.Contains(value, ".") { - return "", errors.New("custom domain must be a fully qualified hostname") - } - labels := strings.Split(value, ".") - for _, label := range labels { - if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { - return "", errors.New("custom domain contains an invalid label") - } - for _, r := range label { - if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { - return "", errors.New("custom domain must use DNS-safe ASCII labels") - } - } - } - if allDigits(labels[len(labels)-1]) { - return "", errors.New("custom domain top-level label must not be numeric") - } - return value, nil -} - -func allDigits(value string) bool { - if value == "" { - return false - } - for _, r := range value { - if r < '0' || r > '9' { - return false - } - } - return true -} - -func projectCustomDomainTarget(project *Project) string { - if project == nil { - return "" - } - selected := strings.TrimSpace(project.SelectedService) - for _, service := range project.Services { - if selected != "" && service.Name != selected { - continue - } - if target := projectURLHost(service.URL); target != "" { - return target - } - } - if target := projectURLHost(project.PreviewURL); target != "" { - return target - } - for _, service := range project.Services { - if target := projectURLHost(service.URL); target != "" { - return target - } - } - return "" -} - -func projectCustomDomainServiceName(project *Project) string { - if project == nil { - return "" - } - selected := strings.TrimSpace(project.SelectedService) - if selected != "" { - for _, service := range project.Services { - if service.Name == selected && projectURLHost(service.URL) != "" { - return selected - } - } - if project.PreviewURL != "" { - return selected - } - } - for _, service := range project.Services { - if strings.TrimSpace(service.Name) != "" && projectURLHost(service.URL) != "" { - return strings.TrimSpace(service.Name) - } - } - if project.PreviewURL != "" { - return firstNonEmpty(selected, "app") - } - return selected -} - -func projectURLHost(rawURL string) string { - rawURL = strings.TrimSpace(rawURL) - if rawURL == "" { - return "" - } - if parsed, err := url.Parse(rawURL); err == nil && parsed.Host != "" { - return parsed.Hostname() - } - rawURL = strings.TrimPrefix(strings.TrimPrefix(rawURL, "https://"), "http://") - host := strings.Split(rawURL, "/")[0] - if withoutPort, _, err := net.SplitHostPort(host); err == nil && withoutPort != "" { - return strings.Trim(withoutPort, "[]") - } - return host -} - func (s *Server) handleProjectFeed(w http.ResponseWriter, r *http.Request, user *User, project *Project) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") diff --git a/internal/likeable/server.go b/internal/likeable/server.go index 9542f68..01c8f3c 100644 --- a/internal/likeable/server.go +++ b/internal/likeable/server.go @@ -29,7 +29,6 @@ type Server struct { cleanupSlots chan struct{} email emailSender jobs *JobSystem - domainDNS customDomainResolver limiter *RateLimiter limiterOnce sync.Once platform platformBackoffState diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index 97b10e5..f69ff70 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -4178,30 +4178,6 @@ func TestProjectCustomDomainEndpointsAreDisabled(t *testing.T) { } } -func TestProjectCustomDomainRejectsInvalidHostnames(t *testing.T) { - for _, raw := range []string{ - "", - "https://app.example.com/path", - "https://app.example.com:8443/", - "https://app.example.com/?preview=true", - "https://user@app.example.com/", - "*.example.com", - "localhost", - "app.example.123", - "арр.example.com", - "-app.example.com", - "app-.example.com", - "app.example.com:8443", - "app..example.com", - } { - t.Run(raw, func(t *testing.T) { - if domain, err := normalizeProjectCustomDomain(raw); err == nil { - t.Fatalf("domain=%q, want validation error for %q", domain, raw) - } - }) - } -} - func TestProjectPassiveActionsDoNotTouchPlaygroundUsage(t *testing.T) { store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { @@ -6600,9 +6576,6 @@ func TestAdminProjectDiagnosticsExposeSupportContext(t *testing.T) { if err := appStore.ReplaceProjectResources(t.Context(), project.ID, []store.ProjectRepository{{Role: "source", SourceRepoURL: "https://github.test/source/repo", Provider: "github"}}, []store.ProjectService{{Name: "app", URL: "https://app.test", Type: "dynamic", Visibility: "external"}}); err != nil { t.Fatal(err) } - if err := appStore.UpsertProjectDomain(t.Context(), user.ID, project.ID, "app.customer.example", "app.test"); err != nil { - t.Fatal(err) - } if err := appStore.UpsertPayment(t.Context(), store.Payment{ID: "payment-diag", UserID: user.ID, ProviderPaymentID: "cs_diag", AmountCents: 2000, Currency: "usd", Status: "paid", CreatedAt: "2026-05-27T10:00:00Z"}); err != nil { t.Fatal(err) } @@ -6625,10 +6598,16 @@ func TestAdminProjectDiagnosticsExposeSupportContext(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("diagnostics returned %d: %s", rec.Code, rec.Body.String()) } + bodyBytes := rec.Body.Bytes() + for _, hidden := range []string{"productionExpiresAt", "productionRuntimeStatus", "customDomain", "customDomainStatus", "customDomainTarget"} { + if bytes.Contains(bodyBytes, []byte(hidden)) { + t.Fatalf("diagnostics response includes hidden legacy field %q: %s", hidden, string(bodyBytes)) + } + } var resp struct { Diagnostics AdminProjectDiagnostics `json:"diagnostics"` } - if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + if err := json.Unmarshal(bodyBytes, &resp); err != nil { t.Fatal(err) } if resp.Diagnostics.Internal.ConversationID != "conv-diagnostics" || resp.Diagnostics.Internal.PlaygroundID != "playground-diag" || resp.Diagnostics.Internal.RepoURL != "https://gitea.test/owner/repo.git" { @@ -6637,9 +6616,6 @@ func TestAdminProjectDiagnosticsExposeSupportContext(t *testing.T) { if len(resp.Diagnostics.Project.Services) != 1 || resp.Diagnostics.Project.Services[0].URL != "https://app.test" { t.Fatalf("services=%+v, want attached service", resp.Diagnostics.Project.Services) } - if resp.Diagnostics.Project.CustomDomain != "app.customer.example" || resp.Diagnostics.Project.CustomDomainTarget != "app.test" { - t.Fatalf("project domain=%+v, want custom domain diagnostics", resp.Diagnostics.Project) - } if len(resp.Diagnostics.WorkSessions) != 1 || resp.Diagnostics.WorkSessions[0].SessionKey != "turn-diag" { t.Fatalf("work sessions=%+v, want turn", resp.Diagnostics.WorkSessions) } @@ -6710,67 +6686,6 @@ func TestAdminProductionGrantIsDisabled(t *testing.T) { } } -func TestProductionProjectStartBlockedByRuntimeBillingNotifiesUser(t *testing.T) { - appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) - if err != nil { - t.Fatal(err) - } - defer appStore.Close() - if err := appStore.UpsertConfig(t.Context(), map[string]string{ - "fibe_base_url": "server.test:3000", - "fibe_api_key": "test-key", - }, secretConfigKeys); err != nil { - t.Fatal(err) - } - user, err := appStore.UpsertUser(t.Context(), "runtime-billing@example.com", "Runtime Billing", "") - if err != nil { - t.Fatal(err) - } - project := &Project{ - ID: "project-runtime-billing", - UserID: user.ID, - Title: "Runtime billing", - ConversationID: "conv-runtime-billing", - AgentID: "agent-1", - MarqueeID: "server-1", - PlaygroundID: "playground-runtime-billing", - Status: "stopped", - } - if err := appStore.CreateProject(t.Context(), project); err != nil { - t.Fatal(err) - } - if granted, err := appStore.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_runtime_billing", time.Now().UTC().Add(7*24*time.Hour)); err != nil || !granted { - t.Fatalf("production grant=%v err=%v, want granted", granted, err) - } - logPath := filepath.Join(t.TempDir(), "fibe.log") - server := &Server{ - store: appStore, - config: RuntimeConfig{BaseURL: "http://example.test"}, - http: fakeFibeHTTPClient(http.DefaultClient, fakeFibeTransportConfig{Mode: "runtime-billing-required", LogPath: logPath}), - } - - server.startProductionProjectIfStopped(t.Context(), user.ID, project.ID) - server.startProductionProjectIfStopped(t.Context(), user.ID, project.ID) - - stored, err := appStore.ProjectForUser(t.Context(), user.ID, project.ID) - if err != nil { - t.Fatal(err) - } - if stored.Status != "stopped" { - t.Fatalf("status=%q, want stopped after blocked start", stored.Status) - } - notices, err := appStore.NoticesForUser(t.Context(), user.ID, 10) - if err != nil { - t.Fatal(err) - } - if len(notices) != 1 || notices[0].Severity != "warning" || !strings.Contains(notices[0].Body, "Production runtime paused") || !strings.Contains(notices[0].Body, "not funded") { - t.Fatalf("notices=%+v, want one runtime billing warning", notices) - } - if log := readFile(t, logPath); strings.Count(log, "playgrounds start playground-runtime-billing") != 2 { - t.Fatalf("log=%s, want two start attempts", log) - } -} - func TestAdminProductionProjectStartRetryIsDisabled(t *testing.T) { appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db")) if err != nil { diff --git a/internal/store/store_projects.go b/internal/store/store_projects.go index 8905627..abc31f5 100644 --- a/internal/store/store_projects.go +++ b/internal/store/store_projects.go @@ -5,8 +5,6 @@ import ( "database/sql" "encoding/json" "errors" - "net" - "net/url" "strings" "time" @@ -292,80 +290,6 @@ func (s *Store) UpdateProjectSelectedService(ctx context.Context, projectID, use return nil } -func (s *Store) UpsertProjectDomain(ctx context.Context, userID, projectID, domain, target string) error { - domain = strings.TrimSpace(strings.ToLower(domain)) - target = strings.TrimSpace(target) - now := nowString() - result, err := s.db.ExecContext(ctx, ` - INSERT INTO project_domains(project_id, user_id, domain, target, status, created_at, updated_at) - VALUES(?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(project_id) DO UPDATE SET domain = excluded.domain, target = excluded.target, status = excluded.status, updated_at = excluded.updated_at - `, projectID, userID, domain, target, ProjectDomainStatusPendingDNS, now, now) - if err != nil { - return err - } - rows, _ := result.RowsAffected() - if rows == 0 { - return sql.ErrNoRows - } - return nil -} - -func (s *Store) UpdateProjectDomainStatus(ctx context.Context, userID, projectID, status string) error { - status = strings.TrimSpace(status) - if status == "" { - return errors.New("project domain status is required") - } - now := nowString() - result, err := s.db.ExecContext(ctx, ` - UPDATE project_domains - SET status = ?, updated_at = ? - WHERE project_id = ? AND user_id = ? - `, status, now, projectID, userID) - if err != nil { - return err - } - rows, _ := result.RowsAffected() - if rows == 0 { - return sql.ErrNoRows - } - return nil -} - -func (s *Store) PendingProjectDomains(ctx context.Context, limit int) ([]ProjectDomain, error) { - if limit <= 0 || limit > 500 { - limit = 100 - } - rows, err := s.db.QueryContext(ctx, ` - SELECT project_id, user_id, domain, target, status, updated_at - FROM project_domains - WHERE status = ? - ORDER BY updated_at ASC - LIMIT ? - `, ProjectDomainStatusPendingDNS, limit) - if err != nil { - return nil, err - } - defer rows.Close() - out := []ProjectDomain{} - for rows.Next() { - var projectDomain ProjectDomain - if err := rows.Scan(&projectDomain.ProjectID, &projectDomain.UserID, &projectDomain.Domain, &projectDomain.Target, &projectDomain.Status, &projectDomain.UpdatedAt); err != nil { - return nil, err - } - out = append(out, projectDomain) - } - return out, rows.Err() -} - -func (s *Store) DeleteProjectDomain(ctx context.Context, userID, projectID string) error { - _, err := s.db.ExecContext(ctx, ` - DELETE FROM project_domains - WHERE project_id = ? AND user_id = ? - `, projectID, userID) - return err -} - func (s *Store) TouchProjectPlaygroundUsage(ctx context.Context, projectID, userID string) error { now := nowString() result, err := s.db.ExecContext(ctx, ` @@ -556,51 +480,6 @@ func (s *Store) DeletingProjects(ctx context.Context, limit int) ([]Project, err return out, nil } -func (s *Store) StoppedProductionProjects(ctx context.Context, limit int) ([]Project, error) { - if limit <= 0 || limit > 1000 { - limit = 100 - } - rows, err := s.db.QueryContext(ctx, ` - SELECT id, user_id, title, conversation_id, agent_id, marquee_id, playground_id, playground_name, playspec_id, prop_id, repo_url, preview_url, selected_service_name, status, error_message, provisioning_lock_until, cleanup_last_error, playground_last_used_at, created_at, updated_at - FROM projects - WHERE status = 'stopped' AND TRIM(playground_id) != '' - AND EXISTS ( - SELECT 1 - FROM project_production_grants - WHERE project_production_grants.project_id = projects.id - AND project_production_grants.expires_at > ? - ) - ORDER BY updated_at ASC - LIMIT ? - `, nowString(), limit) - if err != nil { - return nil, err - } - var out []Project - for rows.Next() { - project, err := scanProject(rows) - if err != nil { - _ = rows.Close() - return nil, err - } - out = append(out, *project) - } - if err := rows.Err(); err != nil { - _ = rows.Close() - return nil, err - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := s.attachProjectResourcesForProjects(ctx, out); err != nil { - return nil, err - } - if out == nil { - out = []Project{} - } - return out, nil -} - func (s *Store) IdleProjectsForPlaygroundStop(ctx context.Context, cutoff time.Time, limit int) ([]Project, error) { if limit <= 0 || limit > 1000 { limit = 100 @@ -761,36 +640,10 @@ func (s *Store) attachProjectResources(ctx context.Context, project *Project) er return err } project.ProductionExpiresAt = expiresAt - if err := s.attachProjectDomain(ctx, project); err != nil { - return err - } project.RefreshComputedFields() return nil } -func (s *Store) attachProjectDomain(ctx context.Context, project *Project) error { - row := s.db.QueryRowContext(ctx, ` - SELECT domain, target, status, updated_at - FROM project_domains - WHERE project_id = ? AND user_id = ? - `, project.ID, project.UserID) - var domain, target, status, updatedAt string - if err := row.Scan(&domain, &target, &status, &updatedAt); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil - } - return err - } - project.CustomDomain = domain - project.CustomDomainStatus = status - project.CustomDomainTarget = projectDomainTarget(project) - if project.CustomDomainTarget == "" { - project.CustomDomainTarget = target - } - project.CustomDomainUpdatedAt = updatedAt - return nil -} - func (s *Store) attachProjectResourcesForProjects(ctx context.Context, projects []Project) error { for i := range projects { if err := s.attachProjectResources(ctx, &projects[i]); err != nil { @@ -800,47 +653,6 @@ func (s *Store) attachProjectResourcesForProjects(ctx context.Context, projects return nil } -func projectDomainTarget(project *Project) string { - if project == nil { - return "" - } - selected := strings.TrimSpace(project.SelectedService) - for _, service := range project.Services { - if selected != "" && service.Name != selected { - continue - } - if target := urlHost(service.URL); target != "" { - return target - } - } - if target := urlHost(project.PreviewURL); target != "" { - return target - } - for _, service := range project.Services { - if target := urlHost(service.URL); target != "" { - return target - } - } - return "" -} - -func urlHost(rawURL string) string { - rawURL = strings.TrimSpace(rawURL) - if rawURL == "" { - return "" - } - parsed, err := url.Parse(rawURL) - if err == nil && parsed.Host != "" { - return parsed.Hostname() - } - rawURL = strings.TrimPrefix(strings.TrimPrefix(rawURL, "https://"), "http://") - host := strings.Split(rawURL, "/")[0] - if withoutPort, _, err := net.SplitHostPort(host); err == nil && withoutPort != "" { - return strings.Trim(withoutPort, "[]") - } - return host -} - func (s *Store) ProjectRepositories(ctx context.Context, projectID string) ([]ProjectRepository, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, project_id, role, prop_id, repo_url, source_repo_url, provider, service_names, created_at diff --git a/internal/store/types.go b/internal/store/types.go index 61277a6..ad72bba 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -4,7 +4,6 @@ import "github.com/fibegg/likeable/internal/domain" type User = domain.User type Project = domain.Project -type ProjectDomain = domain.ProjectDomain type ProjectRepository = domain.ProjectRepository type ProjectService = domain.ProjectService type Message = domain.Message @@ -27,9 +26,3 @@ type AdminProjectInternal = domain.AdminProjectInternal type AdminProjectDiagnostics = domain.AdminProjectDiagnostics type AdminUserDetail = domain.AdminUserDetail type AdminUserFilters = domain.AdminUserFilters - -const ( - ProjectDomainStatusPendingDNS = domain.ProjectDomainStatusPendingDNS - ProjectDomainStatusDNSVerified = domain.ProjectDomainStatusDNSVerified - ProjectDomainStatusActive = domain.ProjectDomainStatusActive -) From 079ff92558565d85eef2efc0185eb58c18a95104 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Thu, 28 May 2026 20:42:20 +0300 Subject: [PATCH 35/36] Clarify Likeable playground scope --- frontend/src/builder_components.tsx | 19 +- frontend/src/i18n/translations.ts | 18 +- frontend/src/profile_panel.tsx | 10 + frontend/src/styles.css | 274 +++++++++++++++++++++++++++- 4 files changed, 314 insertions(+), 7 deletions(-) diff --git a/frontend/src/builder_components.tsx b/frontend/src/builder_components.tsx index 3f7ab41..8b02093 100644 --- a/frontend/src/builder_components.tsx +++ b/frontend/src/builder_components.tsx @@ -524,7 +524,7 @@ export function EmptyCanvas({ title, body }: { title?: string; body?: string } = ); } -export function CanvasLoader({ title, body, tone }: { title: string; body: string; tone?: 'error' }) { +export function CanvasLoader({ title, body, tone }: { title: string; body?: string; tone?: 'error' }) { return (
@@ -536,7 +536,7 @@ export function CanvasLoader({ title, body, tone }: { title: string; body: strin

{title}

-

{body}

+ {body &&

{body}

}
); @@ -545,6 +545,21 @@ export function CanvasLoader({ title, body, tone }: { title: string; body: strin function CanvasFrameDecor({ loading }: { loading?: boolean } = {}) { return ( <> + {loading && ( + + )}
+
+
+ {t('profile.scope')} + {t('profile.scopeTitle')} + {t('profile.scopeBody')} +
+ + {t('profile.openFibe')} + +
{t('profile.hours')} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a0aa0f6..c51cb2d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -3165,6 +3165,17 @@ html.draggingCollapsedChat * { 100% { opacity: .12; transform: scaleX(.36); } } +@keyframes canvasSkeletonSweep { + 0% { opacity: .08; transform: translateX(-115%); } + 42% { opacity: .75; } + 100% { opacity: .08; transform: translateX(115%); } +} + +@keyframes canvasSkeletonWash { + 0%, 100% { opacity: .42; transform: translateX(-18%); } + 50% { opacity: .78; transform: translateX(18%); } +} + @keyframes miniSlideIn { from { opacity: 0; @@ -3199,6 +3210,9 @@ html.draggingCollapsedChat * { .minimizedChatBar.working, .canvasSignalField.loading span, .loaderTelemetry span, + .canvasSkeletonScene::before, + .canvasSkeletonTabs span::after, + .canvasSkeletonBlock::after, .composer.improving::before, .composer.improving::after { animation: none; @@ -8223,6 +8237,123 @@ body { opacity: .54; } +.canvasSkeletonScene { + position: absolute; + inset: 18px 28px; + z-index: 0; + pointer-events: none; + opacity: .8; + filter: saturate(1.06); +} + +.canvasSkeletonScene::before { + content: ""; + position: absolute; + inset: -22px -18px; + background: + radial-gradient(circle at 18% 12%, rgba(94, 212, 168, .12), transparent 26%), + radial-gradient(circle at 80% 16%, rgba(232, 181, 111, .1), transparent 24%), + linear-gradient(100deg, transparent 0 38%, rgba(174, 245, 255, .08) 48%, transparent 58% 100%); + opacity: .72; + transform: translateX(-18%); + animation: canvasSkeletonWash 3.6s ease-in-out infinite; +} + +.canvasSkeletonTabs { + position: absolute; + left: clamp(10px, 3vw, 54px); + top: 0; + display: flex; + gap: clamp(14px, 2vw, 28px); +} + +.canvasSkeletonTabs span, +.canvasSkeletonBlock { + position: absolute; + overflow: hidden; + border: 1px solid rgba(174, 245, 255, .055); + border-radius: 9px; + background: + linear-gradient(90deg, rgba(94, 212, 168, .055), transparent 22%), + linear-gradient(135deg, rgba(29, 42, 55, .9), rgba(20, 30, 42, .76)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, .035), + 0 18px 54px rgba(0, 0, 0, .2); +} + +.canvasSkeletonTabs span { + position: relative; + width: clamp(74px, 8vw, 128px); + height: 26px; + border-radius: 7px; + opacity: .72; +} + +.canvasSkeletonTabs span:nth-child(2) { width: clamp(86px, 9vw, 148px); } +.canvasSkeletonTabs span:nth-child(3) { width: clamp(70px, 7vw, 116px); } + +.canvasSkeletonTabs span::after, +.canvasSkeletonBlock::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(105deg, transparent 0 36%, rgba(255, 255, 255, .09) 48%, transparent 60% 100%); + transform: translateX(-110%); + animation: canvasSkeletonSweep 2.4s ease-in-out infinite; +} + +.canvasSkeletonBlock.hero { + left: clamp(14px, 4vw, 78px); + top: clamp(38px, 5vh, 58px); + width: min(54vw, 760px); + height: min(29vh, 260px); + min-height: 176px; + border-top-left-radius: 14px; + background: + linear-gradient(90deg, rgba(232, 181, 111, .07), transparent 24%), + linear-gradient(135deg, rgba(27, 38, 49, .96), rgba(20, 30, 42, .82)); +} + +.canvasSkeletonBlock.side { + right: clamp(18px, 5vw, 108px); + top: clamp(28px, 4.2vh, 46px); + width: min(27vw, 390px); + height: min(20vh, 178px); +} + +.canvasSkeletonBlock.railA { + left: clamp(14px, 4vw, 78px); + top: calc(clamp(38px, 5vh, 58px) + min(29vh, 260px) + 18px); + width: min(28vw, 420px); + height: min(20vh, 180px); +} + +.canvasSkeletonBlock.main { + right: clamp(40px, 7vw, 148px); + top: calc(clamp(38px, 5vh, 58px) + min(29vh, 260px) + 18px); + width: min(54vw, 760px); + height: min(26vh, 250px); + min-height: 180px; + background: + linear-gradient(90deg, rgba(94, 212, 168, .07), transparent 26%), + linear-gradient(135deg, rgba(30, 42, 54, .94), rgba(20, 30, 42, .82)); +} + +.canvasSkeletonBlock.railB { + left: clamp(14px, 4vw, 78px); + bottom: clamp(86px, 13vh, 140px); + width: min(28vw, 420px); + height: min(19vh, 178px); +} + +.canvasSkeletonBlock.footer { + right: clamp(42px, 7vw, 150px); + bottom: clamp(78px, 12vh, 130px); + width: min(36vw, 560px); + height: min(12vh, 112px); + opacity: .56; +} + .loaderRing { width: 84px; height: 84px; @@ -8387,6 +8518,70 @@ body { transform: scale(.78); } + .canvasSkeletonScene { + inset: 14px 14px; + opacity: .66; + } + + .canvasSkeletonTabs { + left: 12px; + gap: 10px; + } + + .canvasSkeletonTabs span { + width: clamp(56px, 14vw, 92px); + height: 22px; + } + + .canvasSkeletonTabs span:nth-child(2), + .canvasSkeletonTabs span:nth-child(3) { + width: clamp(62px, 15vw, 102px); + } + + .canvasSkeletonBlock.hero { + left: 14px; + top: 44px; + width: min(64vw, 520px); + height: 22vh; + min-height: 138px; + } + + .canvasSkeletonBlock.side { + right: 14px; + top: 44px; + width: 28vw; + height: 17vh; + } + + .canvasSkeletonBlock.railA { + left: 14px; + top: calc(44px + max(22vh, 138px) + 14px); + width: 34vw; + height: 17vh; + } + + .canvasSkeletonBlock.main { + right: 14px; + top: calc(44px + max(22vh, 138px) + 14px); + width: 56vw; + height: 20vh; + min-height: 132px; + } + + .canvasSkeletonBlock.railB { + left: 14px; + bottom: 86px; + width: 38vw; + height: 15vh; + } + + .canvasSkeletonBlock.footer { + right: 14px; + bottom: 84px; + width: 42vw; + height: 10vh; + } + .emptyCopy { width: min(620px, calc(100vw - 28px)); padding: 0 12px; @@ -8408,6 +8603,65 @@ body { width: min(220px, 68vw); } + .canvasSkeletonScene { + inset: 10px; + opacity: .56; + } + + .canvasSkeletonTabs { + left: 8px; + gap: 7px; + } + + .canvasSkeletonTabs span { + width: 54px; + height: 18px; + } + + .canvasSkeletonTabs span:nth-child(2) { + width: 66px; + } + + .canvasSkeletonTabs span:nth-child(3) { + display: none; + } + + .canvasSkeletonBlock.hero { + left: 8px; + top: 36px; + width: calc(100% - 16px); + height: 22vh; + min-height: 128px; + } + + .canvasSkeletonBlock.side, + .canvasSkeletonBlock.footer { + display: none; + } + + .canvasSkeletonBlock.railA { + left: 8px; + top: calc(36px + max(22vh, 128px) + 12px); + width: calc(46% - 10px); + height: 15vh; + } + + .canvasSkeletonBlock.main { + right: 8px; + top: calc(36px + max(22vh, 128px) + 12px); + width: calc(54% - 10px); + height: 19vh; + min-height: 120px; + } + + .canvasSkeletonBlock.railB { + left: 8px; + right: 8px; + bottom: 72px; + width: auto; + height: 12vh; + } + .emptyCopy h1, .canvasLoader .emptyCopy h1 { font-size: 28px; @@ -8496,6 +8750,7 @@ body { .profileHoursCard, .profileSlotsCard, +.profileScopeCard, .profileDangerCard { grid-column: 1 / -1; } @@ -8509,6 +8764,21 @@ body { min-height: 132px; } +.profileScopeCard { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + min-height: 104px; + align-items: center; + border-color: rgba(94, 212, 168, .18); + background: + linear-gradient(135deg, rgba(94, 212, 168, .075), rgba(232, 181, 111, .035) 48%, transparent), + rgba(7, 17, 18, .76); +} + +.profileScopeCard::before { + background: linear-gradient(180deg, rgba(94, 212, 168, .7), rgba(232, 181, 111, .28)); +} + .profileDangerCard { border-color: rgba(255, 122, 122, .24); background: @@ -8774,12 +9044,14 @@ body { .profileActionCard, .profileHoursCard, .profileSlotsCard, + .profileScopeCard, .profileDangerCard { grid-column: 1; } .profileHoursCard, - .profileSlotsCard { + .profileSlotsCard, + .profileScopeCard { grid-template-columns: 1fr; } From 93a74f6ccb5d5dd9498e228e617ca100f3b7e874 Mon Sep 17 00:00:00 2001 From: Valentyn Yakymenko Date: Thu, 28 May 2026 21:01:49 +0300 Subject: [PATCH 36/36] Ignore legacy production Stripe purchases --- internal/likeable/server_test.go | 5 +++++ internal/likeable/stripe.go | 16 ++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go index f69ff70..9744136 100644 --- a/internal/likeable/server_test.go +++ b/internal/likeable/server_test.go @@ -7315,6 +7315,8 @@ func TestStripeWebhookIgnoresLegacyProductionProjectPurchase(t *testing.T) { "data": map[string]any{"object": map[string]any{ "id": "cs_production_project", "client_reference_id": user.ID, + "customer": "cus_production_project", + "subscription": "sub_production_project", "amount_total": 2900, "currency": "usd", "payment_status": "paid", @@ -7350,6 +7352,9 @@ func TestStripeWebhookIgnoresLegacyProductionProjectPurchase(t *testing.T) { if len(payments) != 1 || payments[0].ProviderPaymentID != "cs_production_project" { t.Fatalf("payments=%+v, want legacy payment recorded without grant", payments) } + if sub, err := store.SubscriptionForUser(t.Context(), user.ID); !errors.Is(err, sql.ErrNoRows) || sub != nil { + t.Fatalf("subscription=%+v err=%v, want no subscription for legacy production purchase", sub, err) + } notices, err := store.NoticesForUser(t.Context(), user.ID, 10) if err != nil { t.Fatal(err) diff --git a/internal/likeable/stripe.go b/internal/likeable/stripe.go index 11b87bd..f950465 100644 --- a/internal/likeable/stripe.go +++ b/internal/likeable/stripe.go @@ -336,6 +336,9 @@ func (s *Server) applyStripeCheckoutSession(ctx context.Context, cfg map[string] Status: "paid", }) } + if stripeLegacyProductionProjectPurchase(session["metadata"]) { + return result, nil + } packHours := stripePackHours(session["metadata"]) if packHours > 0 { result.PurchaseKind = "hour_pack" @@ -371,9 +374,6 @@ func (s *Server) applyStripeCheckoutSession(ctx context.Context, cfg map[string] } } } - if stripeProductionProjectID(session["metadata"]) != "" { - result.PurchaseKind = "production_project" - } if subscriptionID != "" && subscriptionID != "" { result.Applied = true _ = s.store.UpsertSubscription(ctx, Subscription{ @@ -477,14 +477,18 @@ func stripeProjectQuotaDays(metadata any, fallback int) int { return n } -func stripeProductionProjectID(metadata any) string { +func stripeLegacyProductionProjectPurchase(metadata any) bool { if m, ok := metadata.(map[string]any); ok { + kind := strings.ToLower(strings.TrimSpace(fmt.Sprint(m["purchase_kind"]))) + if kind == "production_project" || kind == "production-project" || kind == "production" { + return true + } raw := strings.TrimSpace(fmt.Sprint(m["project_id"])) if raw != "" && raw != "" { - return raw + return true } } - return "" + return false } func expectedStripeHourPackPrice(cfg map[string]string, pack int) string {