Skip to content

Commit bdf2fdd

Browse files
fix(billing): accept Team-tier Razorpay subscription checkout
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) <noreply@anthropic.com>
1 parent 2eb6b03 commit bdf2fdd

9 files changed

Lines changed: 265 additions & 51 deletions

internal/crypto/jwt.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,15 @@ type OnboardingClaims struct {
1616
OrgName string `json:"org"`
1717
Tokens []string `json:"tok"`
1818
ResourceTypes []string `json:"rt"`
19-
SuggestedPlan string `json:"plan"`
19+
// SuggestedPlan is an advisory upsell hint computed at provisioning
20+
// time. It is NOT a tier grant: every server-side tier decision flows
21+
// through teams.plan_tier (hardcoded 'free' on claim) and the
22+
// Razorpay subscription.charged webhook — never through this field.
23+
// Renamed 2026-05-29 from json:"plan" -> json:"suggested_plan"
24+
// (API FINDING-2, P1 hygiene) to keep a future engineer from
25+
// trusting a JWT-supplied tier. See onboarding.go — `claims.SuggestedPlan`
26+
// has zero call sites in production code.
27+
SuggestedPlan string `json:"suggested_plan"`
2028
jwt.RegisteredClaims
2129
}
2230

internal/handlers/agent_action_contract_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,12 @@ func TestAgentActionContract_RegistryCoverage(t *testing.T) {
215215
// Quota walls.
216216
"quota_exceeded", "storage_limit_reached", "vault_quota_exceeded",
217217
"vault_not_available", "vault_env_not_allowed", "member_limit",
218-
"upgrade_required", "tier_unavailable", "rate_limit_exceeded",
218+
// "tier_unavailable" was dropped 2026-05-29 alongside the Team-tier
219+
// checkout/change-plan guards (CEO BIZ-1). It was the only code in
220+
// this registry that no handler emitted; the orphan-coverage gate
221+
// flagged it. If a future feature reintroduces a "tier is genuinely
222+
// unavailable" surface, re-add the code + its emitter in one PR.
223+
"upgrade_required", "rate_limit_exceeded",
219224
// Auth.
220225
"unauthorized", "auth_required", "invalid_token", "missing_token",
221226
"vault_requires_auth", "invitation_invalid", "already_accepted",

internal/handlers/billing.go

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -690,17 +690,19 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error {
690690
}
691691

692692
switch plan {
693-
case "hobby", "hobby_plus", "pro":
693+
case "hobby", "hobby_plus", "pro", "team":
694694
// fall through — plan_id is resolved by razorpayPlanIDFor below.
695-
case "team":
696-
// Team tier is under development — block customer-initiated
697-
// subscribe via the public API. The internal /internal/set-tier
698-
// endpoint still works for ops use. Drop this guard when team
699-
// launches (and revert the public pricing UI).
700-
return respondError(c, fiber.StatusBadRequest, "tier_unavailable",
701-
"Team tier is under active development. Email support@instanode.dev to join the early access list.")
695+
// Team enabled 2026-05-29 (CEO BIZ-1 ship call): marketing,
696+
// dashboard PricingGrid, and llms.txt all sell Team @ $199/mo,
697+
// but this handler used to return 400 tier_unavailable on every
698+
// team checkout — turning away the highest-AOV prospect mid-funnel.
699+
// If RAZORPAY_PLAN_ID_TEAM (or RAZORPAY_PLAN_ID_TEAM_ANNUAL) is
700+
// unset in this environment the request now falls through to the
701+
// shared 503 billing_not_configured branch below (a clear
702+
// operator signal), not 400 tier_unavailable (a customer signal
703+
// that the tier itself doesn't exist).
702704
default:
703-
return respondError(c, fiber.StatusBadRequest, "invalid_plan", "plan must be 'hobby', 'hobby_plus', or 'pro'")
705+
return respondError(c, fiber.StatusBadRequest, "invalid_plan", "plan must be 'hobby', 'hobby_plus', 'pro', or 'team'")
704706
}
705707
planID := h.razorpayPlanIDFor(plan, frequency)
706708

@@ -2994,13 +2996,13 @@ func (h *BillingHandler) ChangePlanAPI(c *fiber.Ctx) error {
29942996
"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.",
29952997
"mailto:support@instanode.dev")
29962998
}
2997-
// Team tier is under development — block customer-initiated upgrades to
2998-
// team via the public API. The internal /internal/set-tier endpoint
2999-
// still works for ops use. Drop this guard when team launches.
3000-
if strings.EqualFold(target, "team") {
3001-
return respondError(c, fiber.StatusBadRequest, "tier_unavailable",
3002-
"Team tier is under active development. Email support@instanode.dev to join the early access list.")
3003-
}
2999+
// Team-tier ChangePlan is now allowed for the same reason Team
3000+
// checkout is: marketing + dashboard + llms.txt sell Team @ $199/mo
3001+
// as a self-serve upgrade path. If the operator hasn't created the
3002+
// Razorpay plan_id yet, razorpayPlanIDFor / portal.ChangePlan
3003+
// surfaces the configuration error downstream — never 400
3004+
// tier_unavailable from this layer. (Enabled 2026-05-29 alongside
3005+
// the checkout-creation team guard removal.)
30043006
portal := h.billingPortal()
30053007
if _, err := portal.SubscriptionID(c.Context(), teamID); err != nil {
30063008
return respondError(c, fiber.StatusBadRequest, "no_subscription", "no active subscription to change")

internal/handlers/billing_coverage2_test.go

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -747,7 +747,15 @@ func TestCov2_ChangePlan_DowngradeNotSelfServe(t *testing.T) {
747747
assert.Equal(t, "downgrade_not_self_serve", body["error"])
748748
}
749749

750-
func TestCov2_ChangePlan_TeamTierUnavailable(t *testing.T) {
750+
// TestCov2_ChangePlan_TeamTierAccepted locks in the 2026-05-29 BIZ-1 fix:
751+
// ChangePlan no longer 400-rejects upgrades targeting the Team tier. A
752+
// hobby team with no active Razorpay subscription now flows past the
753+
// (deleted) tier_unavailable guard and is stopped by the downstream
754+
// no_subscription check instead — confirming the team-tier path is
755+
// reachable end-to-end and only fails on a real per-request condition.
756+
// Regression guard: if a future engineer reintroduces the tier_unavailable
757+
// branch on the team tier, this test flips back to RED.
758+
func TestCov2_ChangePlan_TeamTierAccepted(t *testing.T) {
751759
cov2NeedsDB(t)
752760
db, clean := testhelpers.SetupTestDB(t)
753761
defer clean()
@@ -757,7 +765,10 @@ func TestCov2_ChangePlan_TeamTierUnavailable(t *testing.T) {
757765
app := changePlanAppReal(t, db, cfg, teamID)
758766
code, body := changePlanReq(t, app, map[string]any{"target_plan": "team"})
759767
assert.Equal(t, http.StatusBadRequest, code)
760-
assert.Equal(t, "tier_unavailable", body["error"])
768+
assert.NotEqual(t, "tier_unavailable", body["error"],
769+
"team must no longer be tier_unavailable — marketing+dashboard+llms.txt sell Team @ $199/mo")
770+
assert.Equal(t, "no_subscription", body["error"],
771+
"hobby team has no active subscription → expect the downstream guard, not tier_unavailable")
761772
}
762773

763774
func TestCov2_ChangePlan_NoSubscription(t *testing.T) {
@@ -1288,16 +1299,68 @@ func TestCov2_Checkout_PromoCode_ExpiredSkipsBookkeeping(t *testing.T) {
12881299
assert.False(t, present, "an expired admin promo code must not stamp the notes")
12891300
}
12901301

1291-
func TestCov2_Checkout_TeamTierUnavailable(t *testing.T) {
1302+
// TestCov2_Checkout_TeamTierAccepted locks in the 2026-05-29 BIZ-1 fix:
1303+
// Team checkout no longer 400s with tier_unavailable. With Razorpay
1304+
// credentials + RAZORPAY_PLAN_ID_TEAM configured, the team plan reaches
1305+
// the CreateSubscription codepath and returns a short_url like every
1306+
// other paid tier. CEO BIZ-1: marketing/dashboard/llms.txt all sell
1307+
// Team @ $199/mo; the API now matches that story.
1308+
func TestCov2_Checkout_TeamTierAccepted(t *testing.T) {
12921309
cov2NeedsDB(t)
12931310
db, clean := testhelpers.SetupTestDB(t)
12941311
defer clean()
12951312
cfg := &config.Config{JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", RazorpayKeyID: "k", RazorpayKeySecret: "s", RazorpayPlanIDTeam: "plan_team"}
12961313
teamID, userID := seedVerifiedTeamUser(t, db, "free")
1314+
app, bh := cov2CheckoutApp(t, db, cfg, teamID, userID)
1315+
freshSub := "sub_team_" + uuid.NewString()
1316+
bh.CreateSubscription = func(_ map[string]any) (map[string]any, error) {
1317+
return map[string]any{"id": freshSub, "short_url": "https://rzp.io/team"}, nil
1318+
}
1319+
code, body := postCheckoutReq(t, app, map[string]any{"plan": "team"})
1320+
require.Equal(t, http.StatusOK, code, "body=%v", body)
1321+
assert.Equal(t, freshSub, body["subscription_id"])
1322+
assert.Equal(t, "https://rzp.io/team", body["short_url"])
1323+
assert.NotEqual(t, "tier_unavailable", body["error"],
1324+
"Team must no longer return tier_unavailable — see CEO memo 2026-05-29 BIZ-1.")
1325+
}
1326+
1327+
// TestCov2_Checkout_TeamTierYearlyAccepted mirrors the monthly accept
1328+
// for plan_frequency=yearly. RAZORPAY_PLAN_ID_TEAM_ANNUAL configured →
1329+
// the yearly Team subscription is created. Regression guard against a
1330+
// future engineer wiring yearly behind a separate tier_unavailable check.
1331+
func TestCov2_Checkout_TeamTierYearlyAccepted(t *testing.T) {
1332+
cov2NeedsDB(t)
1333+
db, clean := testhelpers.SetupTestDB(t)
1334+
defer clean()
1335+
cfg := &config.Config{JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", RazorpayKeyID: "k", RazorpayKeySecret: "s", RazorpayPlanIDTeamYearly: "plan_team_y"}
1336+
teamID, userID := seedVerifiedTeamUser(t, db, "free")
1337+
app, bh := cov2CheckoutApp(t, db, cfg, teamID, userID)
1338+
freshSub := "sub_team_y_" + uuid.NewString()
1339+
bh.CreateSubscription = func(_ map[string]any) (map[string]any, error) {
1340+
return map[string]any{"id": freshSub, "short_url": "https://rzp.io/team_y"}, nil
1341+
}
1342+
code, body := postCheckoutReq(t, app, map[string]any{"plan": "team", "plan_frequency": "yearly"})
1343+
require.Equal(t, http.StatusOK, code, "body=%v", body)
1344+
assert.Equal(t, freshSub, body["subscription_id"])
1345+
}
1346+
1347+
// TestCov2_Checkout_TeamTierNotConfigured covers the operator-config
1348+
// gap surfaced by memory `project_razorpay_recurring_not_enabled.md`:
1349+
// when RAZORPAY_PLAN_ID_TEAM is unset the request now falls through to
1350+
// the shared 503 billing_not_configured branch (clear operator signal)
1351+
// rather than 400 tier_unavailable (a customer signal that the tier
1352+
// itself doesn't exist).
1353+
func TestCov2_Checkout_TeamTierNotConfigured(t *testing.T) {
1354+
cov2NeedsDB(t)
1355+
db, clean := testhelpers.SetupTestDB(t)
1356+
defer clean()
1357+
cfg := &config.Config{JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", RazorpayKeyID: "k", RazorpayKeySecret: "s"}
1358+
teamID, userID := seedVerifiedTeamUser(t, db, "free")
12971359
app, _ := cov2CheckoutApp(t, db, cfg, teamID, userID)
12981360
code, body := postCheckoutReq(t, app, map[string]any{"plan": "team"})
1299-
assert.Equal(t, http.StatusBadRequest, code)
1300-
assert.Equal(t, "tier_unavailable", body["error"])
1361+
assert.Equal(t, http.StatusServiceUnavailable, code)
1362+
assert.Equal(t, "billing_not_configured", body["error"])
1363+
assert.NotEqual(t, "tier_unavailable", body["error"])
13011364
}
13021365

13031366
func TestCov2_Checkout_InvalidPlan(t *testing.T) {

internal/handlers/billing_residual_test.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,13 @@ func TestResidualChangePlan_Downgrade_400(t *testing.T) {
102102
assert.Equal(t, "downgrade_not_self_serve", body["error"])
103103
}
104104

105-
// TestResidualChangePlan_TeamTier_400 hits tier_unavailable (team is dev-locked).
106-
func TestResidualChangePlan_TeamTier_400(t *testing.T) {
105+
// TestResidualChangePlan_TeamTier_Accepted locks the 2026-05-29 BIZ-1
106+
// fix at the change-plan surface: a Pro team requesting target_plan=team
107+
// no longer 400s with tier_unavailable. The dev-lock branch was removed
108+
// alongside the matching checkout branch, so the request flows to the
109+
// downstream no_subscription guard (this team has no Razorpay
110+
// subscription_id on file).
111+
func TestResidualChangePlan_TeamTier_Accepted(t *testing.T) {
107112
db, clean := testhelpers.SetupTestDB(t)
108113
defer clean()
109114
teamID := mkVerifiedTeam(t, db, "pro")
@@ -112,7 +117,10 @@ func TestResidualChangePlan_TeamTier_400(t *testing.T) {
112117
app := billingAppNoAuth(t, db, cfg, teamID)
113118
status, body := changePlanPost(t, app, `{"target_plan":"team"}`)
114119
assert.Equal(t, http.StatusBadRequest, status)
115-
assert.Equal(t, "tier_unavailable", body["error"])
120+
assert.NotEqual(t, "tier_unavailable", body["error"],
121+
"target_plan=team must no longer return tier_unavailable")
122+
assert.Equal(t, "no_subscription", body["error"],
123+
"pro team with no Razorpay sub id → expect the downstream no_subscription guard")
116124
}
117125

118126
// TestResidualChangePlan_NoSubscription_400 hits no_subscription: a valid

internal/handlers/billing_test.go

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -998,10 +998,19 @@ func TestCheckout_PlanFrequency_MonthlyDefault_NoFrequency(t *testing.T) {
998998
assert.Equal(t, "billing_not_configured", body["error"])
999999
}
10001000

1001-
// TestCheckout_PlanFrequency_TeamGuard_StillFires verifies the team-tier
1002-
// guard runs before frequency resolution — team is unavailable on either
1003-
// cycle while the multi-seat surface is in development.
1004-
func TestCheckout_PlanFrequency_TeamGuard_StillFires(t *testing.T) {
1001+
// Note: the full positive-path Team-tier checkout regression lives in
1002+
// billing_coverage2_test.go (TestCov2_Checkout_TeamTierAccepted /
1003+
// _TeamTierYearlyAccepted / _TeamTierNotConfigured) — those need a
1004+
// real test DB to clear the post-validation email-verify gate, so they
1005+
// can't run in the no-DB harness this file uses for input-validation
1006+
// branches. The no-DB-friendly negative case (typo'd plan still 400s)
1007+
// stays here.
1008+
1009+
// TestCheckout_RejectsUnknownPlan locks the negative side of the
1010+
// 2026-05-29 BIZ-1 fix: a typo'd plan name still returns 400
1011+
// invalid_plan, and the error message now lists team as an accepted
1012+
// plan since the dedicated tier_unavailable branch is gone.
1013+
func TestCheckout_RejectsUnknownPlan(t *testing.T) {
10051014
cfg := &config.Config{
10061015
JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!",
10071016
RazorpayKeyID: "rzp_test_key",
@@ -1010,15 +1019,12 @@ func TestCheckout_PlanFrequency_TeamGuard_StillFires(t *testing.T) {
10101019
RazorpayPlanIDTeamYearly: "plan_yearly_team",
10111020
}
10121021
app := checkoutAppNoDB(t, cfg)
1013-
for _, freq := range []string{"monthly", "yearly", ""} {
1014-
body := map[string]any{"plan": "team"}
1015-
if freq != "" {
1016-
body["plan_frequency"] = freq
1017-
}
1018-
status, resp := postCheckout(t, app, body)
1019-
assert.Equal(t, http.StatusBadRequest, status,
1020-
"team is locked regardless of frequency=%q", freq)
1021-
assert.Equal(t, "tier_unavailable", resp["error"])
1022+
status, resp := postCheckout(t, app, map[string]any{"plan": "teamz"})
1023+
assert.Equal(t, http.StatusBadRequest, status)
1024+
assert.Equal(t, "invalid_plan", resp["error"])
1025+
if msg, ok := resp["message"].(string); ok {
1026+
assert.Contains(t, msg, "team",
1027+
"invalid_plan message should list team as an accepted plan now that the guard is gone")
10221028
}
10231029
}
10241030

internal/handlers/helpers.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,13 @@ var codeToAgentAction = map[string]errorCodeMeta{
129129
AgentAction: "Tell the user this feature requires the Pro plan or higher. Upgrade at https://instanode.dev/pricing — takes 30 seconds.",
130130
UpgradeURL: "https://instanode.dev/pricing",
131131
},
132-
"tier_unavailable": {
133-
AgentAction: "Tell the user this resource type isn't available on their plan. Upgrade to Pro at https://instanode.dev/pricing to unlock it.",
134-
UpgradeURL: "https://instanode.dev/pricing",
135-
},
132+
// "tier_unavailable" was removed 2026-05-29 along with the Team-tier
133+
// checkout/change-plan guards (CEO BIZ-1). The only emitters of this
134+
// code lived in those two billing branches; with both gone, the
135+
// codeToAgentAction entry was orphan-flagged by
136+
// TestCodeToAgentAction_NoOrphans. If a future feature reintroduces
137+
// a "tier is genuinely unavailable" surface, re-add the entry here
138+
// and emit it from the new site in the same PR.
136139
"rate_limit_exceeded": {
137140
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.",
138141
UpgradeURL: "https://instanode.dev/pricing",
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// onboarding_jwt_plan_boundary_test.go
2+
//
3+
// Locks the trust boundary between the onboarding JWT's advisory
4+
// `SuggestedPlan` field and the team's actual `plan_tier`.
5+
//
6+
// Context (API FINDING-2, 2026-05-29):
7+
// - `crypto.OnboardingClaims.SuggestedPlan` is set during anonymous
8+
// provisioning (`provision_helper.go:572`) but has ZERO call sites
9+
// in the production claim path.
10+
// - `onboarding.go` derives tier purely from `models.CreateTeam`'s
11+
// hardcoded SQL literal `plan_tier='free'`.
12+
// - The only path to a paid tier is the Razorpay
13+
// `subscription.charged` webhook calling `ElevateResourceTiersByTeam`.
14+
//
15+
// This test re-signs an onboarding JWT with `SuggestedPlan="team"` while
16+
// reusing the JTI that the server registered in `onboarding_events`,
17+
// posts it to `/claim`, and asserts the resulting team lands on
18+
// `plan_tier="free"`. If a future engineer wires `claims.SuggestedPlan`
19+
// into the claim handler (i.e. starts trusting a JWT-supplied tier),
20+
// this test goes RED.
21+
package handlers_test
22+
23+
import (
24+
"net/http"
25+
"testing"
26+
"time"
27+
28+
"github.com/golang-jwt/jwt/v4"
29+
"github.com/google/uuid"
30+
"github.com/stretchr/testify/assert"
31+
"github.com/stretchr/testify/require"
32+
33+
"instant.dev/internal/crypto"
34+
"instant.dev/internal/testhelpers"
35+
)
36+
37+
func TestClaimDoesNotTrustJWTPlanField(t *testing.T) {
38+
db, cleanDB := testhelpers.SetupTestDB(t)
39+
defer cleanDB()
40+
rdb, cleanRedis := testhelpers.SetupTestRedis(t)
41+
defer cleanRedis()
42+
43+
app, cleanApp := testhelpers.NewTestApp(t, db, rdb)
44+
defer cleanApp()
45+
46+
fp := testhelpers.UniqueFingerprint(t)
47+
res := testhelpers.MustProvisionCacheFull(t, app, fp)
48+
require.NotEmpty(t, res.JWT, "provision response must include an onboarding JWT")
49+
defer db.Exec(`DELETE FROM resources WHERE token = $1`, res.Token)
50+
51+
// Decode the server-issued JWT to lift its JTI + base claims so the
52+
// re-signed token still matches a row in onboarding_events.
53+
parsed, err := crypto.VerifyOnboardingJWT([]byte(testhelpers.TestJWTSecret), res.JWT)
54+
require.NoError(t, err)
55+
require.Equal(t, "hobby", parsed.SuggestedPlan,
56+
"sanity: today's provisioner mints suggested_plan='hobby' (provision_helper.go:572)")
57+
require.NotEmpty(t, parsed.ID, "JWT must carry a JTI registered in onboarding_events")
58+
59+
// Re-sign with SuggestedPlan="team" — a hostile token claiming the
60+
// highest-AOV tier. JTI is preserved so the row in onboarding_events
61+
// resolves cleanly; the only mutation is the JSON suggested_plan field.
62+
hostile := crypto.OnboardingClaims{
63+
Fingerprint: parsed.Fingerprint,
64+
Country: parsed.Country,
65+
CloudVendor: parsed.CloudVendor,
66+
OrgName: parsed.OrgName,
67+
Tokens: parsed.Tokens,
68+
ResourceTypes: parsed.ResourceTypes,
69+
SuggestedPlan: "team",
70+
RegisteredClaims: jwt.RegisteredClaims{
71+
ID: parsed.ID,
72+
IssuedAt: parsed.IssuedAt,
73+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
74+
},
75+
}
76+
hostileToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, hostile).
77+
SignedString([]byte(testhelpers.TestJWTSecret))
78+
require.NoError(t, err)
79+
80+
// Re-verify defence in depth: the re-signed token DOES decode to
81+
// suggested_plan="team", proving the test is exercising the
82+
// "JWT claims a higher tier" surface — not a no-op.
83+
reparsed, err := crypto.VerifyOnboardingJWT([]byte(testhelpers.TestJWTSecret), hostileToken)
84+
require.NoError(t, err)
85+
require.Equal(t, "team", reparsed.SuggestedPlan)
86+
87+
// POST /claim with the hostile token.
88+
email := testhelpers.UniqueEmail(t)
89+
body := map[string]any{
90+
"jwt": hostileToken,
91+
"email": email,
92+
"team_name": "jwt-plan-boundary-" + uuid.NewString()[:8],
93+
}
94+
claimResp := testhelpers.PostJSON(t, app, "/claim", body)
95+
defer claimResp.Body.Close()
96+
require.Equal(t, http.StatusCreated, claimResp.StatusCode,
97+
"claim must succeed — the JWT is still server-signed; the boundary lives in the post-verify tier-resolution step")
98+
99+
// Team's plan_tier must be the hardcoded SQL literal 'free',
100+
// NOT the JWT's advisory 'team'.
101+
var tier string
102+
err = db.QueryRow(`
103+
SELECT plan_tier FROM teams WHERE id = (
104+
SELECT team_id FROM resources WHERE token = $1
105+
)`, res.Token).Scan(&tier)
106+
require.NoError(t, err)
107+
assert.Equal(t, "free", tier,
108+
"claim must derive plan_tier from the hardcoded SQL literal in models.CreateTeam, not from the JWT")
109+
110+
// Resource tier must also be the hardcoded 'free' update, not 'team'.
111+
var resourceTier string
112+
err = db.QueryRow(`SELECT tier FROM resources WHERE token = $1`, res.Token).Scan(&resourceTier)
113+
require.NoError(t, err)
114+
assert.Equal(t, "free", resourceTier,
115+
"resource tier must flip to the hardcoded 'free' literal on claim, not the JWT-supplied tier")
116+
117+
// Cleanup the team that was created.
118+
db.Exec(`DELETE FROM teams WHERE id = (SELECT team_id FROM resources WHERE token = $1)`, res.Token)
119+
}

0 commit comments

Comments
 (0)