Skip to content

fix: emit upgrade + upgrade_jwt on anonymous fresh-success responses#11

Merged
mastermanas805 merged 1 commit into
masterfrom
fix/upgrade-jwt-on-fresh-success
May 11, 2026
Merged

fix: emit upgrade + upgrade_jwt on anonymous fresh-success responses#11
mastermanas805 merged 1 commit into
masterfrom
fix/upgrade-jwt-on-fresh-success

Conversation

@mastermanas805
Copy link
Copy Markdown
Member

Summary

Friction #17 from the claim-flow walkthrough. PR #9 added `upgrade_jwt` to the dedup response path but not the fresh-success (newly provisioned) path — agents hitting a brand-new fingerprint got the URL only inside the note text, having to regex it back out.

Adds `upgrade` + `upgrade_jwt` to the anonymous fresh-success response in db / cache / nosql / queue / webhook. storage.go already had them. Authenticated fresh-success paths don't need them (nothing to claim).

Live verification (before this PR)

```
$ curl -H "X-E2E-Test-Token: "
-H "X-E2E-Source-IP: 192.0.2.117"
-X POST https://api.instanode.dev/db/new -d '{}'

→ 201 (fresh, not 200 dedup)
tier: "anonymous"
note: "Works for 24h free. Claim to keep — from $9/mo: ..."
upgrade: "https://instanode.dev/start?t=ey..."
upgrade_jwt: "ey..." (bare JWT, no parsing needed)
```

Test plan

🤖 Generated with Claude Code

Friction #17 from the claim-flow walkthrough: PR #9 added upgrade_jwt
to the dedup response path but not the fresh-success (newly provisioned)
path. Agents that hit a new fingerprint got the URL only inside the
note string, having to regex it back out — exactly the friction
upgrade_jwt was meant to remove.

Adds upgrade + upgrade_jwt to the anonymous fresh-success response in:
  - db.go        (line 257 area)
  - cache.go     (line 257 area)
  - nosql.go     (line 245 area)
  - queue.go     (line 251 area)
  - webhook.go   (line 241 area)

storage.go already emitted both fields from its fresh path; left
unchanged. Authenticated fresh-success paths (in *.go newDB/Cache/etc
authenticated branches) do not need upgrade fields — there's nothing
to claim because the team already exists.

Tests:
  TestAnonymousProvisionEmitsUpgradeJWT_OnFreshSuccess
    Asserts a 201 fresh response carries both fields, that
    upgrade_jwt is a bare JWT (no scheme), and that upgrade is a
    /start?t=<jwt> URL.

