Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion internal/crypto/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
7 changes: 6 additions & 1 deletion internal/handlers/agent_action_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 18 additions & 16 deletions internal/handlers/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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")
Expand Down
73 changes: 68 additions & 5 deletions internal/handlers/billing_coverage2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
14 changes: 11 additions & 3 deletions internal/handlers/billing_residual_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down
32 changes: 19 additions & 13 deletions internal/handlers/billing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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")
}
}

Expand Down
11 changes: 7 additions & 4 deletions internal/handlers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
119 changes: 119 additions & 0 deletions internal/handlers/onboarding_jwt_plan_boundary_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading