From 008964f44064d1985027cc00f7e85683e91cd043 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 24 Apr 2026 00:13:00 +0530 Subject: [PATCH 1/3] fix: authenticated free users are no longer treated as anonymous MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced: passing a valid Bearer JWT to /db/new or /webhook/new returned the *same anonymous resource repeatedly*, with "tier": "anonymous" "note": "Returning your existing database. Keep it forever: ..." despite the caller already being signed in. Root cause in handlers.go: the handler gated the fingerprint-based anti-abuse dedup on `if !isPaid`, where isPaid == (auth valid && PlanTier == "paid"). An authenticated FREE-tier user has a valid JWT but PlanTier != "paid", so they fell into the anonymous branch — fingerprint dedup kicked in, ignoring their auth, and returned whatever anon resource lived on their /24 subnet today. Fix: gate the fingerprint dedup on `if !isAuthed`. Anti-abuse is for unauthenticated callers; once a user has signed in, we know who they are and can attribute resources directly. Three shapes: - not authed → anonymous tier + fingerprint dedup + 24h TTL, no owner - authed free → anonymous tier (limits/TTL) + user_id set, no dedup - authed paid → paid tier + user_id set, no TTL, no dedup Both handleNewDB and handleNewWebhook updated. Note message on the authed-free path now points at /pricing.html (upgrade) instead of /start?token=... (claim) — they're already signed in, there's nothing to claim. While in the neighbourhood, corrected several notes to use s.marketingURL for /start and /pricing.html links (they were still referencing s.baseURL which is the API host). --- internal/server/handlers.go | 66 ++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 629ce20..39f3383 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -65,14 +65,18 @@ func (s *server) handleNewDB(w http.ResponseWriter, r *http.Request) { return } - // Authenticated paid users skip the per-fingerprint anon cap and get - // permanent resources linked to their account automatically. Accepts - // session cookie (from the browser) or Authorization: Bearer - // (from CLI / agents). - paidUser := s.authUser(r) - isPaid := paidUser != nil && paidUser.PlanTier == "paid" - - if !isPaid { + // Authenticated callers skip the per-fingerprint anon cap — the cap is + // anti-abuse for unauthenticated traffic, and once a user has signed in + // we know who they are and can tie resources to them. Accepts session + // cookie (from the browser) or Authorization: Bearer (from CLI / + // agents). isPaid gates paid-tier perks (permanent resources, higher + // quotas); authed free users still get ownership but with anon-tier + // limits + TTL. + authedUser := s.authUser(r) + isAuthed := authedUser != nil + isPaid := isAuthed && authedUser.PlanTier == "paid" + + if !isAuthed { exceeded, existing := s.checkLimitAndIncrement(ctx, fp, "postgres") if exceeded { if existing != nil { @@ -83,11 +87,11 @@ func (s *server) handleNewDB(w http.ResponseWriter, r *http.Request) { "connection_url": existing.connectionURL, "tier": "anonymous", "limits": map[string]any{"storage_mb": s.cfg.Postgres.StorageMB, "connections": s.cfg.Postgres.ConnLimit, "expires_in": s.cfg.Limits.AnonTTL}, - "note": fmt.Sprintf("Returning your existing database. Keep it forever: %s/start?token=%s", s.baseURL, existing.token), + "note": fmt.Sprintf("Returning your existing database. Keep it forever: %s/start?token=%s", s.marketingURL, existing.token), }) } else { writeJSON(w, http.StatusTooManyRequests, map[string]any{ - "ok": false, "error": "rate_limited", "message": fmt.Sprintf("Daily provision limit reached (%d/day). Keep resources forever: %s/start", s.cfg.Limits.MaxProvisionsPerDay, s.baseURL), + "ok": false, "error": "rate_limited", "message": fmt.Sprintf("Daily provision limit reached (%d/day). Keep resources forever: %s/start", s.cfg.Limits.MaxProvisionsPerDay, s.marketingURL), }) } return @@ -120,11 +124,20 @@ func (s *server) handleNewDB(w http.ResponseWriter, r *http.Request) { } id := uuid.New() + // Three ownership shapes: + // - anonymous (no auth): no user link, 24h TTL + // - authed free: user link, 24h TTL (ownership but anon limits) + // - authed paid: user link, no TTL, paid-tier limits if isPaid { _, err = s.db.ExecContext(ctx, `INSERT INTO resources (id, token, resource_type, name, tier, fingerprint, connection_url, expires_at, migrated_to_user_id) VALUES ($1, $2, 'postgres', $3, 'paid', $4, $5, NULL, $6)`, - id, token, name, fp, connURL, paidUser.ID) + id, token, name, fp, connURL, authedUser.ID) + } else if isAuthed { + _, err = s.db.ExecContext(ctx, + `INSERT INTO resources (id, token, resource_type, name, tier, fingerprint, connection_url, expires_at, migrated_to_user_id) + VALUES ($1, $2, 'postgres', $3, 'anonymous', $4, $5, $6, $7)`, + id, token, name, fp, connURL, expiresAt, authedUser.ID) } else { _, err = s.db.ExecContext(ctx, `INSERT INTO resources (id, token, resource_type, name, tier, fingerprint, connection_url, expires_at) @@ -166,11 +179,18 @@ func (s *server) handleNewDB(w http.ResponseWriter, r *http.Request) { "limits": map[string]any{"storage_mb": s.cfg.Postgres.StorageMB, "connections": s.cfg.Postgres.ConnLimit}, } if isPaid { - resp["note"] = "Permanent database (Developer tier). Manage it at " + s.baseURL + "/dashboard.html" + resp["note"] = "Permanent database (Developer tier). Manage it at " + s.marketingURL + "/dashboard.html" + } else if isAuthed { + // Authed free tier: owned but still on anon limits + TTL. Surface the + // upgrade path rather than the "claim" path (they're already signed + // in, nothing to claim). + resp["expires_at"] = expiresAt + resp["limits"].(map[string]any)["expires_in"] = s.cfg.Limits.AnonTTL + resp["note"] = fmt.Sprintf("Anonymous-tier database (24h TTL). Upgrade to keep it forever: %s/pricing.html", s.marketingURL) } else { resp["expires_at"] = expiresAt resp["limits"].(map[string]any)["expires_in"] = s.cfg.Limits.AnonTTL - resp["note"] = fmt.Sprintf("Works now. Keep it forever (free 14-day trial): %s/start?token=%s", s.baseURL, token.String()) + resp["note"] = fmt.Sprintf("Works now. Keep it forever (free 14-day trial): %s/start?token=%s", s.marketingURL, token.String()) } writeJSON(w, http.StatusCreated, resp) } @@ -192,10 +212,13 @@ func (s *server) handleNewWebhook(w http.ResponseWriter, r *http.Request) { return } - paidUser := s.authUser(r) - isPaid := paidUser != nil && paidUser.PlanTier == "paid" + // See handleNewDB for the auth/isPaid/isAuthed contract — same three + // ownership shapes apply here. + authedUser := s.authUser(r) + isAuthed := authedUser != nil + isPaid := isAuthed && authedUser.PlanTier == "paid" - if !isPaid { + if !isAuthed { exceeded, existing := s.checkLimitAndIncrement(ctx, fp, "webhook") if exceeded { if existing != nil { @@ -206,11 +229,11 @@ func (s *server) handleNewWebhook(w http.ResponseWriter, r *http.Request) { "receive_url": existing.connectionURL, "tier": "anonymous", "limits": map[string]any{"requests_stored": s.cfg.Limits.WebhookMaxStored, "expires_in": s.cfg.Limits.AnonTTL}, - "note": "Returning your existing webhook. Keep it forever: " + s.baseURL + "/start", + "note": "Returning your existing webhook. Keep it forever: " + s.marketingURL + "/start", }) } else { writeJSON(w, http.StatusTooManyRequests, map[string]any{ - "ok": false, "error": "rate_limited", "message": fmt.Sprintf("Daily provision limit reached (%d/day). Keep resources forever: %s/start", s.cfg.Limits.MaxProvisionsPerDay, s.baseURL), + "ok": false, "error": "rate_limited", "message": fmt.Sprintf("Daily provision limit reached (%d/day). Keep resources forever: %s/start", s.cfg.Limits.MaxProvisionsPerDay, s.marketingURL), }) } return @@ -237,7 +260,12 @@ func (s *server) handleNewWebhook(w http.ResponseWriter, r *http.Request) { _, err = s.db.ExecContext(ctx, `INSERT INTO resources (id, token, resource_type, name, tier, fingerprint, connection_url, expires_at, migrated_to_user_id) VALUES ($1, $2, 'webhook', $3, 'paid', $4, $5, NULL, $6)`, - id, token, name, fp, receiveURL, paidUser.ID) + id, token, name, fp, receiveURL, authedUser.ID) + } else if isAuthed { + _, err = s.db.ExecContext(ctx, + `INSERT INTO resources (id, token, resource_type, name, tier, fingerprint, connection_url, expires_at, migrated_to_user_id) + VALUES ($1, $2, 'webhook', $3, 'anonymous', $4, $5, $6, $7)`, + id, token, name, fp, receiveURL, expiresAt, authedUser.ID) } else { _, err = s.db.ExecContext(ctx, `INSERT INTO resources (id, token, resource_type, name, tier, fingerprint, connection_url, expires_at) From afcb178a63d5935fcd10f574130bb85820255160 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 24 Apr 2026 00:22:51 +0530 Subject: [PATCH 2/3] fix: POST /db/new + /webhook/new are idempotent by name for authed users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-running a script that does URL=$(curl /db/new -d '{"name":"my-project"}' | jq -r .connection_url) echo DATABASE_URL=$URL >> .env used to orphan the first resource and create a second one each run. Real-world usage (CI re-runs, teammate re-checkouts, "rerun my notebook tomorrow") racks up duplicate DBs, burns plan limits, and leaks resources the user never intended to keep. Fix: for authenticated callers, /db/new and /webhook/new now look up (migrated_to_user_id, resource_type, name) before creating. If an active match exists, return it with a "returning your existing" note and the DELETE endpoint to use if they really wanted a fresh one. If not, create new (existing behaviour). Unauthed callers unchanged — fingerprint dedup there is an anti-abuse cap, and name isn't a stable identity for a user we don't know. Scope / edge cases: - name collisions across resource types are fine (postgres + webhook named "foo" are independent) - deleted resources aren't matched (status != 'active') - no unique constraint on (user_id, type, name) — a tight race window can still produce duplicates, but worst case is an annoying dupe not a 5xx. Constraint + migration can come later. go build / go vet / go test all pass. --- internal/server/handlers.go | 86 +++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 39f3383..9df12d6 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -76,6 +76,30 @@ func (s *server) handleNewDB(w http.ResponseWriter, r *http.Request) { isAuthed := authedUser != nil isPaid := isAuthed && authedUser.PlanTier == "paid" + // Idempotent-by-name for authenticated callers: if the user already owns + // an active postgres resource with this name, return it. Makes the + // "store $DATABASE_URL, re-run the script" pattern safe across runs. + if isAuthed { + if existing := s.lookupExistingNamed(ctx, authedUser.ID, "postgres", name); existing != nil { + resp := map[string]any{ + "ok": true, + "id": existing.id, + "token": existing.token, + "name": name, + "connection_url": existing.connectionURL, + "tier": existing.tier, + "limits": map[string]any{"storage_mb": s.cfg.Postgres.StorageMB, "connections": s.cfg.Postgres.ConnLimit}, + "note": fmt.Sprintf("Returning your existing %q database. Delete it via DELETE /api/me/resources/%s to provision a new one with this name.", name, existing.token), + } + if existing.expiresAt.Valid { + resp["expires_at"] = existing.expiresAt.Time + resp["limits"].(map[string]any)["expires_in"] = s.cfg.Limits.AnonTTL + } + writeJSON(w, http.StatusOK, resp) + return + } + } + if !isAuthed { exceeded, existing := s.checkLimitAndIncrement(ctx, fp, "postgres") if exceeded { @@ -218,6 +242,28 @@ func (s *server) handleNewWebhook(w http.ResponseWriter, r *http.Request) { isAuthed := authedUser != nil isPaid := isAuthed && authedUser.PlanTier == "paid" + // Idempotent-by-name for authenticated callers — see handleNewDB for rationale. + if isAuthed { + if existing := s.lookupExistingNamed(ctx, authedUser.ID, "webhook", name); existing != nil { + resp := map[string]any{ + "ok": true, + "id": existing.id, + "token": existing.token, + "name": name, + "receive_url": existing.connectionURL, + "tier": existing.tier, + "limits": map[string]any{"requests_stored": s.cfg.Limits.WebhookMaxStored}, + "note": fmt.Sprintf("Returning your existing %q webhook. Delete it via DELETE /api/me/resources/%s to provision a new one with this name.", name, existing.token), + } + if existing.expiresAt.Valid { + resp["expires_at"] = existing.expiresAt.Time + resp["limits"].(map[string]any)["expires_in"] = s.cfg.Limits.AnonTTL + } + writeJSON(w, http.StatusOK, resp) + return + } + } + if !isAuthed { exceeded, existing := s.checkLimitAndIncrement(ctx, fp, "webhook") if exceeded { @@ -379,6 +425,46 @@ type existingResource struct { keyPrefix string } +// namedResource is what lookupExistingNamed returns — just the fields a +// "returning your existing resource" response needs to reconstruct. +type namedResource struct { + id string + token string + connectionURL string + tier string + expiresAt sql.NullTime +} + +// lookupExistingNamed makes POST /db/new and POST /webhook/new idempotent by +// name for authenticated callers. If the user already owns an active +// resource of (resourceType, name), the handler returns that one instead +// of spinning up a duplicate — preserving the re-run-my-script pattern +// ("store DATABASE_URL in .env, re-run provisions tomorrow, same DB"). +// Unauthed callers don't use this (fingerprint dedup already handles abuse). +// Returns nil on no-match or on any DB error (caller falls through to +// create; worst case is a duplicate, not a 5xx). +func (s *server) lookupExistingNamed(ctx context.Context, userID uuid.UUID, resourceType, name string) *namedResource { + var r namedResource + err := s.db.QueryRowContext(ctx, + `SELECT id, token, connection_url, tier, expires_at + FROM resources + WHERE migrated_to_user_id = $1 + AND resource_type = $2 + AND name = $3 + AND status = 'active' + LIMIT 1`, + userID, resourceType, name, + ).Scan(&r.id, &r.token, &r.connectionURL, &r.tier, &r.expiresAt) + if err != nil { + if err != sql.ErrNoRows { + slog.WarnContext(ctx, "lookupExistingNamed: query failed; falling through to create", + "error", err, "user_id", userID, "name", name, "type", resourceType) + } + return nil + } + return &r +} + // checkLimitAndIncrement atomically increments the provision counter and checks // whether the limit is exceeded. Returns (exceeded, existingResource). // If Redis is down, falls back to counting resources in Postgres. From 09e40b5aceb72f73cebc58f332885b9d8dd80fad Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 24 Apr 2026 00:26:45 +0530 Subject: [PATCH 3/3] style: gofmt handlers.go (fixes CI gofmt gate) --- .gitignore | 1 + internal/server/handlers.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a60438c..2eedcdb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ lite .vscode/ .idea/ *.swp +.gstack/ diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 15c76df..0d5e7ed 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -76,7 +76,7 @@ func (s *server) handleNewDB(w http.ResponseWriter, r *http.Request) { isAuthed := authedUser != nil isPaid := isAuthed && authedUser.PlanTier == "paid" - // Idempotent-by-name for authenticated callers: if the user already owns + // Idempotent-by-name for authenticated callers: if the user already owns // an active postgres resource with this name, return it. Makes the // "store $DATABASE_URL, re-run the script" pattern safe across runs. if isAuthed {