Skip to content

fix(claim): polish anonymous→hobby conversion flow (4 friction findings)#9

Merged
mastermanas805 merged 1 commit into
masterfrom
fix/claim-flow-polish
May 11, 2026
Merged

fix(claim): polish anonymous→hobby conversion flow (4 friction findings)#9
mastermanas805 merged 1 commit into
masterfrom
fix/claim-flow-polish

Conversation

@mastermanas805
Copy link
Copy Markdown
Member

Summary

Four small fixes surfaced when walking an agent through the claim flow end-to-end on 2026-05-11. None are bugs — each is a place where the platform was making the agent or end-user do extra work.

# Fix
13 Note copy: `"14-day trial, then $9/mo"` → `"Works for 24h free. Claim to keep — from $9/mo"`. Matches the project memo: anonymous is the trial; paid tiers are paid from day one.
14 All upgrade URLs use `instanode.dev/start` instead of stale `instant.dev/start`. 28 sites swept.
15 `/claim/preview` was implemented but undocumented. Added path + `ClaimPreviewResponse` schema.
16 Provision responses now emit `upgrade_jwt` alongside `upgrade` — the raw JWT, so agents stop having to `sed 's|.*?t=||'` the URL.

Live verification on v2.0.3-claim-polish in prod

```
$ curl -X POST https://api.instanode.dev/db/new -d '{}'

note: "Returning your existing resource. Expires in 20h 43m.
Claim to keep — from $9/mo: https://instanode.dev/start?t=..."
upgrade: "https://instanode.dev/start?t=eyJhbGc..."
upgrade_jwt: "eyJhbGc..."

$ curl https://api.instanode.dev/openapi.json | jq '.paths."/claim/preview".get.summary'
"Preview which resources a claim would attach"
```

Test plan

Out of scope

Fresh-success responses (newly provisioned, not dedup) don't emit `upgrade` at all — only the note string with URL embedded. Will fix in a separate PR to keep this diff focused.

🤖 Generated with Claude Code

Four small fixes surfaced when walking an agent through the claim
flow end-to-end on 2026-05-11. None are bugs in behaviour — they're
each a place where the agent or end-user has to do extra work the
platform should be doing for them.

  #13 Provision response 'note' field said "14-day trial, then $9/mo"
     which contradicts the project memo: anonymous (24h TTL) is the
     trial; hobby/pro/team are paid from day one. Now reads
     "Works for 24h free. Claim to keep — from $9/mo: <url>".

  #14 Upgrade URLs pointed at instant.dev/start — leftover from the
     domain rename. Now instanode.dev/start across all 28 emission
     sites + every error/upgrade-link copy string.

  #15 /claim/preview was implemented but undocumented; agents reading
     openapi.json had no way to discover the no-side-effect probe
     ("what would I attach to a team if I claimed?"). Added the path
     entry + ClaimPreviewResponse schema describing the resources
     array.

  #16 Anonymous provision responses emitted 'upgrade' as a full URL
     like 'https://instanode.dev/start?t=<jwt>'. Agents had to
     string-strip the JWT out with sed to pass it back to /claim.
     Now also emits 'upgrade_jwt' alongside 'upgrade' on every
     dedup-path response (db, cache, nosql, queue, storage, webhook).
     ClaimRequest schema description points agents at the new field.

Tests:
  TestOpenAPI_ClaimPreviewEndpointDocumented — guards #15
  TestOpenAPI_ClaimRequestDocumentsUpgradeJWT  — guards #16
  Existing TestOpenAPISpecParses catches any JSON-quote regression.

Live verification on v2.0.3-claim-polish in prod:
  POST /db/new → note: "...Claim to keep — from $9/mo: https://instanode.dev/start?t=..."
  upgrade:     "https://instanode.dev/start?t=eyJhbGc..."
  upgrade_jwt: "eyJhbGc..."  (raw JWT, no parsing needed)
  GET /openapi.json | jq .paths."/claim/preview"  → present

Out of scope (separate friction worth its own PR): the fresh-success
response path (newly provisioned, not dedup) doesn't emit the upgrade
field at all — only the note string with URL embedded. Will fix
separately to keep this diff focused on the four points raised.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mastermanas805 mastermanas805 merged commit 9fa021b into master May 11, 2026
@mastermanas805 mastermanas805 deleted the fix/claim-flow-polish branch May 11, 2026 09:39
mastermanas805 added a commit that referenced this pull request May 11, 2026
#10)

