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
28 changes: 8 additions & 20 deletions internal/handlers/admin_customers.go
Original file line number Diff line number Diff line change
Expand Up @@ -649,9 +649,14 @@ func (h *AdminCustomersHandler) ChangeTier(c *fiber.Ctx) error {
return respondError(c, fiber.StatusServiceUnavailable, "db_failed", "Failed to update tier")
}

fromR := adminTierRank(fromTier)
toR := adminTierRank(req.Tier)
isDemote := toR < fromR && fromR > 0 && toR >= 0
fromR := plans.Rank(fromTier)
toR := plans.Rank(req.Tier)
// Guard against the -1 sentinel (unknown tier on either side).
// adminAllowedTiers already restricts req.Tier to {free,hobby,pro,team}
// at validate-time, but fromTier comes straight from the DB and could
// historically have been anonymous/growth on some teams — treat any
// negative rank as "no transition direction" rather than guessing.
isDemote := fromR >= 0 && toR >= 0 && toR < fromR

// Promote existing permanent resources only when this is a real
// promotion (rank goes up). Downgrades leave existing rows on their
Expand Down Expand Up @@ -900,23 +905,6 @@ func (h *AdminCustomersHandler) computeMRR(tier string) (int, int) {
return monthly, monthly * 12
}

// adminTierRank returns the rank of a tier for promote-vs-downgrade
// detection. Higher = more privileged. Unknown tiers rank as -1 so they
// never trigger an unintended elevation.
func adminTierRank(tier string) int {
switch tier {
case AdminTierTeam:
return 4
case AdminTierPro:
return 3
case AdminTierHobby:
return 2
case AdminTierFree:
return 1
}
return -1
}

// adminParseTierFilter parses the ?tier query value into the deduped set
// of valid tier strings to OR together in the WHERE clause.
//
Expand Down
28 changes: 3 additions & 25 deletions internal/handlers/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"instant.dev/internal/metrics"
"instant.dev/internal/middleware"
"instant.dev/internal/models"
"instant.dev/internal/plans"
"instant.dev/internal/razorpaybilling"
)

Expand Down Expand Up @@ -999,29 +1000,6 @@ func (h *BillingHandler) ChangePlanAPI(c *fiber.Ctx) error {
})
}

// tierRank maps a plan tier name to a totally-ordered rank used to classify
// transitions as upgrade vs downgrade. Higher rank = more capacity.
// Unknown tiers map to -1 so any comparison involving them returns the safe
// "no transition direction" verdict (callers emit nothing rather than a
// misleading audit row).
func tierRank(tier string) int {
switch strings.ToLower(strings.TrimSpace(tier)) {
case "anonymous":
return 0
case "free":
return 1
case "hobby":
return 2
case "growth":
return 3
case "pro":
return 4
case "team":
return 5
}
return -1
}

// emitSubscriptionChangeAudit writes a subscription.upgraded or
// subscription.downgraded row for the Loops forwarder when a charged-webhook
// transition strictly changes the team's tier. Same-tier renewals (the
Expand All @@ -1032,8 +1010,8 @@ func tierRank(tier string) int {
// from the webhook handler because the handler already runs in a request
// goroutine that completes before Razorpay sees a 200.
func emitSubscriptionChangeAudit(ctx context.Context, db *sql.DB, teamID uuid.UUID, fromTier, toTier, subID string) {
fromR := tierRank(fromTier)
toR := tierRank(toTier)
fromR := plans.Rank(fromTier)
toR := plans.Rank(toTier)
// Unknown tiers (-1) or no-change cases produce no audit row.
if fromR < 0 || toR < 0 || fromR == toR {
return
Expand Down
56 changes: 40 additions & 16 deletions internal/middleware/env_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,22 +181,15 @@ func RequireEnvAccess(action string, opts ...EnvPolicyOption) fiber.Handler {
}
}

// Build the agent_action prose. We list allowed roles in the
// human-readable form (comma-separated, last joined with "or").
agentRole := role
if agentRole == "" {
agentRole = "unknown"
}
// agent_action conforms to the U3 contract — see
// internal/handlers/agent_action.go. The middleware lives in a
// different package, so the string is built inline here; the
// shape (open with "Tell the user", name the specific reason,
// name the exact next action, include full https://instanode.dev/ URL)
// must stay in sync.
agentAction := fmt.Sprintf(
"Tell the user the %s env requires the %s role to %s. Their role is %s — have a team owner run the prompt at https://instanode.dev/app/team or adjust the env-policy.",
env, formatAllowedRoles(allowed), action, agentRole,
)
// Build the agent_action prose via the named builder. Extracted
// from an inline fmt.Sprintf so the contract-review grep
// (`grep "agent_action" internal/middleware`) surfaces every
// middleware-level agent_action string in one place, alongside
// unauthorizedAgentAction (auth.go) and adminForbiddenAgentAction
// (admin.go). The middleware can't import handlers/agent_action.go
// (cycle), so the builder lives in this package — same pattern as
// the other two middleware-level constants.
agentAction := envPolicyDeniedAgentAction(env, formatAllowedRoles(allowed), action, role)
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"ok": false,
"error": "env_policy_denied",
Expand Down Expand Up @@ -275,6 +268,37 @@ func defaultEnvLookup(c *fiber.Ctx) (string, error) {
return "", nil
}

// envPolicyDeniedAgentAction is the canonical agent_action sentence served
// on every 403 from RequireEnvAccess. Mirrors the U3 contract shape used by
// handlers/agent_action.go::newAgentActionEnvPolicyDenied:
//
// - opens with "Tell the user"
// - names the specific reason (env + required role + action)
// - exact next action ("have a team owner run the prompt")
// - full https://instanode.dev/... URL
//
// Duplicated here (rather than imported from handlers) because middleware is
// depended on by handlers, not the other way around — a cross-import would
// close a cycle. Same justification as unauthorizedAgentAction (auth.go) and
// adminForbiddenAgentAction (admin.go). The handlers builder is the source
// of truth; if the prose changes, update both.
//
// The contract test (handlers.TestAgentActionContract) can't reach into
// middleware without an import cycle. The shape (Tell the user / specific
// reason / next action / https URL) MUST stay in sync with the handlers
// builder by hand — covered by env_policy_test.go assertions on the 403
// response body.
func envPolicyDeniedAgentAction(env, allowedRoles, action, callerRole string) string {
agentRole := callerRole
if agentRole == "" {
agentRole = "unknown"
}
return fmt.Sprintf(
"Tell the user the %s env requires the %s role to %s. Their role is %s — have a team owner run the prompt at https://instanode.dev/app/team or adjust the env-policy.",
env, allowedRoles, action, agentRole,
)
}

// formatAllowedRoles renders ["owner"] as "owner", ["owner","developer"] as
// "owner or developer", and longer lists with Oxford comma. Used in the
// agent_action prose.
Expand Down
30 changes: 29 additions & 1 deletion internal/middleware/quota.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,34 @@ import (
// can override it (e.g. point at a custom billing portal).
var QuotaUpgradeURL = "https://instanode.dev/pricing"

// quotaExceededAgentAction builds the canonical agent_action sentence served
// on every 402 from PaymentRequired. Mirrors the "quota_exceeded" entry in
// handlers.codeToAgentAction — the U3 contract shape is identical:
//
// - opens with "Tell the user"
// - names the specific reason ("plan's usage limit")
// - exact next action ("upgrade")
// - full https://instanode.dev/... URL via QuotaUpgradeURL
//
// Built as a function (not a const) because QuotaUpgradeURL is a `var` so
// tests + self-hosted operators can override it — a const would freeze the
// URL at package-init time and silently ignore the override.
//
// Kept as a package-private builder rather than inlined at the call site so
// the contract review (grep "agent_action" internal/middleware) surfaces
// every middleware-level agent_action string in this file alongside
// unauthorizedAgentAction (auth.go) and adminForbiddenAgentAction (admin.go).
// Duplicated rather than imported because middleware is depended on by
// handlers, not the other way around (cross-import would close a cycle —
// same justification as the other two middleware-level constants).
//
// The contract test (handlers.TestAgentActionContract) can't reach into
// middleware without an import cycle, so this builder is exercised by the
// existing PaymentRequired tests which assert the response-body shape.
func quotaExceededAgentAction() string {
return "Tell the user they've hit their plan's usage limit. To unlock more, have them upgrade at " + QuotaUpgradeURL + "."
}

// PaymentRequired writes a 402 response with the canonical instanode.dev
// shape used across all quota-exceeded paths:
//
Expand Down Expand Up @@ -61,6 +89,6 @@ func PaymentRequired(c *fiber.Ctx, errKey string) error {
"ok": false,
"error": errKey,
"upgrade_url": QuotaUpgradeURL,
"agent_action": "Tell the user they've hit their plan's usage limit. To unlock more, have them upgrade at " + QuotaUpgradeURL + ".",
"agent_action": quotaExceededAgentAction(),
})
}
11 changes: 11 additions & 0 deletions internal/plans/plans.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,14 @@ func Default() *Registry { return commonplans.Default() }
// base tier (e.g. "pro_yearly" -> "pro"). Re-exported from common/plans so
// handlers in this module don't need to import the shared package directly.
func CanonicalTier(tier string) string { return commonplans.CanonicalTier(tier) }

// Rank returns the totally-ordered rank of the given plan tier. Higher rank
// = more capacity (anonymous=0, free=1, hobby=2, growth=3, pro=4, team=5).
// Unknown tiers return -1 — callers MUST guard against the sentinel when
// comparing two ranks (a negative rank means "no transition direction").
//
// Re-exported from common/plans so api handlers don't need to import the
// shared package directly. The yearly variants are NOT auto-normalised —
// pass them through CanonicalTier first if you want "pro_yearly" to rank
// the same as "pro".
func Rank(tier string) int { return commonplans.Rank(tier) }