mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 01:53:33 +00:00
0fd5d0cdfb
* fix(store): swap plainto_tsquery → websearch_to_tsquery for PG FTS (BUG-842)
`TestListItems_FTS_HyphenatedSearchTerm/task-five` has been failing on
every Go (PostgreSQL) CI run because `plainto_tsquery('english',
'task-five')` doesn't match the asciihword lexeme(s) the english parser
produces for an indexed `task-five-distinctive`. The result is that
every PG full-text search for hyphenated terms returns zero rows.
`websearch_to_tsquery` (Postgres 11+) is purpose-built for arbitrary
user input and tokenizes hyphenated terms the same way `to_tsvector`
does for the indexed document, so the query intersects the index
correctly. Swapped in three spots in the postgres dialect — FTSMatch,
FTSSnippet, FTSRank — and updated the caller-side comments that
referenced plainto_tsquery. SQLite path is unchanged: it goes through
items_fts MATCH with sanitizeFTSQuery, never through these methods.
* fix(server): drain background goroutines on Stop() (BUG-842)
`TestAdminBillingStats_SidecarSidecarError_DegradesToLocalOnly` (and
other server tests) have been flaking on the Go (PostgreSQL) CI runner
with `TempDir RemoveAll cleanup: directory not empty`. Root cause:
several request handlers spawned bare `go func() { ... }()` goroutines
that touched the SQLite WAL DB after the test function returned.
testServer's t.Cleanup closed the store but had no way to drain those
goroutines first, so a fire-and-forget WAL write could re-create the
`-wal`/`-shm` files between Close() and t.TempDir's RemoveAll.
Add a Server.bg sync.WaitGroup, a Server.goAsync helper that wraps a
WaitGroup-tracked goroutine, and a Server.Stop() that blocks until
every goAsync closure has finished. Convert the four known
fire-and-forget sites to goAsync:
- middleware_auth.go (TouchUserActivity)
- handlers_auth.go (password reset email)
- handlers_cloud.go (stripe_processed_events pruning)
- handlers_members.go (workspace invitation email)
Wire `srv.Stop()` into both testServer (server_test.go) and
newMetricsTestServer (metrics_auth_test.go) so cleanup order is
Stop → Close → TempDir RemoveAll. Add
TestServer_Stop_DrainsBackgroundGoroutines to pin the contract: a
goAsync goroutine must block Stop until it returns.
* fix(store): correct PG FTS hyphenation via OR-combined plainto_tsquery (BUG-842)
The previous attempt swapped plainto_tsquery → websearch_to_tsquery,
which was wrong: websearch_to_tsquery treats `-` as a NEGATION operator
(Google-style), so `task-five` becomes `task & !five` and the search
returns 0 rows for the same reason as before. This commit reverts the
swap and applies the actual fix.
PG's english parser indexes `task-five-distinctive` as
`{task-five-distinct, task, five, distinct}` — the asciihword AND its
parts. plainto_tsquery applied to the partial query `task-five`
produces `task-fiv & task & five`: the stemmed asciihword for the
PARTIAL query (`task-fiv`) is NOT in the vector, so the AND fails.
Replacing the hyphen with a space makes plainto emit `task & five`,
which DOES match — but doing that unconditionally breaks `BUG-842`-
style queries: PG indexes the `-842` suffix as a negative-number
lexeme, so `plainto_tsquery('BUG-842')` matches via `-842`, while
`plainto_tsquery('BUG 842')` searches for `842` and misses.
The fix ORs the two query variants together so the search vector is
matched against either the raw user query OR its hyphen-as-space form.
Both `task-five` (against `task-five-distinctive`) and `BUG-842`
(against `BUG-842 fix the cleanup race`) hit. Verified locally against
postgres:17-alpine via PAD_TEST_POSTGRES_URL — both 10x stress and
race-detector runs are green.
Surfaces:
- dialect.go: FTSMatch / FTSSnippet / FTSRank now consume TWO
placeholders each in the PG dialect.
- items.go: listItemsFTS PG branch + SearchItems PG branch update
args to pass (raw, sanitized) for every PG `?` placeholder.
- search.go: SearchItems main / count / facets PG branches updated
likewise. New sanitizePGFTSQuery helper alongside sanitizeFTSQuery.
- documents.go: ListDocuments PG branch updated.
Tests:
- TestListItems_FTS_HyphenatedSearchTerm extended with a `BUG-842`
case to pin the OR-combined logic — naive hyphen-stripping would
silently regress this.
- New TestSanitizePGFTSQuery unit test.
* chore: gofmt 11 files with import-order issues (BUG-842 PR cleanup)
The Go (SQLite) CI job has been failing on `main` (and every PR built
against it) because golangci-lint flags 11 files whose third-party
imports are intermixed with internal imports — the import-grouping
rule that gofmt enforces. None of these were introduced by the
BUG-842 PR; they're pre-existing on main. The PR can't go green
without this cleanup, though, so it's bundled here.
Pure mechanical change — `gofmt -w <files>` only re-orders import
groups; no logic changes. Files touched:
cmd/pad/configure.go
cmd/pad/main.go
internal/cli/format.go
internal/server/handlers_admin_invitations.go
internal/server/handlers_admin_users.go
internal/server/handlers_grants.go
internal/server/handlers_share_links.go
internal/server/handlers_stars.go
internal/server/middleware_auth.go
internal/store/store.go
internal/store/store_test.go
After this commit `gofmt -l ./cmd ./internal` returns clean.
1137 lines
38 KiB
Go
1137 lines
38 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
)
|
|
|
|
// validateCloudSecret checks the cloud_secret field in a JSON request body
|
|
// against the server's configured cloud secret. Returns true if the secret
|
|
// matches; writes a 403 error and returns false otherwise.
|
|
//
|
|
// This is the authentication mechanism between the pad-cloud sidecar and
|
|
// the pad binary. All cloud-gated endpoints (oauth-login, admin/plan) must
|
|
// call this before processing the request.
|
|
func (s *Server) validateCloudSecret(secret string, w http.ResponseWriter) bool {
|
|
if len(s.cloudSecrets) == 0 {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Cloud mode not configured")
|
|
return false
|
|
}
|
|
for i, key := range s.cloudSecrets {
|
|
if subtle.ConstantTimeCompare([]byte(secret), []byte(key)) == 1 {
|
|
if i > 0 {
|
|
slog.Info("cloud secret validated with rotated key", "key_index", i)
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
writeError(w, http.StatusForbidden, "forbidden", "Invalid cloud secret")
|
|
return false
|
|
}
|
|
|
|
// requireCloudMode is a middleware/guard that rejects requests when the server
|
|
// is not running in cloud mode. Used to protect cloud-only endpoints.
|
|
func (s *Server) requireCloudMode(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !s.cloudMode {
|
|
writeError(w, http.StatusNotFound, "not_found", "Not found")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// cloudAdminPaths are the exact paths where a cloud-secret auth attempt is
|
|
// allowed to bypass the normal user-auth / CSRF gates. Defined as an
|
|
// explicit whitelist so no future /api/v1/... route accidentally inherits
|
|
// the bypass — Codex caught a P0 in the first cut of this change where
|
|
// setting X-Cloud-Secret on any path (e.g. GET /api/v1/workspaces)
|
|
// bypassed auth globally.
|
|
var cloudAdminPaths = map[string]struct{}{
|
|
"/api/v1/admin/plan": {},
|
|
"/api/v1/admin/stripe-customer-id": {},
|
|
"/api/v1/admin/user-by-customer": {},
|
|
"/api/v1/admin/stripe-event-processed": {},
|
|
"/api/v1/admin/stripe-event-unmark": {},
|
|
"/api/v1/admin/payment-failed": {},
|
|
}
|
|
|
|
// isCloudAdminPath returns true if the request targets one of the three
|
|
// cloud-sidecar-only admin endpoints. Kept separate from the credential
|
|
// check so the auth and CSRF middleware call sites can clearly combine
|
|
// "right path" AND "right credential marker" in a single if-statement.
|
|
func isCloudAdminPath(path string) bool {
|
|
_, ok := cloudAdminPaths[path]
|
|
return ok
|
|
}
|
|
|
|
// hasCloudSecretMarker returns true if the request carries a sidecar-
|
|
// style auth marker: X-Cloud-Secret header or a cloud_secret field in
|
|
// the JSON body of a POST/PUT to a cloud admin path. It does NOT check
|
|
// the path — callers MUST gate on isCloudAdminPath first. Returning
|
|
// true for a request that carries a wrong secret value is safe; the
|
|
// handler's validateCloudSecret rejects mismatches.
|
|
//
|
|
// The body peek is scoped to cloud admin POSTs only so the cost and
|
|
// body-replay side-effect are bounded to the few endpoints that need
|
|
// it.
|
|
//
|
|
// The legacy ?cloud_secret= query-param is NOT honored here — query
|
|
// values land in access logs, so a log-file compromise became a
|
|
// compromise of the cloud trust boundary. Sidecars must send the
|
|
// secret in the X-Cloud-Secret header (TASK-656).
|
|
func hasCloudSecretMarker(r *http.Request) bool {
|
|
if r.Header.Get("X-Cloud-Secret") != "" {
|
|
return true
|
|
}
|
|
if (r.Method == http.MethodPost || r.Method == http.MethodPut) && r.Body != nil {
|
|
ct := r.Header.Get("Content-Type")
|
|
if strings.HasPrefix(ct, "application/json") && bodyHasCloudSecret(r) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// bodyHasCloudSecret peeks at the request body (capped at 64 KB) to see
|
|
// whether it's a JSON object with a "cloud_secret" field. Restores the
|
|
// body afterwards so downstream handlers can decode it again. A parse
|
|
// error or missing field is treated as "no marker" — the auth gate will
|
|
// reject the request via the normal auth path.
|
|
func bodyHasCloudSecret(r *http.Request) bool {
|
|
const maxPeek = 64 * 1024
|
|
buf, err := io.ReadAll(io.LimitReader(r.Body, maxPeek))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
// Replace r.Body with a buffered reader so handlers can still decode.
|
|
// If the body was larger than maxPeek the tail is lost — acceptable
|
|
// because cloud admin requests are all tiny JSON payloads and the
|
|
// handler-level validateCloudSecret will still reject any caller
|
|
// whose request doesn't match the expected schema.
|
|
r.Body = io.NopCloser(bytes.NewReader(buf))
|
|
var body struct {
|
|
CloudSecret string `json:"cloud_secret"`
|
|
}
|
|
if err := json.Unmarshal(buf, &body); err != nil {
|
|
return false
|
|
}
|
|
return body.CloudSecret != ""
|
|
}
|
|
|
|
// --- OAuth Login (TASK-430) ---
|
|
|
|
// handleOAuthLogin handles POST /api/v1/auth/oauth-login.
|
|
// Called by the pad-cloud sidecar after completing an OAuth flow.
|
|
// Creates or finds a user by email and creates a session.
|
|
func (s *Server) handleOAuthLogin(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
Provider string `json:"provider"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
CloudSecret string `json:"cloud_secret"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
// 1. Validate cloud secret
|
|
if !s.validateCloudSecret(input.CloudSecret, w) {
|
|
slog.Warn("oauth-login: invalid cloud secret", "provider", input.Provider, "email", input.Email)
|
|
return
|
|
}
|
|
|
|
// 2. Validate required fields
|
|
if input.Provider == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "provider is required")
|
|
return
|
|
}
|
|
if input.Provider != "github" && input.Provider != "google" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "provider must be 'github' or 'google'")
|
|
return
|
|
}
|
|
|
|
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
|
if input.Email == "" || !emailRegexp.MatchString(input.Email) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "valid email is required")
|
|
return
|
|
}
|
|
|
|
// 3. Require verified email from OAuth provider
|
|
if !input.EmailVerified {
|
|
slog.Warn("oauth-login: rejected unverified email", "provider", input.Provider, "email", input.Email)
|
|
s.logAuditEvent(models.ActionOAuthLoginFailed, r, auditMeta(map[string]string{
|
|
"provider": input.Provider,
|
|
"email": input.Email,
|
|
"reason": "email_not_verified",
|
|
}))
|
|
writeError(w, http.StatusForbidden, "forbidden", "Only verified email addresses are accepted from OAuth providers")
|
|
return
|
|
}
|
|
|
|
// 4. Sanitize inputs
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
if len(input.Name) > 200 {
|
|
input.Name = input.Name[:200]
|
|
}
|
|
if input.AvatarURL != "" {
|
|
if u, err := url.Parse(input.AvatarURL); err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
|
input.AvatarURL = "" // Invalid URL — drop it
|
|
}
|
|
}
|
|
|
|
// 5. Find or create user
|
|
user, err := s.store.GetUserByEmail(input.Email)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
isNewUser := false
|
|
if user == nil {
|
|
// Create new user from OAuth
|
|
if input.Name == "" {
|
|
input.Name = strings.Split(input.Email, "@")[0]
|
|
}
|
|
user, err = s.store.CreateOAuthUser(input.Email, input.Name, input.AvatarURL)
|
|
if err != nil {
|
|
slog.Error("oauth-login: failed to create user", "error", err, "email", input.Email)
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
isNewUser = true
|
|
|
|
// Auto-link the provider for new OAuth users
|
|
if err := s.store.AddOAuthProvider(user.ID, input.Provider); err != nil {
|
|
slog.Error("oauth-login: failed to link provider", "error", err, "user_id", user.ID)
|
|
}
|
|
|
|
slog.Info("oauth-login: created new user", "provider", input.Provider, "email", input.Email, "user_id", user.ID)
|
|
|
|
// Auto-create default workspace for new OAuth users
|
|
s.autoCreateWorkspace(user)
|
|
} else {
|
|
// Existing user — require explicit provider linking.
|
|
// The user must have previously linked this provider from their settings.
|
|
if !user.HasOAuthProvider(input.Provider) {
|
|
slog.Warn("oauth-login: rejected — provider not linked",
|
|
"provider", input.Provider,
|
|
"email", input.Email,
|
|
"user_id", user.ID,
|
|
)
|
|
s.logAuditEventForUser(models.ActionOAuthLoginFailed, r, user.ID, auditMeta(map[string]string{
|
|
"provider": input.Provider,
|
|
"email": input.Email,
|
|
"reason": "provider_not_linked",
|
|
}))
|
|
writeError(w, http.StatusForbidden, "oauth_provider_not_linked",
|
|
"An account with this email already exists. Sign in with your password and link "+input.Provider+" from account settings.")
|
|
return
|
|
}
|
|
|
|
// Update avatar if they don't have one
|
|
if user.AvatarURL == "" && input.AvatarURL != "" {
|
|
avatar := input.AvatarURL
|
|
s.store.UpdateUser(user.ID, models.UserUpdate{AvatarURL: &avatar})
|
|
}
|
|
}
|
|
|
|
// 6. Reject disabled accounts
|
|
if user.IsDisabled() {
|
|
writeError(w, http.StatusForbidden, "account_disabled", "Your account has been disabled. Contact an administrator.")
|
|
return
|
|
}
|
|
|
|
// 7. Create session (30-day TTL for OAuth sessions)
|
|
token, err := s.createAuthSession(w, r, user, 30*24*time.Hour)
|
|
if err != nil {
|
|
return // Error already written by createAuthSession
|
|
}
|
|
|
|
// 7. Audit log
|
|
s.logAuditEventForUser(models.ActionOAuthLogin, r, user.ID, auditMeta(map[string]string{
|
|
"provider": input.Provider,
|
|
"email": input.Email,
|
|
"new_user": strconv.FormatBool(isNewUser),
|
|
}))
|
|
|
|
// 8. Return session info
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"user": sessionUserPayload(user),
|
|
"token": token,
|
|
"new_user": isNewUser,
|
|
})
|
|
}
|
|
|
|
// --- Admin Plan Endpoint (TASK-431) ---
|
|
|
|
// handleSetPlan handles POST /api/v1/admin/plan.
|
|
// Called by the pad-cloud sidecar to update a user's billing plan
|
|
// after Stripe subscription events.
|
|
func (s *Server) handleSetPlan(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
UserID string `json:"user_id"`
|
|
Plan string `json:"plan"`
|
|
ExpiresAt string `json:"expires_at"`
|
|
CloudSecret string `json:"cloud_secret"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
// 1. Validate cloud secret (or admin auth)
|
|
user := currentUser(r)
|
|
isAdmin := user != nil && user.Role == "admin"
|
|
if !isAdmin {
|
|
if !s.validateCloudSecret(input.CloudSecret, w) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// 2. Validate inputs
|
|
if input.UserID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "user_id is required")
|
|
return
|
|
}
|
|
validPlans := map[string]bool{"free": true, "pro": true, "self-hosted": true}
|
|
if !validPlans[input.Plan] {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "plan must be 'free', 'pro', or 'self-hosted'")
|
|
return
|
|
}
|
|
|
|
// 2b. Validate expires_at format if provided
|
|
if input.ExpiresAt != "" {
|
|
if _, err := time.Parse(time.RFC3339, input.ExpiresAt); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "expires_at must be a valid RFC3339 timestamp")
|
|
return
|
|
}
|
|
}
|
|
|
|
// 3. Verify user exists
|
|
targetUser, err := s.store.GetUser(input.UserID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if targetUser == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "User not found")
|
|
return
|
|
}
|
|
|
|
// 4. Update plan
|
|
oldPlan := targetUser.Plan
|
|
if err := s.store.SetUserPlan(input.UserID, input.Plan, input.ExpiresAt); err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
// 5. Audit log
|
|
actorID := ""
|
|
if user != nil {
|
|
actorID = user.ID
|
|
}
|
|
s.logAuditEventForUser(models.ActionPlanChanged, r, actorID, auditMeta(map[string]string{
|
|
"target_user_id": input.UserID,
|
|
"old_plan": oldPlan,
|
|
"new_plan": input.Plan,
|
|
"expires_at": input.ExpiresAt,
|
|
}))
|
|
|
|
slog.Info("plan updated", "user_id", input.UserID, "old_plan", oldPlan, "new_plan", input.Plan)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"user_id": input.UserID,
|
|
"plan": input.Plan,
|
|
"ok": true,
|
|
})
|
|
}
|
|
|
|
// --- OAuth Provider Linking (TASK-504) ---
|
|
|
|
// handleOAuthLink handles POST /api/v1/auth/oauth-link.
|
|
// Called by the pad-cloud sidecar after an OAuth flow initiated from account settings.
|
|
// Requires an active session (the user must be logged in) and links the provider.
|
|
func (s *Server) handleOAuthLink(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
Provider string `json:"provider"`
|
|
Email string `json:"email"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
CloudSecret string `json:"cloud_secret"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
// 1. Validate cloud secret
|
|
if !s.validateCloudSecret(input.CloudSecret, w) {
|
|
return
|
|
}
|
|
|
|
// 2. Validate provider
|
|
if input.Provider != "github" && input.Provider != "google" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "provider must be 'github' or 'google'")
|
|
return
|
|
}
|
|
|
|
// 3. Require verified email
|
|
if !input.EmailVerified {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Only verified email addresses are accepted")
|
|
return
|
|
}
|
|
|
|
// 4. Find user by email (the sidecar passes the OAuth email)
|
|
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
|
user, err := s.store.GetUserByEmail(input.Email)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if user == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "No account found with that email")
|
|
return
|
|
}
|
|
|
|
// 5. Check if already linked
|
|
if user.HasOAuthProvider(input.Provider) {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"provider": input.Provider,
|
|
"message": "Provider already linked",
|
|
})
|
|
return
|
|
}
|
|
|
|
// 6. Link the provider
|
|
if err := s.store.AddOAuthProvider(user.ID, input.Provider); err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
// 7. Audit log
|
|
s.logAuditEventForUser(models.ActionOAuthLogin, r, user.ID, auditMeta(map[string]string{
|
|
"provider": input.Provider,
|
|
"email": input.Email,
|
|
"action": "link_provider",
|
|
}))
|
|
|
|
slog.Info("oauth-link: provider linked", "provider", input.Provider, "user_id", user.ID)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"provider": input.Provider,
|
|
})
|
|
}
|
|
|
|
// handleOAuthUnlink handles POST /api/v1/auth/oauth-unlink.
|
|
// Removes a linked OAuth provider. Requires the user to have a usable password
|
|
// (to prevent locking themselves out).
|
|
func (s *Server) handleOAuthUnlink(w http.ResponseWriter, r *http.Request) {
|
|
user := currentUser(r)
|
|
if user == nil {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
|
|
var input struct {
|
|
Provider string `json:"provider"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
if input.Provider != "github" && input.Provider != "google" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "provider must be 'github' or 'google'")
|
|
return
|
|
}
|
|
|
|
if !user.HasOAuthProvider(input.Provider) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Provider not linked")
|
|
return
|
|
}
|
|
|
|
// Ensure user won't be locked out after unlinking. They must retain
|
|
// at least one usable sign-in method: either another linked OAuth
|
|
// provider, or an explicitly-set password. OAuth-only users have a
|
|
// random placeholder hash in password_hash that can't actually be
|
|
// used to log in, which is why we track password_set separately.
|
|
providers := user.GetOAuthProviders()
|
|
hasOtherProvider := false
|
|
for _, p := range providers {
|
|
if p != input.Provider {
|
|
hasOtherProvider = true
|
|
break
|
|
}
|
|
}
|
|
if !hasOtherProvider && !user.HasPassword() {
|
|
writeError(w, http.StatusBadRequest, "bad_request",
|
|
"Cannot unlink your only sign-in method. Link another provider or set a password first.")
|
|
return
|
|
}
|
|
|
|
if err := s.store.RemoveOAuthProvider(user.ID, input.Provider); err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
s.logAuditEventForUser(models.ActionOAuthLogin, r, user.ID, auditMeta(map[string]string{
|
|
"provider": input.Provider,
|
|
"action": "unlink_provider",
|
|
}))
|
|
|
|
slog.Info("oauth-unlink: provider unlinked", "provider", input.Provider, "user_id", user.ID)
|
|
|
|
// Removing a sign-in method changes the account's auth surface — rotate
|
|
// all sessions so any cookie issued while the provider was linked
|
|
// (possibly via a compromised OAuth account) becomes invalid. The
|
|
// caller keeps their session via a re-issued cookie.
|
|
token, ok := s.rotateSessionsAfterCredentialChange(w, r, user)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"provider": input.Provider,
|
|
"token": token, // for Bearer-only callers
|
|
})
|
|
}
|
|
|
|
// --- Stripe Customer ID (TASK-505) ---
|
|
|
|
// handleSetStripeCustomerID handles POST /api/v1/admin/stripe-customer-id.
|
|
// Called by the pad-cloud sidecar after a Stripe checkout.completed event
|
|
// to associate a Stripe customer ID with a Pad user.
|
|
func (s *Server) handleSetStripeCustomerID(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
UserID string `json:"user_id"`
|
|
CustomerID string `json:"customer_id"`
|
|
CloudSecret string `json:"cloud_secret"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
// 1. Validate cloud secret (or admin auth)
|
|
user := currentUser(r)
|
|
isAdmin := user != nil && user.Role == "admin"
|
|
if !isAdmin {
|
|
if !s.validateCloudSecret(input.CloudSecret, w) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// 2. Validate inputs
|
|
if input.UserID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "user_id is required")
|
|
return
|
|
}
|
|
if input.CustomerID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "customer_id is required")
|
|
return
|
|
}
|
|
if !strings.HasPrefix(input.CustomerID, "cus_") {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "customer_id must start with 'cus_'")
|
|
return
|
|
}
|
|
|
|
// 3. Verify user exists
|
|
targetUser, err := s.store.GetUser(input.UserID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if targetUser == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "User not found")
|
|
return
|
|
}
|
|
|
|
// 4. Store the Stripe customer ID
|
|
if err := s.store.SetUserStripeCustomerID(input.UserID, input.CustomerID); err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
// 5. Audit log
|
|
actorID := ""
|
|
if user != nil {
|
|
actorID = user.ID
|
|
}
|
|
s.logAuditEventForUser(models.ActionPlanChanged, r, actorID, auditMeta(map[string]string{
|
|
"target_user_id": input.UserID,
|
|
"stripe_customer_id": input.CustomerID,
|
|
"action": "set_stripe_customer_id",
|
|
}))
|
|
|
|
slog.Info("stripe customer ID set", "user_id", input.UserID, "customer_id", input.CustomerID)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"user_id": input.UserID,
|
|
"customer_id": input.CustomerID,
|
|
"ok": true,
|
|
})
|
|
}
|
|
|
|
// handleGetUserByCustomerID handles GET /api/v1/admin/user-by-customer?customer_id=cus_xxx.
|
|
// Called by the pad-cloud sidecar during Stripe subscription webhook processing
|
|
// to resolve a Stripe customer back to a Pad user.
|
|
func (s *Server) handleGetUserByCustomerID(w http.ResponseWriter, r *http.Request) {
|
|
// 1. Validate cloud secret via X-Cloud-Secret header (or admin auth).
|
|
//
|
|
// NOTE: ?cloud_secret= was previously accepted for GET convenience but
|
|
// dropped in TASK-656 — query-param values land in access logs (our
|
|
// StructuredLogger records the full path + query, and any fronting
|
|
// reverse proxy typically logs the same), so a log file compromise
|
|
// became a compromise of the cloud trust boundary. Sidecars must now
|
|
// send the secret in the X-Cloud-Secret header.
|
|
user := currentUser(r)
|
|
isAdmin := user != nil && user.Role == "admin"
|
|
if !isAdmin {
|
|
secret := r.Header.Get("X-Cloud-Secret")
|
|
if !s.validateCloudSecret(secret, w) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// 2. Validate customer_id
|
|
customerID := r.URL.Query().Get("customer_id")
|
|
if customerID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "customer_id query parameter is required")
|
|
return
|
|
}
|
|
if !strings.HasPrefix(customerID, "cus_") {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "customer_id must start with 'cus_'")
|
|
return
|
|
}
|
|
|
|
// 3. Look up user
|
|
targetUser, err := s.store.GetUserByStripeCustomerID(customerID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if targetUser == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "No user found with that Stripe customer ID")
|
|
return
|
|
}
|
|
|
|
// 4. Return minimal user info (only what the sidecar needs)
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"user_id": targetUser.ID,
|
|
"email": targetUser.Email,
|
|
"plan": targetUser.Plan,
|
|
})
|
|
}
|
|
|
|
// --- Stripe Webhook Idempotency (TASK-696) ---
|
|
|
|
// handleStripeEventProcessed handles POST /api/v1/admin/stripe-event-processed.
|
|
// Called by the pad-cloud sidecar BEFORE processing a Stripe webhook event.
|
|
// Pad is the source of truth for which event IDs have been handled, so the
|
|
// check survives sidecar restarts (previously held in-memory, lost on crash).
|
|
//
|
|
// Request body:
|
|
//
|
|
// {"event_id": "evt_xxx", "cloud_secret": "..."}
|
|
//
|
|
// Response:
|
|
//
|
|
// {"event_id": "evt_xxx", "already_processed": true|false, "processed_at": "RFC3339"}
|
|
//
|
|
// The processed_at field is the row's timestamp: the one we just inserted on
|
|
// a fresh mark, or the existing row's timestamp on a duplicate. Sidecars
|
|
// MUST persist this value and pass it back to /admin/stripe-event-unmark if
|
|
// they later need to roll back a failed handler (TASK-736 race protection:
|
|
// the unmark call uses (event_id, processed_at) as a composite key, so a
|
|
// stale unmark can't delete a fresh marker left by a successful retry).
|
|
//
|
|
// Semantics: the call is transactional — it atomically records the event and
|
|
// tells the caller whether it was new or a duplicate. The caller should skip
|
|
// handler logic if already_processed=true.
|
|
//
|
|
// Retention: records older than 7 days are opportunistically pruned inside
|
|
// this handler (~1% of successful inserts trigger a DELETE). Stripe retries
|
|
// events for up to 72h, so 7 days gives a safe margin.
|
|
func (s *Server) handleStripeEventProcessed(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
EventID string `json:"event_id"`
|
|
CloudSecret string `json:"cloud_secret"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
// 1. Validate cloud secret (body field for POST; handler matches the
|
|
// existing /admin/stripe-customer-id pattern).
|
|
user := currentUser(r)
|
|
isAdmin := user != nil && user.Role == "admin"
|
|
if !isAdmin {
|
|
if !s.validateCloudSecret(input.CloudSecret, w) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// 2. Validate input
|
|
if input.EventID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "event_id is required")
|
|
return
|
|
}
|
|
if !strings.HasPrefix(input.EventID, "evt_") {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "event_id must start with 'evt_'")
|
|
return
|
|
}
|
|
|
|
// 3. Atomically record-or-detect-duplicate
|
|
alreadyProcessed, processedAt, err := s.store.MarkStripeEventProcessed(input.EventID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
// 4. Opportunistic pruning (1% of calls; keeps the table bounded without
|
|
// a dedicated goroutine). Failures here are non-fatal — log and continue.
|
|
if store.ShouldPruneStripeEvents() {
|
|
eventID := input.EventID
|
|
s.goAsync(func() {
|
|
// Run in background so we don't block the response. 7-day retention
|
|
// covers Stripe's 72h retry window with a generous safety margin.
|
|
removed, perr := s.store.PruneStripeProcessedEvents(7 * 24 * time.Hour)
|
|
if perr != nil {
|
|
slog.Warn("prune stripe_processed_events failed", "error", perr, "trigger_event_id", eventID)
|
|
return
|
|
}
|
|
if removed > 0 {
|
|
slog.Info("pruned stripe_processed_events", "removed", removed)
|
|
}
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"event_id": input.EventID,
|
|
"already_processed": alreadyProcessed,
|
|
"processed_at": processedAt,
|
|
})
|
|
}
|
|
|
|
// handleStripeEventUnmark handles POST /api/v1/admin/stripe-event-unmark.
|
|
// Called by the pad-cloud sidecar as a best-effort rollback when a webhook
|
|
// handler fails AFTER /stripe-event-processed marked the event as seen
|
|
// (TASK-736). Without this, Stripe's retries of the same event are
|
|
// short-circuited as duplicates and the un-applied side effect has no
|
|
// automated recovery — operators have to manually DELETE the row and
|
|
// replay from the Stripe dashboard.
|
|
//
|
|
// Request body:
|
|
//
|
|
// {"event_id": "evt_xxx", "processed_at": "RFC3339", "cloud_secret": "..."}
|
|
//
|
|
// Response:
|
|
//
|
|
// {"event_id": "evt_xxx", "unmarked": true|false}
|
|
//
|
|
// Semantics: the delete is scoped to the specific (event_id, processed_at)
|
|
// row originally inserted by /stripe-event-processed. processed_at is
|
|
// mandatory — without it, a stale unmark from an earlier failed attempt
|
|
// could silently delete the fresh marker left by a successful retry and
|
|
// reopen the retry window after the event has been handled. With it, a
|
|
// stale token simply doesn't match and the unmark becomes a no-op.
|
|
//
|
|
// Idempotent: unmarked=true means we actually deleted a row; unmarked=false
|
|
// means nothing matched (missing row OR timestamp mismatch — both safe).
|
|
// Either outcome is 200.
|
|
//
|
|
// Audit: every call is persisted to the audit log (ActionStripeEventUnmarked)
|
|
// with the event_id and whether a row actually went away. The endpoint can
|
|
// reopen Stripe retry windows, so a queryable audit trail is required —
|
|
// slog alone would let a compromised cloud_secret spam unmarks invisibly.
|
|
//
|
|
// Auth: same cloud_secret gate as /stripe-event-processed. Admins hitting
|
|
// the endpoint with a browser session can also unmark (useful for manual
|
|
// reconciliation from the admin UI), mirroring the pattern of the
|
|
// other cloud admin endpoints.
|
|
func (s *Server) handleStripeEventUnmark(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
EventID string `json:"event_id"`
|
|
ProcessedAt string `json:"processed_at"`
|
|
CloudSecret string `json:"cloud_secret"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
// 1. Validate cloud secret (body field for POST; admins also allowed).
|
|
user := currentUser(r)
|
|
isAdmin := user != nil && user.Role == "admin"
|
|
if !isAdmin {
|
|
if !s.validateCloudSecret(input.CloudSecret, w) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// 2. Validate input — must match the shape /stripe-event-processed accepts
|
|
// so that an operator copying an event ID from the Stripe dashboard
|
|
// gets the same format validation across both endpoints. processed_at
|
|
// is mandatory for race protection (see handler godoc).
|
|
if input.EventID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "event_id is required")
|
|
return
|
|
}
|
|
if !strings.HasPrefix(input.EventID, "evt_") {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "event_id must start with 'evt_'")
|
|
return
|
|
}
|
|
if input.ProcessedAt == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "processed_at is required (pass the value returned by /stripe-event-processed)")
|
|
return
|
|
}
|
|
|
|
// 3. Delete the row only if (event_id, processed_at) matches.
|
|
unmarked, err := s.store.UnmarkStripeEventProcessed(input.EventID, input.ProcessedAt)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
// 4. Audit log — persisted so admins can see who/when reopened retry
|
|
// windows. actor_is_admin distinguishes "manual operator action" from
|
|
// "sidecar rollback" so dashboards can filter accordingly.
|
|
actorID := ""
|
|
if user != nil {
|
|
actorID = user.ID
|
|
}
|
|
s.logAuditEventForUser(models.ActionStripeEventUnmarked, r, actorID, auditMeta(map[string]string{
|
|
"event_id": input.EventID,
|
|
"processed_at": input.ProcessedAt,
|
|
"unmarked": boolToString(unmarked),
|
|
"actor_is_admin": boolToString(isAdmin),
|
|
}))
|
|
|
|
if unmarked {
|
|
slog.Info("unmarked stripe event for replay", "event_id", input.EventID, "actor_is_admin", isAdmin)
|
|
} else {
|
|
slog.Info("unmark stripe event: no matching row (idempotent)", "event_id", input.EventID, "actor_is_admin", isAdmin)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"event_id": input.EventID,
|
|
"unmarked": unmarked,
|
|
})
|
|
}
|
|
|
|
// handlePaymentFailed handles POST /api/v1/admin/payment-failed.
|
|
// Called by the pad-cloud sidecar when it receives an invoice.payment_failed
|
|
// webhook from Stripe. Pad owns the user→email mapping and the Maileroo
|
|
// integration, so the sidecar forwards the invoice metadata here and pad
|
|
// does the actual notification.
|
|
//
|
|
// Request body:
|
|
//
|
|
// {
|
|
// "stripe_customer_id": "cus_...",
|
|
// "amount_display": "$10.00", // optional, pre-formatted
|
|
// "next_retry_display": "April 30, 2026", // optional, pre-formatted
|
|
// "cloud_secret": "..."
|
|
// }
|
|
//
|
|
// Response:
|
|
//
|
|
// {"stripe_customer_id": "cus_...", "email_sent": true|false, "reason": "..."}
|
|
//
|
|
// The handler returns 200 even when no email is sent (e.g. unknown
|
|
// customer, email provider not configured, user has no stored email) —
|
|
// the sidecar should treat those as non-fatal so Stripe does not retry
|
|
// the webhook. Reasons:
|
|
// - "sent" — email dispatched
|
|
// - "no_customer" — no user matches stripe_customer_id
|
|
// - "no_email_address" — user exists but has no email on file
|
|
// - "email_not_configured" — no Maileroo key / base URL
|
|
// - "send_failed" — Maileroo returned an error (details in logs)
|
|
//
|
|
// The email is transactional (dunning) and has no unsubscribe link by
|
|
// design; users who want to stop receiving dunning mail can update
|
|
// their card or cancel the subscription.
|
|
func (s *Server) handlePaymentFailed(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
StripeCustomerID string `json:"stripe_customer_id"`
|
|
AmountDisplay string `json:"amount_display"`
|
|
NextRetryDisplay string `json:"next_retry_display"`
|
|
CloudSecret string `json:"cloud_secret"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
// 1. Validate cloud secret (or admin auth)
|
|
user := currentUser(r)
|
|
isAdmin := user != nil && user.Role == "admin"
|
|
if !isAdmin {
|
|
if !s.validateCloudSecret(input.CloudSecret, w) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// 2. Validate input
|
|
if input.StripeCustomerID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "stripe_customer_id is required")
|
|
return
|
|
}
|
|
if !strings.HasPrefix(input.StripeCustomerID, "cus_") {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "stripe_customer_id must start with 'cus_'")
|
|
return
|
|
}
|
|
|
|
// 3. Look up user. Missing customer is a non-error — Stripe could be
|
|
// firing a webhook for an account that was deleted on our side;
|
|
// returning 200 tells the sidecar not to rollback/retry.
|
|
targetUser, err := s.store.GetUserByStripeCustomerID(input.StripeCustomerID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
// auditAndRespond records a single audit row for every outcome of the
|
|
// payment-failed flow — including the skip paths before we even try to
|
|
// send — and writes the JSON response. Keeping the audit in one place
|
|
// guarantees that no-customer / no-email / email-not-configured /
|
|
// send-failed all leave a durable trail that /audit-log can surface
|
|
// during dunning reconciliation. UserID attaches to the target user
|
|
// when we have one so /audit-log?user=<id> finds the event; when the
|
|
// customer is unknown, the row still exists, just unfiltered by user.
|
|
auditAndRespond := func(targetUserID, reason string, sent bool) {
|
|
meta := map[string]string{
|
|
"stripe_customer_id": input.StripeCustomerID,
|
|
"amount_display": input.AmountDisplay,
|
|
"next_retry_display": input.NextRetryDisplay,
|
|
"reason": reason,
|
|
"sent": boolToString(sent),
|
|
}
|
|
// When an authenticated admin hits this endpoint manually (instead
|
|
// of the sidecar with cloud_secret), record which admin did it.
|
|
// logAuditEventForUser only carries one UserID field, which we use
|
|
// for the TARGET user so /audit-log?user=<id> surfaces the event —
|
|
// the admin's own ID has to live in metadata. Sidecar calls have
|
|
// no authenticated user, so admin_actor_id is absent for those.
|
|
if isAdmin && user != nil {
|
|
meta["admin_actor_id"] = user.ID
|
|
}
|
|
s.logAuditEventForUser(models.ActionPaymentFailedEmailSent, r, targetUserID, auditMeta(meta))
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"stripe_customer_id": input.StripeCustomerID,
|
|
"email_sent": sent,
|
|
"reason": reason,
|
|
})
|
|
}
|
|
|
|
if targetUser == nil {
|
|
slog.Info("payment-failed: no user for customer",
|
|
"customer_id", input.StripeCustomerID)
|
|
auditAndRespond("", "no_customer", false)
|
|
return
|
|
}
|
|
if targetUser.Email == "" {
|
|
slog.Info("payment-failed: user has no email on file",
|
|
"user_id", targetUser.ID)
|
|
auditAndRespond(targetUser.ID, "no_email_address", false)
|
|
return
|
|
}
|
|
|
|
// 4. Verify email provider is wired up. Same pattern as password reset
|
|
// (handlers_auth.go) — no panic if the operator hasn't configured
|
|
// Maileroo, just log and skip.
|
|
if s.email == nil || s.baseURL == "" {
|
|
slog.Warn("payment-failed: email provider not configured; skipping send",
|
|
"user_id", targetUser.ID)
|
|
auditAndRespond(targetUser.ID, "email_not_configured", false)
|
|
return
|
|
}
|
|
|
|
billingPortalURL := strings.TrimRight(s.baseURL, "/") + "/billing/portal"
|
|
sendErr := s.email.SendPaymentFailed(r.Context(),
|
|
targetUser.Email, targetUser.Name,
|
|
input.AmountDisplay, input.NextRetryDisplay, billingPortalURL)
|
|
|
|
if sendErr != nil {
|
|
slog.Error("payment-failed: email send failed",
|
|
"user_id", targetUser.ID, "error", sendErr)
|
|
auditAndRespond(targetUser.ID, "send_failed", false)
|
|
return
|
|
}
|
|
|
|
slog.Info("payment-failed email sent",
|
|
"user_id", targetUser.ID, "customer_id", input.StripeCustomerID)
|
|
auditAndRespond(targetUser.ID, "sent", true)
|
|
}
|
|
|
|
// boolToString converts a Go bool to the string representation used in
|
|
// audit metadata. Kept local to this file because audit metadata is
|
|
// string-typed (JSON object of string→string), so we need the literal
|
|
// "true"/"false" spelling rather than fmt.Sprintf's lower-case default
|
|
// (which happens to match but is easy to misread).
|
|
func boolToString(b bool) string {
|
|
if b {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
}
|
|
|
|
// --- Public Plan Limits (TASK-511) ---
|
|
|
|
// handleGetPlanLimits returns the configured plan limits for free and pro tiers.
|
|
// GET /api/v1/plan-limits — public endpoint, no auth required.
|
|
// Used by the billing page to show actual limits instead of hardcoded values.
|
|
func (s *Server) handleGetPlanLimits(w http.ResponseWriter, r *http.Request) {
|
|
result := map[string]interface{}{
|
|
"free": store.DefaultFreeLimits,
|
|
"pro": store.DefaultProLimits,
|
|
}
|
|
|
|
// Override with DB-stored limits if available
|
|
features := []string{
|
|
"workspaces", "items_per_workspace", "members_per_workspace",
|
|
"api_tokens", "storage_bytes", "webhooks", "automated_backups",
|
|
}
|
|
for _, plan := range []string{"free", "pro"} {
|
|
overrides := make(map[string]int)
|
|
for _, feature := range features {
|
|
key := "plan_limits_" + plan + "_" + feature
|
|
val, err := s.store.GetPlatformSetting(key)
|
|
if err != nil || val == "" {
|
|
continue
|
|
}
|
|
v, _ := strconv.Atoi(val)
|
|
overrides[feature] = v
|
|
}
|
|
if len(overrides) > 0 {
|
|
// Merge overrides onto defaults
|
|
defaults := store.DefaultFreeLimits
|
|
if plan == "pro" {
|
|
defaults = store.DefaultProLimits
|
|
}
|
|
merged := map[string]int{
|
|
"workspaces": defaults.Workspaces,
|
|
"items_per_workspace": defaults.ItemsPerWorkspace,
|
|
"members_per_workspace": defaults.MembersPerWorkspace,
|
|
"api_tokens": defaults.APITokens,
|
|
"storage_bytes": defaults.StorageBytes,
|
|
"webhooks": defaults.Webhooks,
|
|
"automated_backups": defaults.AutomatedBackups,
|
|
}
|
|
for k, v := range overrides {
|
|
merged[k] = v
|
|
}
|
|
result[plan] = merged
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
// --- Plan Limit Enforcement ---
|
|
|
|
// enforcePlanLimit checks a workspace-scoped plan limit and writes a 403
|
|
// error if the limit is exceeded. Returns true if the operation is allowed.
|
|
// In non-cloud mode, always returns true (no limits enforced).
|
|
func (s *Server) enforcePlanLimit(w http.ResponseWriter, workspaceID, feature string) bool {
|
|
if !s.cloudMode {
|
|
return true // Self-hosted: no limits
|
|
}
|
|
|
|
result, err := s.store.CheckLimit(workspaceID, feature)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return false
|
|
}
|
|
if !result.Allowed {
|
|
writePlanLimitError(w, result)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// enforceUserPlanLimit checks a user-scoped plan limit and writes a 403
|
|
// error if the limit is exceeded. Returns true if the operation is allowed.
|
|
// In non-cloud mode, always returns true (no limits enforced).
|
|
func (s *Server) enforceUserPlanLimit(w http.ResponseWriter, userID, feature string) bool {
|
|
if !s.cloudMode {
|
|
return true // Self-hosted: no limits
|
|
}
|
|
|
|
result, err := s.store.CheckUserLimit(userID, feature)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return false
|
|
}
|
|
if !result.Allowed {
|
|
writePlanLimitError(w, result)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// writePlanLimitError writes a structured 403 response for plan limit violations.
|
|
func writePlanLimitError(w http.ResponseWriter, result *store.LimitResult) {
|
|
writeJSON(w, http.StatusForbidden, map[string]interface{}{
|
|
"error": "plan_limit_exceeded",
|
|
"feature": result.Feature,
|
|
"limit": result.Limit,
|
|
"current": result.Current,
|
|
"plan": result.Plan,
|
|
"upgrade_url": "/console/billing",
|
|
})
|
|
}
|
|
|
|
// --- Auto-create Workspace (TASK-432) ---
|
|
|
|
// autoCreateWorkspace creates a default workspace for a new user in cloud mode.
|
|
// Called after user creation in register, bootstrap, and oauth-login handlers.
|
|
// No-op in self-hosted mode. Errors are logged but don't fail the signup.
|
|
func (s *Server) autoCreateWorkspace(user *models.User) {
|
|
if !s.cloudMode {
|
|
return
|
|
}
|
|
|
|
name := user.Name + "'s Workspace"
|
|
ws, err := s.store.CreateWorkspace(models.WorkspaceCreate{
|
|
Name: name,
|
|
OwnerID: user.ID,
|
|
})
|
|
if err != nil {
|
|
slog.Error("auto-create workspace failed", "user_id", user.ID, "error", err)
|
|
return
|
|
}
|
|
|
|
// Seed default collections with the startup starter pack. Cloud signups
|
|
// are implicit workspace creations, so they should get the same curated
|
|
// starter conventions/playbooks that `pad init` produces.
|
|
if err := s.store.SeedCollectionsFromTemplate(ws.ID, "startup"); err != nil {
|
|
slog.Warn("auto-create workspace: failed to seed collections", "workspace_id", ws.ID, "error", err)
|
|
}
|
|
|
|
// Add user as owner
|
|
_ = s.store.AddWorkspaceMember(ws.ID, user.ID, "owner")
|
|
|
|
slog.Info("auto-created default workspace", "user_id", user.ID, "workspace", ws.Slug)
|
|
}
|
|
|
|
// --- Helpers ---
|