Live verification on v2.0.4-fresh-success-upgrade in prod:
  $ curl -H "X-E2E-Test-Token: <secret>" \
         -H "X-E2E-Source-IP: 192.0.2.117" \
         -X POST https://api.instanode.dev/db/new -d '{}'
  → 201 (fresh, not 200 dedup)
    tier:        "anonymous"
    note:        "Works for 24h free. Claim to keep — from $9/mo: ..."
    upgrade:     "https://instanode.dev/start?t=ey..."
    upgrade_jwt: "ey..."   (bare JWT, no parsing needed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mastermanas805 mastermanas805 merged commit 171830b into master May 11, 2026
@mastermanas805 mastermanas805 deleted the fix/upgrade-jwt-on-fresh-success branch May 11, 2026 09:53
mastermanas805 added a commit that referenced this pull request May 17, 2026
P1-A: fingerprint daily provision cap (5/day) was bypassable by mixing
service types — the dedup-recovery query was type-scoped, so an abuser
could provision 5x EACH service type/day. It also ignored `env`, leaking
a `production` resource to a `development` caller (defeats migration 026 /
convention #11). models.GetActiveResourceByFingerprintType now takes an
`env` arg and filters on it; added models.GetActiveResourceByFingerprint
as a cross-service fallback. All 7 call sites (db, cache, nosql, queue,
storage, webhook, vector) now: pass env, and on no-same-type-match do a
cross-service check — if any anonymous resource exists for the
fingerprint+env the cap is spent, return 429 instead of provisioning.

P1-B: anonymous /storage/new never enforced the tier byte cap (advertised
10MB anon cap unenforced). Added models.SumStorageBytesByFingerprintAndType
and a quota check on the anonymous storage path mirroring the
authenticated path, scoped to the fingerprint. Lags real usage by one
worker scanner tick (storage_bytes is worker-populated); fails open.

P1-C: expired anonymous webhooks kept accepting payloads forever —
Receive checked status but not expires_at, and each receive re-extended
the Redis-list TTL. Receive and ListRequests now return 410 Gone when
resource.ExpiresAt is valid and in the past.

P1-D: nosql provisioning advertised `connections: <tier limit>` as a
per-token guarantee, but MongoDB enforces no per-user connection cap. The
authenticated response now exposes connections_informational +
connections_note instead of a false enforced-limit claim.

P1-E: Redis ACL username (api local backend) was derived from the 8-char
token prefix, so two tokens sharing 8 hex chars collided on one ACL user
(SETUSER overwrite). aclUsername now uses the FULL token, matching the
key-prefix; added legacyACLUsername helper so an old-scheme user is still
locatable for deletion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mastermanas805 added a commit that referenced this pull request May 17, 2026
…ning

Round-3 P2 remediation across deploy/stack/webhook/auth surfaces.

1. deploy.go Redeploy now rejects a deployment in a terminal status
   (expired/deleted/stopped) with 409 + error code
   `deployment_not_redeployable` — redeploying one would resurrect an
   over-TTL/over-cap workload. New models.IsDeploymentTerminal +
   DeployStatusStopped const.

2. stack.go Redeploy re-runs the per-tier deployments_apps cap check when
   the stack is NOT in an active (slot-occupying) status — a failed/stopped
   stack flipping back to `building` could take a team to cap+1. New
   models.IsStackActive.

3. stack.go empty-env vault fallback changed from "production" to
   models.EnvDefault (development) at both new + redeploy sites — convention
   #11: a no-env legacy stack must not silently read production secrets.

4. deploy_teardown_reconciler.go increments a new
   metrics.DeployTeardownMarkFailed counter when MarkDeploymentTornDown
   fails — a persistently stuck row is now alertable in NR, not a silent log.

5. auth.go findOrCreateUserGitHub now matches an existing account by email
   (GetUserByEmail) and links github_id via new models.LinkGitHubID instead
   of forking a new team/user — mirrors findOrCreateUserGoogle and rejects
   takeover of an account already linked to a different GitHub ID. The
   /user/emails fallback now filters on Verified && Primary.

6. (already correct) models.CreateUser already routes email through
   NormalizeEmail at the write boundary — every OAuth/magic-link/claim call
   site is covered. No change needed; verified.

7. webhook.go receive_url is now built from a fixed server-controlled base
   (new webhookReceiveBaseURL: API_PUBLIC_URL / compiled-in base; c.BaseURL()
   only as a non-production dev fallback) instead of the client-controllable
   Host header. The URL is encrypted + persisted, so a client-settable host
   was a persistence-poisoning vector.

8. webhook.go Receive + ListRequests reject any non-webhook resource token
   with 404 — GetResourceByToken selects by token only, so a postgres/redis
   token previously passed.

9. auth.go GoogleAuthURL drops the impossible url.Parse-error 500 branch
   (the argument is a compile-time constant) — matches GoogleStart.

Regression tests: models/redeploy_guard_test.go (IsDeploymentTerminal,
IsStackActive), models/link_github_id_test.go (LinkGitHubID),
handlers/webhook_receive_base_url_test.go (#7), handlers/p2_roundup_test.go
(#1 error code, #4 metric, #9 GoogleAuthURL), and a wrong-resource-type case
appended to handlers/webhook_test.go (#8).

go build ./... and go vet ./... pass. New no-DB regression tests pass; the
DB/Redis-backed suites require a test Postgres/Redis (unavailable in this
environment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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