Earlier PRs (#4, #6, #9) shipped OpenAPI schema tests but lacked
handler-level behavior tests. Code reviewers flagged the gap — a
schema test catches "field documented" regressions but misses "field
actually emitted" or "input actually parsed" regressions.

This PR backfills behavior tests for four shipped behaviors:

  1. upgradeNote / limitExceededNote copy (PR #9, friction #13)
     TestUpgradeNote_DoesNotMentionTrial         — 2 sub-cases
     TestLimitExceededNote_DoesNotMentionTrial   — 4 sub-cases
     Guards: no "14-day trial" framing, contains "Claim to keep" +
     "$9/mo", no instant.dev/start leakage.

  2. POST /api/v1/whoami (PR #6, friction #9)
     TestWhoami_NoTokenReturns401          — 401 on missing bearer
     TestWhoami_ReturnsIdentityForAuthedRequest
       — 200 with uid/tid claims; plan_tier enrichment when DB hit
     Test app now wires /api/v1/whoami so this and future tests can
     hit it through the full RequireAuth middleware.

  3. POST /deploy/new env_vars JSON parsing (PR #4, friction #11)
     TestDeployNew_EnvVarsJSON_Parsed_Into_InitEnv
       — valid JSON merges into deployment.EnvVars; underscore-prefixed
         keys silently stripped (_secret never leaks)
     TestDeployNew_EnvVarsInvalidJSON_Returns400
       — malformed JSON returns 400 error="invalid_env_vars"
         (not a generic 500)
     Includes a multipartDeployBody helper that other deploy tests
     can reuse without colliding with stack_test.go's name.

  4. upgrade_jwt in provisioning responses (PR #9, friction #16)
     TestAnonymousProvisionEmitsUpgradeJWT_OnDedup
       — dedup response includes raw upgrade_jwt JWT (no parsing)
         alongside the legacy upgrade URL; the two presentations of
         the same token must not drift.
     Skips cleanly when local test DB schema lags (env column).

All 10 new test cases pass against postgres:16-alpine + redis:7-alpine.
Total run time <1s.

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

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 added a commit that referenced this pull request May 11, 2026
The dashboard's BillingPage + AppShell read `tier` and `email`
from /api/v1/whoami. Until this commit the handler only returned
`plan_tier` (under a different field name) and never returned the
email at all. As a result:

- The sidebar in /app showed '—' as the tier
- The 'aanya@example.com' avatar tooltip was either blank or the
  fixture default
- Billing-page banners that depend on tier worked only because the
  BillingPage hits /api/v1/billing separately

Found by running the end-to-end real-customer flow test:

  POST /db/new                              → anonymous Postgres token
  POST /claim {jwt,email}                    → team + session_token
  GET  /api/v1/whoami                        → ok:true, tier:null,
                                                email:null  ← bug
  GET  /api/v1/billing                       → ok:true, tier:hobby ← correct

This commit:
- Adds `tier` to the response (alias of `plan_tier`; kept both for
  backward compat — some agents already key off `plan_tier`)
- Adds `email` by looking up the user record via models.GetUserByID
  on the auth'd user_id from the JWT

go build clean. The OpenAPI `/api/v1/whoami` description already
includes `team_id + plan_tier` per friction-point #9; the new
fields are additive.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mastermanas805 added a commit that referenced this pull request May 17, 2026
Wave 4 of the 2026-05-17 bug hunt — P2 contract/security/doc fixes.

Auth / session security:
- CanonicalResourceURLFor + dpop.requestCanonicalURL no longer derive the
  canonical host from client-settable X-Forwarded-Host/-Proto headers — the
  RFC 8707 audience check and RFC 9449 DPoP htu check now resolve the host
  from API_PUBLIC_URL or the compiled default only.
- OptionalAuth now runs the JTI-revocation check RequireAuth does; a revoked
  token drops the credential and continues anonymous.
- signSessionJWT + the impersonation mint now stamp `aud` (canonical resource
  URL) so the opt-in audience check actually applies to session JWTs.

plans.Registry instead of hardcoded limits (convention #3):
- dbAnonymousLimits / vectorAnonymousLimits / webhookAnonLimits /
  queueAnonymousLimits are now handler methods reading plans.Registry.

Quota / storage:
- cache.StorageBytes raised the 1000-key SCAN cap to 200k with COUNT 500 and
  a truncation warning — no longer silently under-counts a tenant's quota.
- quota.LimitBytes is the single MB->bytes conversion point (MiB, matching
  CheckStorageQuota); resourceToMap used SI 1_000_000 and under-stated the
  wall ~4.8%. Updated resource_limits_test.go to MiB.

API contract / OpenAPI:
- Provision response schemas (db/vector/cache/nosql/queue/webhook/storage)
  gained id/name/env/env_override_reason; upgrade_url renamed to `upgrade`
  to match what the handlers actually emit.
- Recycle-gate 402 routed through the canonical ErrorResponse envelope
  (request_id + retry_after_seconds + claim_url) via respondRecycleGate.

Billing:
- ChangePlanAPI rejects self-serve downgrades (target rank <= current rank)
  with 400 + mailto-support agent_action — enforces the no-self-serve-
  downgrade policy.
- Added billing_inr_drift_test.go: anti-drift guard for
  monthlyAmountINRForTier against plans.yaml prices.

Stacks / lifecycle:
- Redeploy + UpdateEnv reject stacks in `deleting` status with 409.
- POST /stacks/new now accepts an optional validated `env` form field
  (defaults to EnvDefault) — matches the resource/deploy env contract.
- Confirmed-deletion teardown failure now returns
  deletion_status=confirmed_teardown_pending instead of a false "torn down".

Doc-comment / model:
- 403-vs-404 doc drift corrected in resource_family.go, resource_metrics.go,
  backup.go (code already returns 404).
- Custom domain: cert_ready documented as the terminal state; the never-
  implemented cert_ready -> live transition dropped from the docs and the
  `live` constant marked back-compat-only.

Skipped: #9 (unknown /api/v1/* -> 401 not 404) — left as-is; 401 for an
unauthenticated caller on any path is a deliberate posture (do not reveal
route existence). Pre-existing unrelated failure TestAdminList_AdminUserSees200
is not touched by this change.

go build ./... and go vet ./... clean.

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