Files
pad/internal/server/handlers_cloud.go
T
xarmian 812f68930b fix(store): an unlink re-checks that a sign-in method remains under the account lock (BUG-3816) (#2191)
* fix(store): an unlink re-checks that a sign-in method remains under the account lock (BUG-3816)

handleOAuthUnlink checked "another provider or a password remains" against
the account as it was when the request was admitted, so two unlinks of
different providers sent at once both passed and the second wrote an empty
provider list. RemoveOAuthProvider now runs the check on the locked row and
refuses with ErrLastSignInMethod, which the handler maps to the existing 400.
A repeat unlink of a provider no longer linked stays a no-op.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(server): the TASK-3351 unlink test keeps a second sign-in method (BUG-3816)

It unlinked the account's only provider to check the binding is forgotten,
which the locked last-method check now refuses. It links github first, so
it stays about the binding.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(store): the concurrent unlink pin waits for the unlink's own lock read (BUG-3816)

It counted any lock wait in the database as the overlap. It now matches the
backend whose current query is the account row's FOR NO KEY UPDATE read,
the class codex r1 found in BUG-3817's race test.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-15 17:23:24 -04:00

2108 lines
82 KiB
Go

package server
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// supportedOAuthProviders is the set of OAuth providers pad accepts for
// cloud login/linking. The pad-cloud sidecar verifies the provider response
// (GitHub/Google via the browser redirect flow; Apple via native
// identity-token verification — PLAN-1772) before calling these endpoints,
// so this is a defense-in-depth allowlist, not the primary trust boundary.
// Storage (users.oauth_providers) is a free-form JSON array with no DB
// constraint, so adding a provider here is the only gate to widen.
var supportedOAuthProviders = map[string]bool{
"github": true,
"google": true,
"apple": true,
}
// isSupportedOAuthProvider reports whether provider is one pad accepts.
func isSupportedOAuthProvider(provider string) bool {
return supportedOAuthProviders[provider]
}
// validOAuthSubject accepts a provider account id: 1-255 bytes, no control
// characters (TASK-3351). The column has no database NUL trigger, so this
// door is what keeps control bytes out.
func validOAuthSubject(subject string) bool {
if subject == "" || len(subject) > 255 {
return false
}
for _, r := range subject {
if r < 0x20 || r == 0x7f {
return false
}
}
return true
}
// refuseOAuthSubject answers a sign-in or link whose provider account is not
// the one bound to this pad account (TASK-3351). It names the way back in:
// the address still reaches its owner, so a password reset recovers the
// account, and unlinking and relinking the provider then binds the new
// provider account. A store error that is not a mismatch is a 500.
func (s *Server) refuseOAuthSubject(w http.ResponseWriter, r *http.Request, userID, provider, email string, err error) {
if errors.Is(err, store.ErrOAuthProviderNotLinked) {
// Unlinked while this sign-in was in flight.
writeError(w, http.StatusForbidden, "oauth_provider_not_linked",
provider+" is no longer linked to this account. Sign in another way and link it again from account settings.")
return
}
if !errors.Is(err, store.ErrOAuthSubjectMismatch) {
writeInternalError(w, err)
return
}
slog.Warn("oauth: provider subject mismatch", "provider", provider, "user_id", userID)
s.logAuditEventForUser(models.ActionOAuthLoginFailed, r, userID, auditMeta(map[string]string{
"provider": provider,
"email": email,
"reason": "subject_mismatch",
}))
writeError2(w, http.StatusForbidden, "oauth_subject_mismatch",
"This "+provider+" account is not the one linked to the Pad account for this address. "+
"Sign in with your password, or reset it from \"Forgot password\", then unlink and relink "+provider+" in account settings.",
map[string]interface{}{"provider": provider, "recovery": "password_reset", "recovery_path": "/forgot-password"})
}
// 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/user-by-id": {},
"/api/v1/admin/billing-users": {},
"/api/v1/admin/stripe-event-processed": {},
"/api/v1/admin/stripe-event-unmark": {},
"/api/v1/admin/stripe-event-complete": {},
"/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"`
// Subject is the provider's stable account id (TASK-3351).
// Required (BUG-3797): without it the sign-in would rest on the
// email address alone, whatever the account's binding state.
Subject string `json:"subject"`
// EmailPrimary: the address is the provider account's PRIMARY one.
// Only GitHub's matters (TASK-3351 ruling 2): an account claim from
// GitHub requires it to be true, so an absent value claims nothing.
EmailPrimary *bool `json:"email_primary"`
}
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 !isSupportedOAuthProvider(input.Provider) {
writeError(w, http.StatusBadRequest, "bad_request", "provider must be 'github', 'google', or 'apple'")
return
}
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
// No provider can vouch for an installed app's address (TASK-3392).
if input.Email == "" || !emailRegexp.MatchString(input.Email) || models.IsReservedAppEmail(input.Email) {
writeError(w, http.StatusBadRequest, "bad_request", "valid email is required")
return
}
// A sign-in without the provider account's id is refused (BUG-3797). It
// used to be refused only once the account had a subject bound
// (BUG-3671), and every account unbound until its first subject-bearing
// sign-in still signed in on its email alone.
if input.Subject == "" {
slog.Warn("oauth-login: rejected sign-in without a provider subject", "provider", input.Provider)
s.logAuditEvent(models.ActionOAuthLoginFailed, r, auditMeta(map[string]string{
"provider": input.Provider,
"email": input.Email,
"reason": "subject_missing",
}))
writeError(w, http.StatusBadRequest, "oauth_subject_required", "subject is required")
return
}
if !validOAuthSubject(input.Subject) {
writeError(w, http.StatusBadRequest, "bad_request", "subject must be 1-255 printable characters")
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 = truncateBindableText(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
}
// A provider account bound to a DIFFERENT pad account (its address
// changed at the provider) neither creates an account nor signs in to
// this one (TASK-3351). Checked before anything is created.
owner, err := s.store.OAuthIdentityOwner(input.Provider, input.Subject)
if err != nil {
writeInternalError(w, err)
return
}
if owner != "" && (user == nil || owner != user.ID) {
refusedFor := owner
if user != nil {
refusedFor = user.ID
}
s.refuseOAuthSubject(w, r, refusedFor, input.Provider, input.Email, store.ErrOAuthSubjectMismatch)
return
}
isNewUser := false
var created *models.Workspace
var claim *store.AccountClaim
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, binding the
// provider account in the same write (TASK-3351).
// Any failure stops the sign-in: an account whose provider never
// linked could not sign in with it again. A mismatch means the
// precheck above passed and another sign-in bound this provider
// account in between.
if err := s.store.LinkOAuthProvider(user.ID, input.Provider, input.Subject); err != nil {
slog.Error("oauth-login: failed to link provider", "error", err, "user_id", user.ID)
s.refuseOAuthSubject(w, r, user.ID, input.Provider, input.Email, err)
return
}
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
created = s.autoCreateWorkspace(user)
} else {
// Existing user — require explicit provider linking.
// The user must have previously linked this provider from their settings.
//
// Except an account nobody ever verified (TASK-3351): it may have
// been registered by someone other than the address's owner, and the
// provider has just vouched that the caller owns the address. The
// owner claims it, which resets every credential the registrant
// held, as the verification-link claim does (BUG-3382).
if !user.HasOAuthProvider(input.Provider) && !user.IsEmailVerified() && providerVouchesForClaim(input.Provider, input.EmailPrimary) {
c, err := s.store.ClaimAccountByProvider(user.ID, input.Provider, input.Subject, input.Name)
switch {
case err == nil:
claim = c
case errors.Is(err, store.ErrOAuthSubjectMismatch):
// Rolled back: the account is unchanged.
s.refuseOAuthSubject(w, r, user.ID, input.Provider, input.Email, err)
return
case errors.Is(err, store.ErrClaimNeedsSupport):
writeError(w, http.StatusConflict, "account_claim_needs_support",
"This account has billing attached, so it can't be claimed automatically. Contact support@getpad.dev and we'll sort it out.")
return
case errors.Is(err, store.ErrClaimNotEligible):
// Verified, disabled or gone since the read: refuse below.
default:
writeInternalError(w, err)
return
}
}
if claim != nil {
s.invalidateUserAccess(user.ID)
claimed, err := s.store.GetUser(user.ID)
if err != nil || claimed == nil {
writeInternalError(w, fmt.Errorf("oauth-login: re-read claimed account: %v", err))
return
}
user = claimed
// The session is fenced on the epoch the claim produced.
user.CredentialEpoch = claim.Epoch
s.logAuditEventForUser(models.ActionAccountClaimed, r, user.ID, auditMeta(map[string]string{
"provider": input.Provider,
"stripped_workspaces": strconv.Itoa(len(claim.StrippedWorkspaces)),
"deleted_workspaces": strconv.Itoa(len(claim.DeletedWorkspaces)),
}))
s.sendAccountClaimedNotice(user, input.Provider, claim)
} else 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
}
}
// 6. Reject disabled accounts
if user.IsDisabled() {
writeError(w, http.StatusForbidden, "account_disabled", "Your account has been disabled. Contact an administrator.")
return
}
// 6a. Provider account binding (TASK-3351). The address alone does not
// say WHICH provider account is asserting it, and an address can move
// between accounts. The first sign-in carrying a subject binds it; a
// different one is refused. A sign-in without one was refused above
// (BUG-3797).
if !isNewUser {
if err := s.store.BindOAuthIdentity(user.ID, input.Provider, input.Subject); err != nil {
s.refuseOAuthSubject(w, r, user.ID, input.Provider, input.Email, err)
return
}
}
// 6b. Two-factor (BUG-3322). The provider stands in for the password,
// not for the second factor: a user who turned on TOTP gets the same
// challenge password login hands out (handleLogin), and no session until
// /auth/2fa/login-verify accepts a code. It used to mint a full session
// here. A refusal rather than a 200 so a sidecar that predates this
// reads it as a failed login and sets no cookie; the challenge rides in
// details for one that knows to continue. The challenge binds to
// clientIP, which is the BROWSER's address only when the sidecar
// forwards it from a trusted peer (PAD_TRUSTED_PROXIES), so a spoofed
// header from anyone else binds the challenge to the spoofer's own
// address and nothing more. The message is for the native shells, which
// have no 2FA step after social sign-in yet. It does not say "use your
// password": an account created through a provider has a random one
// nobody knows (CreateOAuthUser), and TOTP setup does not require it.
if user.TOTPEnabled {
s.logAuditEventForUser(models.ActionOAuthLoginFailed, r, user.ID, auditMeta(map[string]string{
"provider": input.Provider,
"email": input.Email,
"reason": "two_factor_required",
}))
challenge := generateTwoFAChallengeAt(user.ID, clientIP(r), user.CredentialEpoch, s.twoFAChallengeSecret)
writeError2(w, http.StatusForbidden, "two_factor_required",
"This account has two-factor authentication turned on. Sign in on the web to finish with your 2FA code.",
map[string]interface{}{"challenge_token": challenge})
return
}
// Fill a missing avatar only once the sign-in has passed every check
// above, the second factor included (BUG-3322 review).
if !isNewUser && user.AvatarURL == "" && input.AvatarURL != "" {
avatar := input.AvatarURL
s.store.UpdateUser(user.ID, models.UserUpdate{AvatarURL: &avatar})
}
// 7. Create session. Uses webSessionTTL (not a longer OAuth-specific
// TTL) so the session cookie, CSRF cookie, and store session row all
// expire together with every other web login — a longer-lived OAuth
// cookie used to outlive its server-side session, producing silent
// 401s once the store row expired but the browser kept presenting
// the cookie (B9, TASK-1932).
token, err := s.createAuthSession(w, r, user, webSessionTTL)
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
resp := map[string]interface{}{
"user": sessionUserPayload(user),
"token": token,
"new_user": isNewUser,
}
// Where a first signup lands (TASK-3595). The sidecar redirects a new
// user here when the sign-in carried no explicit destination; an older
// sidecar ignores it. Additive; absent unless a usable workspace was just
// auto-created for this sign-in.
if p := createdWorkspacePath(user, created); p != "" {
resp["workspace_path"] = p
}
if claim != nil {
resp["claimed"] = true
resp["stripped_workspaces"] = nonNilStrings(claim.StrippedWorkspaces)
resp["deleted_workspaces"] = nonNilStrings(claim.DeletedWorkspaces)
}
writeJSON(w, http.StatusOK, resp)
}
// providerVouchesForClaim: whether a provider's verified assertion of an
// address is enough to claim the never-verified account registered with it
// (TASK-3351 ruling 2). GitHub reports every verified address on an account,
// not only its own, so only the primary one counts, and only when the
// sidecar says so.
func providerVouchesForClaim(provider string, emailPrimary *bool) bool {
if provider == "github" {
return emailPrimary != nil && *emailPrimary
}
return true
}
// sendAccountClaimedNotice tells the address's owner what the claim removed
// (TASK-3351 ruling 3). The sign-in ends in a redirect through the sidecar,
// so the response's lists may never be shown; the mailbox is the channel
// that reaches the right person. Without email, it is logged.
func (s *Server) sendAccountClaimedNotice(user *models.User, provider string, claim *store.AccountClaim) {
if s.email == nil || s.baseURL == "" {
slog.Info("account claimed by provider sign-in; no email configured for the notice",
"user_id", user.ID, "provider", provider,
"stripped_workspaces", len(claim.StrippedWorkspaces), "deleted_workspaces", len(claim.DeletedWorkspaces))
return
}
to, name := user.Email, user.Name
stripped, deleted := claim.StrippedWorkspaces, claim.DeletedWorkspaces
s.goAsync(func() {
if err := s.email.SendAccountClaimed(context.Background(), to, name, provider, stripped, deleted); err != nil {
slog.Error("failed to send account-claimed notice", "error", err, "user_id", user.ID)
}
})
}
// --- Admin Plan Endpoint (TASK-431) ---
// validSubscriptionID accepts "" or a Stripe-shaped id (sub_...): letters,
// digits, '_' and '-', at most 255 bytes. The column has no database NUL
// trigger, so this door is what keeps control bytes out (BUG-3356).
func validSubscriptionID(id string) bool {
if len(id) > 255 {
return false
}
for _, r := range id {
if !(r == '_' || r == '-' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
return false
}
}
return true
}
// countPlanWrite counts one /admin/plan write by source and outcome
// (BUG-3356).
func (s *Server) countPlanWrite(source, outcome string) {
if s.metrics != nil {
s.metrics.PlanWritesTotal.WithLabelValues(source, outcome).Inc()
}
}
// handleSetPlan handles POST /api/v1/admin/plan.
// Called by the pad-cloud sidecar to update a user's billing plan
// after Stripe subscription events.
//
// `source` says who is setting the plan ("manual" when omitted, "stripe"
// from the sidecar). The store decides whether the write applies: a write
// lowering the plan to free from a source other than the one that set it is
// refused (PLAN-3291 DR-6). A refused write is still a 200, with
// `applied: false` and the plan the user keeps: the sidecar treats any other
// status as a failure and the Stripe webhook would then retry forever.
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"`
Source string `json:"source"`
CloudSecret string `json:"cloud_secret"`
// BUG-3356: the sidecar's fetch time (unix ms) of the Stripe state
// this plan was derived from, and the subscription it came from.
// Optional: absent from a sidecar that predates it.
Revision int64 `json:"revision"`
SubscriptionID string `json:"subscription_id"`
}
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
}
if input.Source == "" {
input.Source = store.PlanSourceManual
}
if !store.ValidPlanSource(input.Source) {
writeError(w, http.StatusBadRequest, "bad_request", "source must be 'manual' or 'stripe'")
return
}
if input.Revision < 0 {
writeError(w, http.StatusBadRequest, "bad_request", "revision must not be negative")
return
}
// A revision orders STRIPE-derived writes; a manual write fences them
// with the current time instead, which a caller-chosen revision would
// undercut (BUG-3356).
if input.Revision > 0 && input.Source != store.PlanSourceStripe {
writeError(w, http.StatusBadRequest, "bad_request", "revision is only accepted with source 'stripe'")
return
}
if !validSubscriptionID(input.SubscriptionID) {
writeError(w, http.StatusBadRequest, "bad_request", "subscription_id must be at most 255 letters, digits, '_' or '-'")
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, under the plan-source rule
oldPlan := targetUser.Plan
result, err := s.store.SetUserPlan(input.UserID, store.PlanWrite{
Plan: input.Plan, ExpiresAt: input.ExpiresAt, Source: input.Source,
Revision: input.Revision, SubscriptionID: input.SubscriptionID,
})
if err != nil {
writeInternalError(w, err)
return
}
if !result.Applied {
s.countPlanWrite(input.Source, result.Reason)
slog.Info("plan update refused",
"reason", result.Reason,
"user_id", input.UserID, "requested_plan", input.Plan, "source", input.Source,
"revision", input.Revision,
"held_plan", result.Plan, "held_source", result.Source)
writeJSON(w, http.StatusOK, map[string]interface{}{
"user_id": input.UserID,
"plan": result.Plan,
"plan_source": result.Source,
"applied": false,
"reason": result.Reason,
"ok": true,
})
return
}
if input.Source == store.PlanSourceStripe && input.Revision == 0 {
// Accepted, unordered, for a sidecar that predates revisions; a
// later change refuses it once the sidecar sends one (BUG-3356).
s.countPlanWrite(input.Source, "no_revision")
slog.Warn("stripe plan write carried no revision; applied without ordering",
"user_id", input.UserID, "plan", input.Plan)
} else {
s.countPlanWrite(input.Source, "applied")
}
// 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,
"source": input.Source,
}))
slog.Info("plan updated", "user_id", input.UserID, "old_plan", oldPlan, "new_plan", input.Plan, "source", input.Source)
writeJSON(w, http.StatusOK, map[string]interface{}{
"user_id": input.UserID,
"plan": result.Plan,
"plan_source": result.Source,
"applied": true,
"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"`
// Subject: the provider's stable account id, bound on link
// (TASK-3351). Optional here; oauth-login requires one (BUG-3797).
Subject string `json:"subject"`
}
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 !isSupportedOAuthProvider(input.Provider) {
writeError(w, http.StatusBadRequest, "bad_request", "provider must be 'github', 'google', or 'apple'")
return
}
if input.Subject != "" && !validOAuthSubject(input.Subject) {
writeError(w, http.StatusBadRequest, "bad_request", "subject must be 1-255 printable characters")
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
}
// A bot is no account to link a provider to (TASK-3392).
if user == nil || user.IsApp() {
writeError(w, http.StatusNotFound, "not_found", "No account found with that email")
return
}
// BUG-3348: never attach the address owner's provider to an account
// that has not proven the address. Whoever registered it holds its
// password, so the owner would be signing in to an account somebody
// else can also read.
if !user.IsEmailVerified() {
writeError(w, http.StatusForbidden, "email_not_verified", "Verify this account's email address before linking a sign-in provider")
return
}
// 5. Check if already linked
if user.HasOAuthProvider(input.Provider) {
if input.Subject != "" {
if err := s.store.LinkOAuthProvider(user.ID, input.Provider, input.Subject); err != nil {
s.refuseOAuthSubject(w, r, user.ID, input.Provider, input.Email, err)
return
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"provider": input.Provider,
"message": "Provider already linked",
})
return
}
// 6. Link the provider, binding its account in the same write: a
// subject already bound to another pad account links nothing.
if err := s.store.LinkOAuthProvider(user.ID, input.Provider, input.Subject); err != nil {
s.refuseOAuthSubject(w, r, user.ID, input.Provider, input.Email, 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) {
// It rotates every session and mints a new one (BUG-3336, BUG-3349).
if isAPITokenAuth(r) {
writeError(w, http.StatusForbidden, "session_required",
"Unlinking a sign-in provider requires an interactive session, not an API token")
return
}
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 !isSupportedOAuthProvider(input.Provider) {
writeError(w, http.StatusBadRequest, "bad_request", "provider must be 'github', 'google', or 'apple'")
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 {
if errors.Is(err, store.ErrLastSignInMethod) {
// A concurrent unlink took the other method after the check
// above read the account (BUG-3816).
writeError(w, http.StatusBadRequest, "bad_request",
"Cannot unlink your only sign-in method. Link another provider or set a password first.")
return
}
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"`
// Replace lets an ADMIN USER overwrite a different stored id (a
// support fix). The sidecar never sends it, and the cloud secret
// alone cannot use it (TASK-3549).
Replace bool `json:"replace"`
}
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, compare-and-set (TASK-3549): a user who
// already holds a DIFFERENT customer keeps it, and the caller is told which,
// with 409. Overwriting the link orphaned the first customer's subscription:
// still charging, its events finding no user. Only an admin user may replace.
if input.Replace && isAdmin {
if err := s.store.SetUserStripeCustomerID(input.UserID, input.CustomerID); err != nil {
writeInternalError(w, err)
return
}
} else {
stored, err := s.store.SetUserStripeCustomerIDIfUnset(input.UserID, input.CustomerID)
if err != nil {
writeInternalError(w, err)
return
}
if stored != input.CustomerID {
slog.Warn("stripe customer ID not replaced: the user already has a different one",
"user_id", input.UserID, "stored_customer_id", stored, "refused_customer_id", input.CustomerID)
writeError2(w, http.StatusConflict, "stripe_customer_conflict",
"This user already has a different Stripe customer; it was not replaced.",
map[string]any{"stored_customer_id": stored})
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,
})
}
// handleGetUserByID handles GET /api/v1/admin/user-by-id?user_id=… (TASK-3549).
// The sidecar reads a user's stored Stripe customer BEFORE writing one, so an
// older pad that still overwrites on POST /admin/stripe-customer-id is never
// asked to, and so a webhook that found the user some other way (customer or
// subscription metadata, the checkout session's client_reference_id) can
// compare the stored customer with the event's. Same credential rule and
// response shape as user-by-customer, plus stripe_customer_id.
func (s *Server) handleGetUserByID(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
isAdmin := user != nil && user.Role == "admin"
if !isAdmin {
if !s.validateCloudSecret(r.Header.Get("X-Cloud-Secret"), w) {
return
}
}
userID := r.URL.Query().Get("user_id")
if userID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "user_id query parameter is required")
return
}
targetUser, err := s.store.GetUser(userID)
if err != nil {
writeInternalError(w, err)
return
}
if targetUser == nil {
writeError(w, http.StatusNotFound, "not_found", "User not found")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"user_id": targetUser.ID,
"email": targetUser.Email,
"plan": targetUser.Plan,
"stripe_customer_id": targetUser.StripeCustomerID,
})
}
// billingUsersMaxLimit bounds one page of GET /admin/billing-users.
const billingUsersMaxLimit = 1000
// handleListBillingUsers handles GET /api/v1/admin/billing-users?after=<user_id>&limit=N
// (TASK-3564). The Pad Cloud sidecar's reconcile sweep pages through every
// user with a stored Stripe customer or a stripe-sourced plan and re-syncs
// each from Stripe, the backstop for a webhook whose effect was lost. Its
// startup key check reads a small page to probe that its Stripe key can see
// these customers. Same credential rule as user-by-id. limit defaults to 200;
// `next` is the cursor for the following page, "" on the last one.
func (s *Server) handleListBillingUsers(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
isAdmin := user != nil && user.Role == "admin"
if !isAdmin {
if !s.validateCloudSecret(r.Header.Get("X-Cloud-Secret"), w) {
return
}
}
limit := 200
if v := r.URL.Query().Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
return
}
limit = min(n, billingUsersMaxLimit)
}
users, err := s.store.ListBillingUsers(r.URL.Query().Get("after"), limit)
if err != nil {
writeInternalError(w, err)
return
}
next := ""
if len(users) == limit {
next = users[len(users)-1].UserID
}
writeJSON(w, http.StatusOK, map[string]any{"users": users, "next": next})
}
// --- 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
// BUG-3561: a claim with a lease. already_processed keeps its meaning for a
// sidecar that predates the lease (anything but a fresh claim); `state`
// tells a newer one apart: claimed (run, then complete), done (a
// duplicate), in_progress (another handler holds the lease; fail the
// delivery so Stripe retries after it).
state, processedAt, err := s.store.ClaimStripeEvent(input.EventID, store.StripeEventLease)
alreadyProcessed := state != store.StripeEventClaimed
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,
"state": state,
})
}
// handleStripeEventComplete handles POST /api/v1/admin/stripe-event-complete
// (BUG-3561): the sidecar reports that the handler for a claimed event
// succeeded, so later deliveries are duplicates. Token-scoped like unmark: a
// handler whose lease was taken over cannot complete the event for the one
// that took it (completed: false). A sidecar that predates the lease never
// calls this; its rows stay 'processing' and a redelivery after the lease
// re-runs the handler, which is harmless because every handler re-syncs from
// current Stripe state.
func (s *Server) handleStripeEventComplete(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
}
user := currentUser(r)
isAdmin := user != nil && user.Role == "admin"
if !isAdmin {
if !s.validateCloudSecret(input.CloudSecret, w) {
return
}
}
if input.EventID == "" || input.ProcessedAt == "" {
writeError(w, http.StatusBadRequest, "bad_request", "event_id and processed_at are required")
return
}
completed, err := s.store.CompleteStripeEvent(input.EventID, input.ProcessedAt)
if err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"event_id": input.EventID, "completed": completed})
}
// 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).
//
// The check is ADVISORY: a concurrent request can fill the cap after it. The
// door's insert must also pass workspaceLimitMintOpts(), which decides the
// limit authoritatively in the insert's transaction (BUG-2808).
func (s *Server) enforcePlanLimit(w http.ResponseWriter, r *http.Request, workspaceID, feature string) bool {
if !s.cloudMode {
return true // Self-hosted: no limits
}
result, err := s.store.CheckLimit(workspaceID, feature)
if err != nil {
// BUG-1430 follow-up: enforcePlanLimit is one of the few
// cloud-mode-only 500 paths in the item-create handler chain
// (it fans out into 3 Postgres queries — workspace owner
// lookup, user fetch, feature count). Under bursty workloads
// like agent onboarding (24 parallel item creates fanned out
// from a single MCP session) it's a plausible source of
// "backend 500" symptoms that local SQLite testing can't
// reproduce. Logging with workspace_id + feature + the
// underlying error gives operators a grep-able tag for the
// next time the symptom surfaces on real Pad Cloud. Costs
// nothing in the happy path.
slog.Error("enforcePlanLimit: CheckLimit failed",
"workspace_id", workspaceID,
"feature", feature,
"error", err)
writeInternalError(w, err)
return false
}
if !result.Allowed {
writePlanLimitError(w, r, result)
return false
}
if s.planLimitAdmittedHook != nil {
s.planLimitAdmittedHook(feature, workspaceID)
}
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, r *http.Request, userID, feature string) bool {
if !s.cloudMode {
return true // Self-hosted: no limits
}
result, err := s.store.CheckUserLimit(userID, feature)
if err != nil {
// BUG-1430 follow-up: symmetric observability for the
// user-scoped sibling. Same rationale as enforcePlanLimit —
// any DB error here surfaces to the agent as a generic 500
// (which the MCP dispatcher then relays as ErrUpstreamError),
// and we want a grep-able tag in the logs to debug.
slog.Error("enforceUserPlanLimit: CheckUserLimit failed",
"user_id", userID,
"feature", feature,
"error", err)
writeInternalError(w, err)
return false
}
if !result.Allowed {
writePlanLimitError(w, r, result)
return false
}
if s.planLimitAdmittedHook != nil {
s.planLimitAdmittedHook(feature, userID)
}
return true
}
// planLimitMintOpts returns the store option that makes a user-scoped mint
// enforce the owner's plan limit inside its own transaction (BUG-2808). Cloud
// mode only, and only for an attributable owner: a legacy workspace token
// resolves no user, and charging an unattributable mint against nobody's plan
// is not a limit (the same guard beginWorkspaceMint applies to the pre-check).
func (s *Server) planLimitMintOpts(ownerID string) []store.MintOption {
if !s.cloudMode || ownerID == "" {
return nil
}
return []store.MintOption{store.WithPlanLimit()}
}
// workspaceLimitMintOpts is the option set for a workspace-scoped limited
// insert (items, members, webhooks): the limit applies in cloud mode only,
// the same condition enforcePlanLimit uses for its pre-check.
func (s *Server) workspaceLimitMintOpts() []store.MintOption {
if !s.cloudMode {
return nil
}
return []store.MintOption{store.WithPlanLimit()}
}
// writeStorePlanLimitError answers a *store.PlanLimitError with the same 403
// the advisory pre-check writes, and reports whether err was one. note, when
// non-empty, is appended to the message.
func writeStorePlanLimitError(w http.ResponseWriter, r *http.Request, err error, note string) bool {
var ple *store.PlanLimitError
if !errors.As(err, &ple) {
return false
}
writePlanLimitErrorNote(w, r, &ple.Result, note)
return true
}
// writePlanLimitError writes a structured 403 response for plan limit violations.
// The envelope follows the standard {"error":{"code":...,"message":...,"details":{...}}}
// shape so PadApiError (frontend), cli.APIError (CLI), and the MCP classifier can
// all parse it uniformly. TASK-788.
func writePlanLimitError(w http.ResponseWriter, r *http.Request, result *store.LimitResult) {
writePlanLimitErrorNote(w, r, result, "")
}
func writePlanLimitErrorNote(w http.ResponseWriter, r *http.Request, result *store.LimitResult, note string) {
msg := planLimitMessage(r, result)
if note != "" {
msg += " " + note
}
writeError2(w, http.StatusForbidden, "plan_limit_exceeded", msg, planLimitDetails(r, result))
}
// writeMemberLimitError answers an INVITEE whose accept would take a workspace
// past members_per_workspace (BUG-3098). It is deliberately not the
// plan-limit envelope: that one is addressed to the plan's owner ("You've
// reached...", an upgrade link), and the invitee can neither see nor change
// someone else's plan. So: its own code, a message naming the owner as the
// person who can make room, and details without upgrade_url.
//
// The owner is named by display name only, never by email: the invitee holds a
// code from an inviter who need not be the owner, and an email address is a
// contact detail they were not given. With no display name the message says
// "the workspace owner". A failed lookup degrades to the same wording rather
// than failing the refusal.
func (s *Server) writeMemberLimitError(w http.ResponseWriter, r *http.Request, workspaceID string, result *store.LimitResult) {
owner := "the workspace owner"
if ws, err := s.store.GetWorkspaceByID(workspaceID); err == nil && ws != nil && ws.OwnerID != "" {
if u, err := s.store.GetUser(ws.OwnerID); err == nil && u != nil && strings.TrimSpace(u.Name) != "" {
owner = "the workspace owner, " + strings.TrimSpace(u.Name) + ","
}
}
// The mobile apps carry no call to action for a purchase, not even one
// addressed to someone else (PLAN-3291 DR-1), so a shell's invitee is told
// only to ask for room.
remedy := "make room or upgrade their plan"
if fromNativeShell(r) {
remedy = "make room"
}
msg := fmt.Sprintf("This workspace has reached its %d-member limit. Ask %s to %s, then accept this invitation again.", result.Limit, owner, remedy)
writeError2(w, http.StatusForbidden, "workspace_member_limit", msg, map[string]interface{}{
"feature": result.Feature,
"limit": result.Limit,
"current": result.Current,
})
}
// checkMemberLimitForAccept is the advisory pre-check for an invitation
// accept, answered with writeMemberLimitError instead of the owner-addressed
// 403. Cloud mode only. Returns true if the accept may proceed. It fires
// planLimitAdmittedHook like enforcePlanLimit, so tests can land a competing
// member in the window before the authoritative insert.
func (s *Server) checkMemberLimitForAccept(w http.ResponseWriter, r *http.Request, workspaceID string) bool {
if !s.cloudMode {
return true
}
result, err := s.store.CheckLimit(workspaceID, "members_per_workspace")
if err != nil {
slog.Error("checkMemberLimitForAccept: CheckLimit failed",
"workspace_id", workspaceID, "error", err)
writeInternalError(w, err)
return false
}
if !result.Allowed {
s.writeMemberLimitError(w, r, workspaceID, result)
return false
}
if s.planLimitAdmittedHook != nil {
s.planLimitAdmittedHook("members_per_workspace", workspaceID)
}
return true
}
// writeStoreMemberLimitError is writeStorePlanLimitError for the accept
// doors: it answers a *store.PlanLimitError with writeMemberLimitError and
// reports whether err was one.
func (s *Server) writeStoreMemberLimitError(w http.ResponseWriter, r *http.Request, workspaceID string, err error) bool {
var ple *store.PlanLimitError
if !errors.As(err, &ple) {
return false
}
s.writeMemberLimitError(w, r, workspaceID, &ple.Result)
return true
}
// nativeShellMarker is the user-agent token both mobile shells append to every
// web-view request (pad-mobile AppUserAgent.MARKER, UserAgent.marker; the
// shells send "PadShell/1"). PLAN-3291 DR-2 matches the prefix, so a future
// "PadShell/2" is still an app.
const nativeShellMarker = "PadShell/"
// fromNativeShell reports whether r came from a Pad mobile app's web view.
// PLAN-3291 DR-1: the apps carry no purchase path, so a plan-limit refusal
// addressed to one names no upgrade destination. The user agent is not an
// authorization boundary and nothing that matters for security reads this;
// it only decides whether commerce copy is offered. A nil request is not an
// app.
func fromNativeShell(r *http.Request) bool {
return r != nil && strings.Contains(requestUserAgent(r), nativeShellMarker)
}
// planLimitDetails is the structured half of a plan-limit refusal, shared by
// the 403 and by the bulk envelope's per-item failure. It takes the request so
// every door has to say who it is answering (PLAN-3291 DR-3): a request from a
// mobile shell gets no upgrade_url, and every other request gets the same map
// as before.
func planLimitDetails(r *http.Request, result *store.LimitResult) map[string]interface{} {
d := map[string]interface{}{
"feature": result.Feature,
"limit": result.Limit,
"current": result.Current,
"plan": result.Plan,
}
if !fromNativeShell(r) {
d["upgrade_url"] = "/console/billing"
}
// Who can lift this limit (TASK-3601): the client offers checkout only to
// the payer, and names the payer to anyone else ("Ask Dana to upgrade").
// The name is the workspace owner's display name, which every member
// already sees. Absent when the check did not resolve a payer.
if result.PayerID != "" {
you := false
if u := currentUser(r); u != nil && u.ID == result.PayerID {
you = true
}
d["payer_is_you"] = you
if !you && result.PayerName != "" {
d["payer_name"] = result.PayerName
}
}
// `requested` appears only where an operation adds more than one at once
// (BUG-3103: ImportWorkspace). Every other door leaves Requested zero and
// this map is byte-identical to what it was before the field existed, which
// a test pins against a literal.
if result.Requested > 0 {
d["requested"] = result.Requested
}
return d
}
// restoreLimitOpts is workspaceLimitMintOpts for RestoreItem, whose options
// are MutationOptions (BUG-3101). Cloud mode only, like the pre-check.
func (s *Server) restoreLimitOpts() []store.MutationOption {
if !s.cloudMode {
return nil
}
return []store.MutationOption{store.WithRestorePlanLimit()}
}
// planLimitMessage returns a human-readable statement-of-fact sentence for a
// plan limit violation. Each surface (web toast, CLI, MCP hint) appends its
// own upgrade call-to-action so the message itself doesn't repeat it (B1 fix).
// That is also why a mobile shell's request gets the same sentence: it carries
// no upgrade wording to remove. It takes the request anyway so that any future
// wording must decide per caller (PLAN-3291 DR-3), and a test pins the
// shell's sentence free of upgrade words.
// Uses hyphenated adjective form ("3-member") per B2 fix. TASK-788.
func planLimitMessage(r *http.Request, result *store.LimitResult) string {
featureLabel := map[string]string{
"items_per_workspace": "item",
"members_per_workspace": "member",
"workspaces": "workspace",
"api_tokens": "API token",
"webhooks": "webhook",
}
label, ok := featureLabel[result.Feature]
if !ok {
label = result.Feature
}
// "3-member", "10-item", "1-workspace", "10-API-token", etc.
// Hyphenated form reads as a compound adjective modifying "limit".
limitStr := fmt.Sprintf("%d-%s", result.Limit, label)
// An operation that would add MORE THAN ONE at once gets its own sentence
// (BUG-3103: ImportWorkspace). Dave's day-71 ruling asks this refusal to
// name how many would land versus the limit, and the sentence below cannot
// — it never reads Current, and on the import door "You've reached" is
// simply false: the workspace the transaction just minted holds nothing,
// and it is the import that WOULD exceed the cap.
//
// Requested is zero at every other door, so their message is unchanged and
// a test pins it against a literal.
// Only items_per_workspace reaches this branch today (it is the one cap an
// import can exceed in bulk), so `label + "s"` is the correct plural for
// every value that gets here rather than a general pluralizer. A second
// bulk feature would need its own plural and a test; there is deliberately
// no speculative machinery for one that does not exist.
// Storage is counted in bytes and refused per upload (TASK-3619): the
// sentence names the plan the limit belongs to (it is not always free)
// and says what being over it does and does not mean.
if result.Feature == "storage_bytes" {
holder := "this workspace"
if result.Scope == store.StorageScopeAccount {
holder = "the owner's account"
}
return fmt.Sprintf("This file (%s) would take %s past its %s storage limit on the %s plan (%s used). Existing files stay available; deleting files frees space.",
humanBytes(int64(result.Requested)), holder, humanBytes(int64(result.Limit)), planDisplayName(result.Plan), humanBytes(int64(result.Current)))
}
if result.Requested > 0 {
return fmt.Sprintf("This import would add %d %ss, over the %s limit on the free plan.", result.Requested, label, limitStr)
}
return fmt.Sprintf("You've reached the %s limit on the free plan.", limitStr)
}
// humanBytes renders a byte count for a refusal sentence, in binary units
// with the decimal-style labels the billing page uses ("1 GB" for 1<<30).
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit && exp < 3; m /= unit {
div *= unit
exp++
}
v := float64(n) / float64(div)
label := []string{"KB", "MB", "GB", "TB"}[exp]
if v == float64(int64(v)) {
return fmt.Sprintf("%d %s", int64(v), label)
}
return fmt.Sprintf("%.1f %s", v, label)
}
// planDisplayName is a plan slug as a sentence says it.
func planDisplayName(plan string) string {
switch plan {
case "pro":
return "Pro"
case "free", "":
return "free"
default:
return plan
}
}
// writeError2 is like writeError but also embeds a details object inside the error
// envelope. Shape: {"error":{"code":...,"message":...,"details":{...}}}.
func writeError2(w http.ResponseWriter, status int, code, message string, details map[string]interface{}) {
writeJSON(w, status, map[string]interface{}{
"error": map[string]interface{}{
"code": code,
"message": message,
"details": details,
},
})
}
// --- 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.
//
// It returns the workspace only when it is usable: created, seeded and owned by
// the user. Every failure arm returns nil, including the two that KEEP a
// workspace whose owner row is unknown or carries the wrong role, because
// landing the user in one of those would 403 (TASK-3595: the signup doors send
// a first signup into what this returns).
//
// Errors never fail the SIGNUP — by the time this runs the user row, the session
// and (self-serve) the verification email all exist, and a 5xx here would 409 on
// retry for an error that was never about the account. What an error does fail
// is the WORKSPACE: this door answers "a usable workspace or nothing" (BUG-2715's
// property, BUG-3087 on this door), so a workspace that cannot be seeded or
// cannot be administered is removed rather than left behind, and the user lands
// on the console's empty state. Nothing re-runs this for an account with zero
// workspaces, which is why "log and continue" was not an option: the partial
// workspace was permanent, and the product described it as brand-new.
func (s *Server) autoCreateWorkspace(user *models.User) *models.Workspace {
if !s.cloudMode {
return nil
}
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 nil
}
// 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.
//
// A seed failure REMOVES the workspace (BUG-3087). Nothing re-seeds it later
// — the store's zero-collection rescue has no callers, and the seeder's
// idempotent retry has no door — so keeping it meant a workspace the
// dashboard and the agent bootstrap both flagged needs_onboarding, whose
// onboard playbook did not exist. No retry: the seeder is idempotent and a
// retry would be safe, but the creation door does not retry its seed either.
//
// The helper's other-members guard is trivially satisfied here — the owner
// row is added BELOW, so at this point no member row can exist — and the
// guard is not load-bearing on this path. It is called through the helper
// anyway so every door removes a workspace the same way: soft delete (frees
// the slug) THEN purge (so the row never surfaces as restorable — see the
// helper's comment for why a soft delete alone is not "nothing").
if err := s.store.SeedCollectionsFromTemplate(ws.ID, "startup"); err != nil {
slog.Error("auto-create workspace: failed to seed collections; the workspace is unusable and is being removed",
"workspace_id", ws.ID, "workspace_slug", ws.Slug, "user_id", user.ID, "error", err)
if rerr := s.removeUnusableWorkspace("cloud auto-create", ws.ID, ws.Slug, user.ID, err); rerr != nil {
// BUG-3479: say it, rather than leave "is being removed" as the last word.
slog.Error("auto-create workspace: the unusable workspace could NOT be removed and is still live",
"workspace_id", ws.ID, "workspace_slug", ws.Slug, "user_id", user.ID, "error", rerr)
}
return nil
}
// Add user as owner. This is not optional bookkeeping: without a
// workspace_members row, RequireWorkspaceAccess 403s the owner on
// every request (owner_id alone grants no access), and the
// workspace never shows up in GetUserWorkspaces — it becomes a
// permanently orphaned, completely unreachable row. Retry once for
// transient blips (e.g. a dropped Postgres connection); if it still fails,
// RECONCILE against the durable row before deciding — see BUG-3026 below —
// and delete the now-useless workspace only when the membership genuinely is
// absent. Log loudly on every branch so on-call can see it happened
// (B6, TASK-1932).
if err := s.store.AddWorkspaceMember(ws.ID, user.ID, "owner"); err != nil {
slog.Warn("auto-create workspace: add owner member failed, retrying",
"workspace_id", ws.ID, "workspace_slug", ws.Slug, "user_id", user.ID, "error", err)
if err := s.store.AddWorkspaceMember(ws.ID, user.ID, "owner"); err != nil {
// RECONCILE BEFORE DESTROYING (BUG-3026). The retry's failure does not
// mean the row is absent, and the case where it does not is the very
// failure the retry was written for. AddWorkspaceMember is a plain
// INSERT against PRIMARY KEY (workspace_id, user_id) returning the raw
// tx.Commit() error, so a commit whose acknowledgement was lost — the
// "dropped Postgres connection" this comment already names — lands the
// row and reports an error; the retry then hits the primary key and
// fails deterministically. Deleting here destroyed the workspace
// PRECISELY when the membership write had succeeded.
//
// TWO QUESTIONS, not one, and an earlier draft of this reconcile
// conflated them (codex round 1): "did MY write land?" and "is it safe
// to destroy this workspace?". Existence answers the second; only the
// ROLE answers the first, because what was attempted was an OWNER row.
// A row carrying some other role means my write did NOT land, and also
// that somebody has access — so neither success nor deletion is honest.
// Hence four arms over GetWorkspaceMember's (row, error), not three
// over a bool.
membershipCheck := s.store.GetWorkspaceMember
if s.membershipCheck != nil {
membershipCheck = s.membershipCheck
}
switch member, cerr := membershipCheck(ws.ID, user.ID); {
case cerr != nil:
// UNREADABLE — the read itself failed, so we cannot tell a genuine
// absence from an ack-lost success. Keep the workspace. An orphan is
// recoverable (and three sibling call sites accept one deliberately,
// BUG-2715); deleting a live workspace on a state nobody could read
// is not.
slog.Error("auto-create workspace: add owner member failed and the membership read also failed; "+
"KEEPING the workspace because its state is unknown",
"workspace_id", ws.ID, "workspace_slug", ws.Slug, "user_id", user.ID,
"error", err, "check_error", cerr)
return nil
case member == nil:
// ABSENT — the original behaviour, now reached only when the row
// genuinely is not there and the workspace really is unreachable.
//
// The removal is the shared soft-delete-plus-purge (BUG-3087). A
// bare DeleteWorkspace left a husk: ListDeletedWorkspaces is scoped
// by owner_id, not membership, so the row sat in the user's
// deleted-workspaces list, and restoring it returned a workspace
// with no member row — 403 on every read and on the delete. The
// helper also refuses to remove a workspace that has acquired
// other members, which this arm never checked.
slog.Error("auto-create workspace: add owner member failed after retry; the workspace is unusable and is being removed",
"workspace_id", ws.ID, "workspace_slug", ws.Slug, "user_id", user.ID, "error", err)
if rerr := s.removeUnusableWorkspace("cloud auto-create", ws.ID, ws.Slug, user.ID, err); rerr != nil {
slog.Error("auto-create workspace: the unusable workspace could NOT be removed and is still live",
"workspace_id", ws.ID, "workspace_slug", ws.Slug, "user_id", user.ID, "error", rerr)
}
return nil
// The literal matches the AddWorkspaceMember call above and every other
// role comparison in this package; there is no shared constant.
case member.Role == "owner":
// LANDED as owner despite the reported error. Nothing is wrong with
// this workspace; fall through to success.
slog.Warn("auto-create workspace: add owner member reported an error but the owner row is present; "+
"reconciled to success",
"workspace_id", ws.ID, "workspace_slug", ws.Slug, "user_id", user.ID, "error", err)
default:
// PRESENT WITH THE WRONG ROLE. Not a success — the user cannot
// administer their own auto-created workspace (owner > editor >
// viewer) — and not a deletion either, since somebody does have
// access to it. Keep it and say so loudly; this needs a human, and
// silently claiming success would hide that.
//
// Deliberately NOT repaired by writing the role: this path cannot
// tell an ack-lost write from another actor's deliberate one, and
// escalating a role on that ambiguity is the wrong default for a
// permission.
slog.Error("auto-create workspace: add owner member failed and the membership row carries a "+
"different role; KEEPING the workspace, manual intervention required",
"workspace_id", ws.ID, "workspace_slug", ws.Slug, "user_id", user.ID,
"found_role", member.Role, "error", err)
return nil
}
}
}
slog.Info("auto-created default workspace", "user_id", user.ID, "workspace", ws.Slug)
return ws
}
// createdWorkspacePath is the SPA path of a workspace autoCreateWorkspace just
// made for user, or "" when it made none (TASK-3595). Workspace routes are
// /{owner username}/{slug}, and the user is the owner.
func createdWorkspacePath(user *models.User, ws *models.Workspace) string {
if user == nil || ws == nil || user.Username == "" || ws.Slug == "" {
return ""
}
return "/" + url.PathEscape(user.Username) + "/" + url.PathEscape(ws.Slug)
}
// --- Helpers ---