Skip to content

fix: /db/new + /webhook/new are idempotent by name for authed users#8

Merged
mastermanas805 merged 4 commits intomasterfrom
fix/idempotent-by-name
Apr 23, 2026
Merged

fix: /db/new + /webhook/new are idempotent by name for authed users#8
mastermanas805 merged 4 commits intomasterfrom
fix/idempotent-by-name

Conversation

@mastermanas805
Copy link
Copy Markdown
Member

Problem

Re-running a script like

```bash
URL=$(curl /db/new -d '{"name":"my-project"}' | jq -r .connection_url)
echo "DATABASE_URL=$URL" >> .env
```

orphaned the first database and created a second on every run.
Real-world workflows (CI re-runs, teammate re-checkouts, "rerun my
notebook tomorrow") quietly burned plan quota. The only system that
does this is one that hasn't thought about the actual use case —
Supabase, Neon, every peer treats project name as unique per account.

Fix

Added `lookupExistingNamed(ctx, userID, resourceType, name)`. Both
`handleNewDB` and `handleNewWebhook` now call it before any
provisioning work:

  • Match on `(migrated_to_user_id, resource_type, name, status='active')`
  • Return the existing resource with all the fields a fresh response
    would have (id, token, connection_url / receive_url, tier,
    expires_at) plus a `note` explaining how to delete + re-provision
    if the user really wants a fresh one.
  • On no-match or any DB error: fall through to create (worst case is
    a duplicate, not a 5xx).

Unauthed callers unchanged — fingerprint dedup there is anti-abuse,
not ownership semantics.

Edge cases handled

  • Postgres + webhook with the same name are independent (scoped per
    resource_type)
  • Deleted / expired resources don't match (`status = 'active'`)
  • No name → 400 name_required (unchanged)

Known gap

No DB unique constraint on `(migrated_to_user_id, resource_type, name)`,
so a tight race between two simultaneous requests could still insert
duplicates. Worst case is an annoying dupe, not a 5xx. Constraint +
backfill migration belongs in a follow-up.

Verification post-deploy

```bash
JWT=

first call creates

curl -X POST https://api.instanode.dev/db/new \
-H "Authorization: Bearer $JWT" \
-H 'Content-Type: application/json' \
-d '{"name":"idempotent-test"}' | jq .token

second call with same name: SAME token in response

curl -X POST https://api.instanode.dev/db/new \
-H "Authorization: Bearer $JWT" \
-H 'Content-Type: application/json' \
-d '{"name":"idempotent-test"}' | jq .token

different name: NEW token

curl -X POST https://api.instanode.dev/db/new \
-H "Authorization: Bearer $JWT" \
-H 'Content-Type: application/json' \
-d '{"name":"something-else"}' | jq .token
```

mastermanas805 and others added 4 commits April 24, 2026 00:13
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).
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.
@mastermanas805 mastermanas805 merged commit 3aba2ba into master Apr 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant