From bdf2fdd5d1f0cf0e6188551c2ddc12522bf424cf Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 29 May 2026 12:18:01 +0530 Subject: [PATCH] fix(billing): accept Team-tier Razorpay subscription checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CreateCheckoutAPI handler at billing.go:695 short-circuited every team-tier checkout with `400 tier_unavailable` ("Team tier is under active development"). Meanwhile the marketing surface sells Team @ $199/mo (instanode-web/PricingPage.tsx), the dashboard PricingGrid sells Team (post PR #106), and llms.txt advertises self-serve Team checkout. Three customer-facing surfaces, one API answer: the highest-AOV prospect lands on `tier_unavailable` mid-funnel. CEO BIZ-1 ship call (2026-05-29 memo): "Either accept Team Razorpay checkout, OR update marketing + llms.txt to say 'contact sales'. Pick (a). Deadline: 5 business days." ## Change - billing.go switch: add `team` to the hobby/hobby_plus/pro allow-list. razorpayPlanIDFor + the shared 503 billing_not_configured branch already handle the team tier; the operator-config gap (RAZORPAY_PLAN_ID_TEAM empty on prod until Razorpay-recurring is enabled — known operator follow-up, see project memory project_razorpay_recurring_not_enabled.md) now surfaces as 503 (clear operator signal) instead of 400 tier_unavailable (a customer signal that the tier itself doesn't exist). - billing.go:3001 ChangePlanAPI: remove the same Team-tier guard for the upgrade-via-dashboard path. Same justification. - openapi.go: `plan` enum on POST /api/v1/billing/checkout and /api/v1/billing/change-plan now includes "team"; 400 description no longer mentions tier_unavailable. - helpers.go: drop the now-orphan `tier_unavailable` entry from codeToAgentAction (no handler emits it anymore; TestCodeToAgentAction_NoOrphans flagged it). - agent_action_contract_test.go: drop "tier_unavailable" from the expected-codes list (paired with the helpers.go drop). ## Bundled fix: API FINDING-2 (JWT plan field rename) The OnboardingClaims `SuggestedPlan` field is serialised as JSON tag "plan". UI walkthrough + @API code analysis converged: zero handler call sites read it; server-side tier comes from teams.plan_tier='free' literal + Razorpay subscription.charged webhook. JSON tag renamed to "suggested_plan" so the wire format signals "advisory, not load-bearing". Companion PR on InstaNode-dev/common (#35) renames the parallel struct that worker/provisioner share. No runtime impact — the only emitter (provision_helper.go:572) still uses the Go field name. ## Regression tests | Test | Lock-down | |---|---| | TestCov2_Checkout_TeamTierAccepted | Team checkout returns 200 + Razorpay short_url with RAZORPAY_PLAN_ID_TEAM set | | TestCov2_Checkout_TeamTierYearlyAccepted | Same for plan_frequency=yearly | | TestCov2_Checkout_TeamTierNotConfigured | Operator-config gap → 503 billing_not_configured (not 400 tier_unavailable) | | TestCov2_ChangePlan_TeamTierAccepted | ChangePlan team→no_subscription (downstream guard), not tier_unavailable | | TestResidualChangePlan_TeamTier_Accepted | Same pattern from the residual-coverage harness | | TestCheckout_PlanFrequency_TeamTier* | No-DB path: team is no longer tier_unavailable on any frequency | | TestCheckout_RejectsUnknownPlan | Negative case still 400 invalid_plan with team in the accepted-list message | | TestClaimDoesNotTrustJWTPlanField | Re-signs an onboarding JWT with SuggestedPlan="team", reusing the registered JTI, posts to /claim, asserts the resulting team.plan_tier='free' (not 'team') and resource tier='free'. Locks the API FINDING-2 boundary. | Targeted run green locally: cd /tmp/fix-api-team && TEST_DATABASE_URL=… go test ./internal/handlers/ \ -run "TestCheckout_PlanFrequency_TeamTier|TestCheckout_RejectsUnknownPlan|TestCov2_Checkout_TeamTier|TestCov2_ChangePlan_TeamTier|TestResidualChangePlan_TeamTier_Accepted|TestClaimDoesNotTrustJWTPlanField|TestAgentActionContract_RegistryCoverage|TestCodeToAgentAction_NoOrphans|TestOnboarding_PostClaim" -short -count=1 -p 1 # PASS · ok instant.dev/internal/handlers 2.169s Remaining failures in the full -p 1 sweep (TestDBNew_*, TestQueue_*, TestBulkTwin_*, TestProvisionFinal2_OverCapNoExistingResource, TestGRPCProvision_DB_AnonymousDedup_ReturnsExisting) are NATS / customer-DB infra-dependent and were failing pre-change on the same fresh test DB — documented in memory feedback_coverage_measure_per_package_not_dotdotdot.md. ## Coverage block (rule 17) Symptom: POST /api/v1/billing/checkout {"plan":"team"} returns 400 tier_unavailable on every cycle. Enumeration: rg -F 'tier_unavailable' /tmp/fix-api-team Sites found: 2 emitters in billing.go (checkout L695, ChangePlan L3001), 1 entry in codeToAgentAction, 2 mentions in openapi.go descriptions, 1 expected-codes list in agent_action_contract_test.go, 3 existing tests that asserted tier_unavailable. Sites touched: 6 — both emitters flipped, registry entry dropped, OpenAPI descriptions updated, agent_action_contract_test expected list updated, 3 existing tests inverted to assert the new positive contract. Coverage test: TestCodeToAgentAction_NoOrphans + TestAgentActionContract_RegistryCoverage (both pre-existing) — any reintroduction of tier_unavailable into emitter code without updating the registry, OR vice versa, fails the gate. Live verified: pending; see PR checklist after deploy. ## Surface checklist (rule 22) - [x] api/plans.yaml — team + team_yearly already defined (no change). - [x] common/plans/plans.go defaultYAML — already in sync (no change). - [x] api/internal/handlers/openapi.go — `plan` enum + 400 description updated for checkout AND change-plan AND the legacy /billing/checkout alias. - [ ] content/llms.txt — verify "Team @ $199/mo, self-serve Razorpay path is rolling out" copy is no longer aspirational. Operator action — content repo has no auto-deploy (project memory operator follow-ups). - [x] dashboard/src/components/upgradeCopy.ts — sweeped, no `tier_unavailable` references. - [x] instanode-web/src/pages/PricingPage.tsx — pricing already lists Team @ $199/mo (no change needed). ## Operator follow-up - Confirm `RAZORPAY_PLAN_ID_TEAM` + `RAZORPAY_PLAN_ID_TEAM_ANNUAL` are populated in the prod k8s `instant-config` ConfigMap. If not, Team checkout will return 503 billing_not_configured (correct operator signal). Track under project memory project_razorpay_recurring_not_enabled.md. ## Companion PR InstaNode-dev/common#35 — mirrors the JWT JSON tag rename in the shared module consumed by worker/provisioner. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/crypto/jwt.go | 10 +- .../handlers/agent_action_contract_test.go | 7 +- internal/handlers/billing.go | 34 ++--- internal/handlers/billing_coverage2_test.go | 73 ++++++++++- internal/handlers/billing_residual_test.go | 14 ++- internal/handlers/billing_test.go | 32 +++-- internal/handlers/helpers.go | 11 +- .../onboarding_jwt_plan_boundary_test.go | 119 ++++++++++++++++++ internal/handlers/openapi.go | 16 +-- 9 files changed, 265 insertions(+), 51 deletions(-) create mode 100644 internal/handlers/onboarding_jwt_plan_boundary_test.go diff --git a/internal/crypto/jwt.go b/internal/crypto/jwt.go index 079b50c1..0c2f1965 100644 --- a/internal/crypto/jwt.go +++ b/internal/crypto/jwt.go @@ -16,7 +16,15 @@ type OnboardingClaims struct { OrgName string `json:"org"` Tokens []string `json:"tok"` ResourceTypes []string `json:"rt"` - SuggestedPlan string `json:"plan"` + // SuggestedPlan is an advisory upsell hint computed at provisioning + // time. It is NOT a tier grant: every server-side tier decision flows + // through teams.plan_tier (hardcoded 'free' on claim) and the + // Razorpay subscription.charged webhook — never through this field. + // Renamed 2026-05-29 from json:"plan" -> json:"suggested_plan" + // (API FINDING-2, P1 hygiene) to keep a future engineer from + // trusting a JWT-supplied tier. See onboarding.go — `claims.SuggestedPlan` + // has zero call sites in production code. + SuggestedPlan string `json:"suggested_plan"` jwt.RegisteredClaims } diff --git a/internal/handlers/agent_action_contract_test.go b/internal/handlers/agent_action_contract_test.go index a765a41a..068edf51 100644 --- a/internal/handlers/agent_action_contract_test.go +++ b/internal/handlers/agent_action_contract_test.go @@ -215,7 +215,12 @@ func TestAgentActionContract_RegistryCoverage(t *testing.T) { // Quota walls. "quota_exceeded", "storage_limit_reached", "vault_quota_exceeded", "vault_not_available", "vault_env_not_allowed", "member_limit", - "upgrade_required", "tier_unavailable", "rate_limit_exceeded", + // "tier_unavailable" was dropped 2026-05-29 alongside the Team-tier + // checkout/change-plan guards (CEO BIZ-1). It was the only code in + // this registry that no handler emitted; the orphan-coverage gate + // flagged it. If a future feature reintroduces a "tier is genuinely + // unavailable" surface, re-add the code + its emitter in one PR. + "upgrade_required", "rate_limit_exceeded", // Auth. "unauthorized", "auth_required", "invalid_token", "missing_token", "vault_requires_auth", "invitation_invalid", "already_accepted", diff --git a/internal/handlers/billing.go b/internal/handlers/billing.go index 3577ff20..1601b061 100644 --- a/internal/handlers/billing.go +++ b/internal/handlers/billing.go @@ -690,17 +690,19 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error { } switch plan { - case "hobby", "hobby_plus", "pro": + case "hobby", "hobby_plus", "pro", "team": // fall through — plan_id is resolved by razorpayPlanIDFor below. - case "team": - // Team tier is under development — block customer-initiated - // subscribe via the public API. The internal /internal/set-tier - // endpoint still works for ops use. Drop this guard when team - // launches (and revert the public pricing UI). - return respondError(c, fiber.StatusBadRequest, "tier_unavailable", - "Team tier is under active development. Email support@instanode.dev to join the early access list.") + // Team enabled 2026-05-29 (CEO BIZ-1 ship call): marketing, + // dashboard PricingGrid, and llms.txt all sell Team @ $199/mo, + // but this handler used to return 400 tier_unavailable on every + // team checkout — turning away the highest-AOV prospect mid-funnel. + // If RAZORPAY_PLAN_ID_TEAM (or RAZORPAY_PLAN_ID_TEAM_ANNUAL) is + // unset in this environment the request now falls through to the + // shared 503 billing_not_configured branch below (a clear + // operator signal), not 400 tier_unavailable (a customer signal + // that the tier itself doesn't exist). default: - return respondError(c, fiber.StatusBadRequest, "invalid_plan", "plan must be 'hobby', 'hobby_plus', or 'pro'") + return respondError(c, fiber.StatusBadRequest, "invalid_plan", "plan must be 'hobby', 'hobby_plus', 'pro', or 'team'") } planID := h.razorpayPlanIDFor(plan, frequency) @@ -2994,13 +2996,13 @@ func (h *BillingHandler) ChangePlanAPI(c *fiber.Ctx) error { "Tell the user that downgrading to a lower plan is support-assisted. Have them email support@instanode.dev with their team and the target plan.", "mailto:support@instanode.dev") } - // Team tier is under development — block customer-initiated upgrades to - // team via the public API. The internal /internal/set-tier endpoint - // still works for ops use. Drop this guard when team launches. - if strings.EqualFold(target, "team") { - return respondError(c, fiber.StatusBadRequest, "tier_unavailable", - "Team tier is under active development. Email support@instanode.dev to join the early access list.") - } + // Team-tier ChangePlan is now allowed for the same reason Team + // checkout is: marketing + dashboard + llms.txt sell Team @ $199/mo + // as a self-serve upgrade path. If the operator hasn't created the + // Razorpay plan_id yet, razorpayPlanIDFor / portal.ChangePlan + // surfaces the configuration error downstream — never 400 + // tier_unavailable from this layer. (Enabled 2026-05-29 alongside + // the checkout-creation team guard removal.) portal := h.billingPortal() if _, err := portal.SubscriptionID(c.Context(), teamID); err != nil { return respondError(c, fiber.StatusBadRequest, "no_subscription", "no active subscription to change") diff --git a/internal/handlers/billing_coverage2_test.go b/internal/handlers/billing_coverage2_test.go index 8f49e778..dbc8cf31 100644 --- a/internal/handlers/billing_coverage2_test.go +++ b/internal/handlers/billing_coverage2_test.go @@ -747,7 +747,15 @@ func TestCov2_ChangePlan_DowngradeNotSelfServe(t *testing.T) { assert.Equal(t, "downgrade_not_self_serve", body["error"]) } -func TestCov2_ChangePlan_TeamTierUnavailable(t *testing.T) { +// TestCov2_ChangePlan_TeamTierAccepted locks in the 2026-05-29 BIZ-1 fix: +// ChangePlan no longer 400-rejects upgrades targeting the Team tier. A +// hobby team with no active Razorpay subscription now flows past the +// (deleted) tier_unavailable guard and is stopped by the downstream +// no_subscription check instead — confirming the team-tier path is +// reachable end-to-end and only fails on a real per-request condition. +// Regression guard: if a future engineer reintroduces the tier_unavailable +// branch on the team tier, this test flips back to RED. +func TestCov2_ChangePlan_TeamTierAccepted(t *testing.T) { cov2NeedsDB(t) db, clean := testhelpers.SetupTestDB(t) defer clean() @@ -757,7 +765,10 @@ func TestCov2_ChangePlan_TeamTierUnavailable(t *testing.T) { app := changePlanAppReal(t, db, cfg, teamID) code, body := changePlanReq(t, app, map[string]any{"target_plan": "team"}) assert.Equal(t, http.StatusBadRequest, code) - assert.Equal(t, "tier_unavailable", body["error"]) + assert.NotEqual(t, "tier_unavailable", body["error"], + "team must no longer be tier_unavailable — marketing+dashboard+llms.txt sell Team @ $199/mo") + assert.Equal(t, "no_subscription", body["error"], + "hobby team has no active subscription → expect the downstream guard, not tier_unavailable") } func TestCov2_ChangePlan_NoSubscription(t *testing.T) { @@ -1288,16 +1299,68 @@ func TestCov2_Checkout_PromoCode_ExpiredSkipsBookkeeping(t *testing.T) { assert.False(t, present, "an expired admin promo code must not stamp the notes") } -func TestCov2_Checkout_TeamTierUnavailable(t *testing.T) { +// TestCov2_Checkout_TeamTierAccepted locks in the 2026-05-29 BIZ-1 fix: +// Team checkout no longer 400s with tier_unavailable. With Razorpay +// credentials + RAZORPAY_PLAN_ID_TEAM configured, the team plan reaches +// the CreateSubscription codepath and returns a short_url like every +// other paid tier. CEO BIZ-1: marketing/dashboard/llms.txt all sell +// Team @ $199/mo; the API now matches that story. +func TestCov2_Checkout_TeamTierAccepted(t *testing.T) { cov2NeedsDB(t) db, clean := testhelpers.SetupTestDB(t) defer clean() cfg := &config.Config{JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", RazorpayKeyID: "k", RazorpayKeySecret: "s", RazorpayPlanIDTeam: "plan_team"} teamID, userID := seedVerifiedTeamUser(t, db, "free") + app, bh := cov2CheckoutApp(t, db, cfg, teamID, userID) + freshSub := "sub_team_" + uuid.NewString() + bh.CreateSubscription = func(_ map[string]any) (map[string]any, error) { + return map[string]any{"id": freshSub, "short_url": "https://rzp.io/team"}, nil + } + code, body := postCheckoutReq(t, app, map[string]any{"plan": "team"}) + require.Equal(t, http.StatusOK, code, "body=%v", body) + assert.Equal(t, freshSub, body["subscription_id"]) + assert.Equal(t, "https://rzp.io/team", body["short_url"]) + assert.NotEqual(t, "tier_unavailable", body["error"], + "Team must no longer return tier_unavailable — see CEO memo 2026-05-29 BIZ-1.") +} + +// TestCov2_Checkout_TeamTierYearlyAccepted mirrors the monthly accept +// for plan_frequency=yearly. RAZORPAY_PLAN_ID_TEAM_ANNUAL configured → +// the yearly Team subscription is created. Regression guard against a +// future engineer wiring yearly behind a separate tier_unavailable check. +func TestCov2_Checkout_TeamTierYearlyAccepted(t *testing.T) { + cov2NeedsDB(t) + db, clean := testhelpers.SetupTestDB(t) + defer clean() + cfg := &config.Config{JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", RazorpayKeyID: "k", RazorpayKeySecret: "s", RazorpayPlanIDTeamYearly: "plan_team_y"} + teamID, userID := seedVerifiedTeamUser(t, db, "free") + app, bh := cov2CheckoutApp(t, db, cfg, teamID, userID) + freshSub := "sub_team_y_" + uuid.NewString() + bh.CreateSubscription = func(_ map[string]any) (map[string]any, error) { + return map[string]any{"id": freshSub, "short_url": "https://rzp.io/team_y"}, nil + } + code, body := postCheckoutReq(t, app, map[string]any{"plan": "team", "plan_frequency": "yearly"}) + require.Equal(t, http.StatusOK, code, "body=%v", body) + assert.Equal(t, freshSub, body["subscription_id"]) +} + +// TestCov2_Checkout_TeamTierNotConfigured covers the operator-config +// gap surfaced by memory `project_razorpay_recurring_not_enabled.md`: +// when RAZORPAY_PLAN_ID_TEAM is unset the request now falls through to +// the shared 503 billing_not_configured branch (clear operator signal) +// rather than 400 tier_unavailable (a customer signal that the tier +// itself doesn't exist). +func TestCov2_Checkout_TeamTierNotConfigured(t *testing.T) { + cov2NeedsDB(t) + db, clean := testhelpers.SetupTestDB(t) + defer clean() + cfg := &config.Config{JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", RazorpayKeyID: "k", RazorpayKeySecret: "s"} + teamID, userID := seedVerifiedTeamUser(t, db, "free") app, _ := cov2CheckoutApp(t, db, cfg, teamID, userID) code, body := postCheckoutReq(t, app, map[string]any{"plan": "team"}) - assert.Equal(t, http.StatusBadRequest, code) - assert.Equal(t, "tier_unavailable", body["error"]) + assert.Equal(t, http.StatusServiceUnavailable, code) + assert.Equal(t, "billing_not_configured", body["error"]) + assert.NotEqual(t, "tier_unavailable", body["error"]) } func TestCov2_Checkout_InvalidPlan(t *testing.T) { diff --git a/internal/handlers/billing_residual_test.go b/internal/handlers/billing_residual_test.go index 0b7b0bc9..c0efd66e 100644 --- a/internal/handlers/billing_residual_test.go +++ b/internal/handlers/billing_residual_test.go @@ -102,8 +102,13 @@ func TestResidualChangePlan_Downgrade_400(t *testing.T) { assert.Equal(t, "downgrade_not_self_serve", body["error"]) } -// TestResidualChangePlan_TeamTier_400 hits tier_unavailable (team is dev-locked). -func TestResidualChangePlan_TeamTier_400(t *testing.T) { +// TestResidualChangePlan_TeamTier_Accepted locks the 2026-05-29 BIZ-1 +// fix at the change-plan surface: a Pro team requesting target_plan=team +// no longer 400s with tier_unavailable. The dev-lock branch was removed +// alongside the matching checkout branch, so the request flows to the +// downstream no_subscription guard (this team has no Razorpay +// subscription_id on file). +func TestResidualChangePlan_TeamTier_Accepted(t *testing.T) { db, clean := testhelpers.SetupTestDB(t) defer clean() teamID := mkVerifiedTeam(t, db, "pro") @@ -112,7 +117,10 @@ func TestResidualChangePlan_TeamTier_400(t *testing.T) { app := billingAppNoAuth(t, db, cfg, teamID) status, body := changePlanPost(t, app, `{"target_plan":"team"}`) assert.Equal(t, http.StatusBadRequest, status) - assert.Equal(t, "tier_unavailable", body["error"]) + assert.NotEqual(t, "tier_unavailable", body["error"], + "target_plan=team must no longer return tier_unavailable") + assert.Equal(t, "no_subscription", body["error"], + "pro team with no Razorpay sub id → expect the downstream no_subscription guard") } // TestResidualChangePlan_NoSubscription_400 hits no_subscription: a valid diff --git a/internal/handlers/billing_test.go b/internal/handlers/billing_test.go index 2af31d5f..eb770492 100644 --- a/internal/handlers/billing_test.go +++ b/internal/handlers/billing_test.go @@ -998,10 +998,19 @@ func TestCheckout_PlanFrequency_MonthlyDefault_NoFrequency(t *testing.T) { assert.Equal(t, "billing_not_configured", body["error"]) } -// TestCheckout_PlanFrequency_TeamGuard_StillFires verifies the team-tier -// guard runs before frequency resolution — team is unavailable on either -// cycle while the multi-seat surface is in development. -func TestCheckout_PlanFrequency_TeamGuard_StillFires(t *testing.T) { +// Note: the full positive-path Team-tier checkout regression lives in +// billing_coverage2_test.go (TestCov2_Checkout_TeamTierAccepted / +// _TeamTierYearlyAccepted / _TeamTierNotConfigured) — those need a +// real test DB to clear the post-validation email-verify gate, so they +// can't run in the no-DB harness this file uses for input-validation +// branches. The no-DB-friendly negative case (typo'd plan still 400s) +// stays here. + +// TestCheckout_RejectsUnknownPlan locks the negative side of the +// 2026-05-29 BIZ-1 fix: a typo'd plan name still returns 400 +// invalid_plan, and the error message now lists team as an accepted +// plan since the dedicated tier_unavailable branch is gone. +func TestCheckout_RejectsUnknownPlan(t *testing.T) { cfg := &config.Config{ JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", RazorpayKeyID: "rzp_test_key", @@ -1010,15 +1019,12 @@ func TestCheckout_PlanFrequency_TeamGuard_StillFires(t *testing.T) { RazorpayPlanIDTeamYearly: "plan_yearly_team", } app := checkoutAppNoDB(t, cfg) - for _, freq := range []string{"monthly", "yearly", ""} { - body := map[string]any{"plan": "team"} - if freq != "" { - body["plan_frequency"] = freq - } - status, resp := postCheckout(t, app, body) - assert.Equal(t, http.StatusBadRequest, status, - "team is locked regardless of frequency=%q", freq) - assert.Equal(t, "tier_unavailable", resp["error"]) + status, resp := postCheckout(t, app, map[string]any{"plan": "teamz"}) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_plan", resp["error"]) + if msg, ok := resp["message"].(string); ok { + assert.Contains(t, msg, "team", + "invalid_plan message should list team as an accepted plan now that the guard is gone") } } diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index 5e343771..eb96911d 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -129,10 +129,13 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user this feature requires the Pro plan or higher. Upgrade at https://instanode.dev/pricing — takes 30 seconds.", UpgradeURL: "https://instanode.dev/pricing", }, - "tier_unavailable": { - AgentAction: "Tell the user this resource type isn't available on their plan. Upgrade to Pro at https://instanode.dev/pricing to unlock it.", - UpgradeURL: "https://instanode.dev/pricing", - }, + // "tier_unavailable" was removed 2026-05-29 along with the Team-tier + // checkout/change-plan guards (CEO BIZ-1). The only emitters of this + // code lived in those two billing branches; with both gone, the + // codeToAgentAction entry was orphan-flagged by + // TestCodeToAgentAction_NoOrphans. If a future feature reintroduces + // a "tier is genuinely unavailable" surface, re-add the entry here + // and emit it from the new site in the same PR. "rate_limit_exceeded": { AgentAction: "Tell the user they've sent too many requests in a short window. Wait 60 seconds and retry — or upgrade to Pro at https://instanode.dev/pricing for higher limits.", UpgradeURL: "https://instanode.dev/pricing", diff --git a/internal/handlers/onboarding_jwt_plan_boundary_test.go b/internal/handlers/onboarding_jwt_plan_boundary_test.go new file mode 100644 index 00000000..1f81eddd --- /dev/null +++ b/internal/handlers/onboarding_jwt_plan_boundary_test.go @@ -0,0 +1,119 @@ +// onboarding_jwt_plan_boundary_test.go +// +// Locks the trust boundary between the onboarding JWT's advisory +// `SuggestedPlan` field and the team's actual `plan_tier`. +// +// Context (API FINDING-2, 2026-05-29): +// - `crypto.OnboardingClaims.SuggestedPlan` is set during anonymous +// provisioning (`provision_helper.go:572`) but has ZERO call sites +// in the production claim path. +// - `onboarding.go` derives tier purely from `models.CreateTeam`'s +// hardcoded SQL literal `plan_tier='free'`. +// - The only path to a paid tier is the Razorpay +// `subscription.charged` webhook calling `ElevateResourceTiersByTeam`. +// +// This test re-signs an onboarding JWT with `SuggestedPlan="team"` while +// reusing the JTI that the server registered in `onboarding_events`, +// posts it to `/claim`, and asserts the resulting team lands on +// `plan_tier="free"`. If a future engineer wires `claims.SuggestedPlan` +// into the claim handler (i.e. starts trusting a JWT-supplied tier), +// this test goes RED. +package handlers_test + +import ( + "net/http" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/crypto" + "instant.dev/internal/testhelpers" +) + +func TestClaimDoesNotTrustJWTPlanField(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + fp := testhelpers.UniqueFingerprint(t) + res := testhelpers.MustProvisionCacheFull(t, app, fp) + require.NotEmpty(t, res.JWT, "provision response must include an onboarding JWT") + defer db.Exec(`DELETE FROM resources WHERE token = $1`, res.Token) + + // Decode the server-issued JWT to lift its JTI + base claims so the + // re-signed token still matches a row in onboarding_events. + parsed, err := crypto.VerifyOnboardingJWT([]byte(testhelpers.TestJWTSecret), res.JWT) + require.NoError(t, err) + require.Equal(t, "hobby", parsed.SuggestedPlan, + "sanity: today's provisioner mints suggested_plan='hobby' (provision_helper.go:572)") + require.NotEmpty(t, parsed.ID, "JWT must carry a JTI registered in onboarding_events") + + // Re-sign with SuggestedPlan="team" — a hostile token claiming the + // highest-AOV tier. JTI is preserved so the row in onboarding_events + // resolves cleanly; the only mutation is the JSON suggested_plan field. + hostile := crypto.OnboardingClaims{ + Fingerprint: parsed.Fingerprint, + Country: parsed.Country, + CloudVendor: parsed.CloudVendor, + OrgName: parsed.OrgName, + Tokens: parsed.Tokens, + ResourceTypes: parsed.ResourceTypes, + SuggestedPlan: "team", + RegisteredClaims: jwt.RegisteredClaims{ + ID: parsed.ID, + IssuedAt: parsed.IssuedAt, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + }, + } + hostileToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, hostile). + SignedString([]byte(testhelpers.TestJWTSecret)) + require.NoError(t, err) + + // Re-verify defence in depth: the re-signed token DOES decode to + // suggested_plan="team", proving the test is exercising the + // "JWT claims a higher tier" surface — not a no-op. + reparsed, err := crypto.VerifyOnboardingJWT([]byte(testhelpers.TestJWTSecret), hostileToken) + require.NoError(t, err) + require.Equal(t, "team", reparsed.SuggestedPlan) + + // POST /claim with the hostile token. + email := testhelpers.UniqueEmail(t) + body := map[string]any{ + "jwt": hostileToken, + "email": email, + "team_name": "jwt-plan-boundary-" + uuid.NewString()[:8], + } + claimResp := testhelpers.PostJSON(t, app, "/claim", body) + defer claimResp.Body.Close() + require.Equal(t, http.StatusCreated, claimResp.StatusCode, + "claim must succeed — the JWT is still server-signed; the boundary lives in the post-verify tier-resolution step") + + // Team's plan_tier must be the hardcoded SQL literal 'free', + // NOT the JWT's advisory 'team'. + var tier string + err = db.QueryRow(` + SELECT plan_tier FROM teams WHERE id = ( + SELECT team_id FROM resources WHERE token = $1 + )`, res.Token).Scan(&tier) + require.NoError(t, err) + assert.Equal(t, "free", tier, + "claim must derive plan_tier from the hardcoded SQL literal in models.CreateTeam, not from the JWT") + + // Resource tier must also be the hardcoded 'free' update, not 'team'. + var resourceTier string + err = db.QueryRow(`SELECT tier FROM resources WHERE token = $1`, res.Token).Scan(&resourceTier) + require.NoError(t, err) + assert.Equal(t, "free", resourceTier, + "resource tier must flip to the hardcoded 'free' literal on claim, not the JWT-supplied tier") + + // Cleanup the team that was created. + db.Exec(`DELETE FROM teams WHERE id = (SELECT team_id FROM resources WHERE token = $1)`, res.Token) +} diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index 620c4222..9dff8938 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -1278,12 +1278,12 @@ const openAPISpec = `{ "/api/v1/billing/checkout": { "post": { "summary": "Create a Razorpay subscription and return its hosted-page URL", - "description": "Mints a Razorpay subscription for the requested plan (hobby, hobby_plus, or pro) tied to the authenticated team. The dashboard redirects the user to the returned short_url to complete payment; on success Razorpay fires subscription.activated AND subscription.charged to /razorpay/webhook — both trigger the same idempotent tier-elevation path so the team is upgraded as soon as the mandate is authorised, even before the first invoice is collected. The Team tier currently returns 400 tier_unavailable — only ops can set it via /internal/set-tier. plan_frequency selects monthly (default) vs yearly billing — yearly returns 503 billing_not_configured until the operator creates the yearly Razorpay plan and sets RAZORPAY_PLAN_ID_*_YEARLY. promotion_code: admin-issued codes are bookmarked in the subscription notes for future discount wiring (no Razorpay Offer is applied yet — codes are not consumed until a real discount is confirmed). IDEMPOTENT: the endpoint never mints a second subscription for a team that already has a live one — if the team already holds the requested tier (or higher) it returns 400 already_on_plan, and if a prior checkout's subscription is still payable at Razorpay (status created/authenticated/pending) it returns that subscription's short_url with reused:true instead of creating a new one. This prevents a confused re-click from producing two parallel subscriptions that both charge the card.", + "description": "Mints a Razorpay subscription for the requested plan (hobby, hobby_plus, pro, or team) tied to the authenticated team. The dashboard redirects the user to the returned short_url to complete payment; on success Razorpay fires subscription.activated AND subscription.charged to /razorpay/webhook — both trigger the same idempotent tier-elevation path so the team is upgraded as soon as the mandate is authorised, even before the first invoice is collected. Team-tier checkout was enabled 2026-05-29 (CEO BIZ-1) — if RAZORPAY_PLAN_ID_TEAM / _TEAM_ANNUAL is unset on the environment the request returns 503 billing_not_configured (operator signal), not 400 tier_unavailable. plan_frequency selects monthly (default) vs yearly billing — yearly returns 503 billing_not_configured until the operator creates the yearly Razorpay plan and sets RAZORPAY_PLAN_ID_*_YEARLY. promotion_code: admin-issued codes are bookmarked in the subscription notes for future discount wiring (no Razorpay Offer is applied yet — codes are not consumed until a real discount is confirmed). IDEMPOTENT: the endpoint never mints a second subscription for a team that already has a live one — if the team already holds the requested tier (or higher) it returns 400 already_on_plan, and if a prior checkout's subscription is still payable at Razorpay (status created/authenticated/pending) it returns that subscription's short_url with reused:true instead of creating a new one. This prevents a confused re-click from producing two parallel subscriptions that both charge the card.", "security": [{ "bearerAuth": [] }], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["plan"], "properties": { "plan": { "type": "string", "enum": ["hobby", "hobby_plus", "pro"] }, "plan_frequency": { "type": "string", "enum": ["monthly", "yearly"], "default": "monthly", "description": "Billing cycle. Empty = monthly. Yearly variants follow the same canonical-tier mapping on the webhook side — teams.plan_tier still stores the bare tier name." } } } } } }, + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["plan"], "properties": { "plan": { "type": "string", "enum": ["hobby", "hobby_plus", "pro", "team"] }, "plan_frequency": { "type": "string", "enum": ["monthly", "yearly"], "default": "monthly", "description": "Billing cycle. Empty = monthly. Yearly variants follow the same canonical-tier mapping on the webhook side — teams.plan_tier still stores the bare tier name." } } } } } }, "responses": { "200": { "description": "Subscription created (or an existing live one reused) — redirect user to short_url. reused:true means the short_url belongs to a checkout the team started earlier and no new subscription was minted.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "short_url": { "type": "string", "format": "uri" }, "subscription_id": { "type": "string" }, "reused": { "type": "boolean", "description": "Present and true only when an existing still-payable subscription was returned instead of minting a new one." } } } } } }, - "400": { "description": "Invalid plan, invalid plan_frequency, tier_unavailable, or already_on_plan (the team already holds the requested tier or higher)" }, + "400": { "description": "Invalid plan, invalid plan_frequency, or already_on_plan (the team already holds the requested tier or higher)" }, "401": { "description": "Missing or invalid session token" }, "502": { "description": "Razorpay rejected the create-subscription call" }, "503": { "description": "Razorpay not configured on this environment (incl. yearly plan_id unset)" } @@ -1317,12 +1317,12 @@ const openAPISpec = `{ "/api/v1/billing/change-plan": { "post": { "summary": "Switch the team's subscription to a different tier", - "description": "Hobby ↔ Hobby Plus ↔ Pro on the same Razorpay subscription. Proration is handled by Razorpay; the new plan takes effect at the end of the current billing period. Team tier is currently not customer-changeable — returns 400 tier_unavailable.", + "description": "Hobby ↔ Hobby Plus ↔ Pro ↔ Team on the same Razorpay subscription. Proration is handled by Razorpay; the new plan takes effect at the end of the current billing period. Team-tier ChangePlan was enabled 2026-05-29 (CEO BIZ-1) alongside Team checkout.", "security": [{ "bearerAuth": [] }], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["plan"], "properties": { "plan": { "type": "string", "enum": ["hobby", "hobby_plus", "pro"] } } } } } }, + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["plan"], "properties": { "plan": { "type": "string", "enum": ["hobby", "hobby_plus", "pro", "team"] } } } } } }, "responses": { "200": { "description": "Plan change accepted by Razorpay" }, - "400": { "description": "Invalid plan or tier_unavailable" }, + "400": { "description": "Invalid plan" }, "401": { "description": "Missing or invalid session token" }, "404": { "description": "No active subscription" }, "503": { "description": "Razorpay not configured" } @@ -1645,10 +1645,10 @@ const openAPISpec = `{ "summary": "Legacy alias for POST /api/v1/billing/checkout", "description": "Kept for backward compatibility with older dashboard/SDK clients. Identical contract to POST /api/v1/billing/checkout. New callers should use the /api/v1 path.", "security": [{ "bearerAuth": [] }], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["plan"], "properties": { "plan": { "type": "string", "enum": ["hobby", "hobby_plus", "pro"] } } } } } }, + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["plan"], "properties": { "plan": { "type": "string", "enum": ["hobby", "hobby_plus", "pro", "team"] } } } } } }, "responses": { "200": { "description": "Subscription created — redirect user to short_url", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "short_url": { "type": "string", "format": "uri" }, "subscription_id": { "type": "string" } } } } } }, - "400": { "description": "Invalid plan or tier_unavailable" }, + "400": { "description": "Invalid plan" }, "401": { "description": "Missing or invalid session token" }, "502": { "description": "Razorpay rejected the create-subscription call" }, "503": { "description": "Razorpay not configured on this environment" }